Docs menu
Guides
API reference
POST /api/v1/events/withdrawal
Screen a withdrawal. Check a bonus claim or a withdrawal. Generated from the OpenAPI file.
Last updated
Overview
POST https://api.inlineshield.io/api/v1/events/withdrawal
Screens a withdrawal before you pay it out and returns the decision, the action to take and the reasons, in the shape of /verify.
Send the player's accountId, the amount and the payout account as a payout fingerprint, never as a card number, IBAN or wallet address: a raw card number or IBAN is refused with 400 RAW_PAYMENT_DATA. InlineShield stores only a keyed hash of the fingerprint, and uses it to see whether other accounts of yours withdrew to the same payout account in the last 180 days. An account with no sign-up bound still gets an answer, with the reason "Sign-up not linked to this account" and no penalty.
action:allow(pay),hold(do not pay until the review is decided; a live hold has areviewId),block.recommendedAction:allow,review,denyorblock.- The rules in Settings › Rules › Bonus and withdrawal checks only hold or advise.
blockcomes only when the IP and bot checks of your sign-up rules would block the event.
The lookups share one budget of 150 ms; one that has not finished by then is treated as unknown, never as a signal, and the reasons say so. A live event counts once against the monthly quota, like a /verify screening; a test key never counts. Limited per key like /siteverify (the key's own per-minute limit: 1,200 for a live key as created, 600 for a key without a limit of its own); both event endpoints share that bucket. A test key uses the account's own test bucket instead, 300 per minute for both event endpoints, and the daily test cap. Test mode creates no review, webhook, label or alert, and never links accounts.
Authentication
Send your secret API key in the X-Api-Key header.
Your secret API key (bs_live_… or bs_test_…), from the console under Settings › Keys. Never put it in a browser or an app. The header name X-ApiKey is also accepted (deprecated).
Request body
JSON (Content-Type: application/json), required.
Other fields are accepted and ignored.
| Field | Type | Required | Description |
|---|---|---|---|
accountId | string | yes | Your own id for the player account (1 to 128 printable characters). It is stored as you send it, shown masked in the console (the first 2 characters and ***) and sent back in the webhooks. If the account has a sign-up bound to it (by accountId on /verify or /siteverify, or by signupRequestId below), the check compares the event with that sign-up. An unbound account is still answered, with the reason "Sign-up not linked to this account" and no penalty. Anything else gets 400 INVALID_ACCOUNT_ID.at least 1 character; at most 128 characters |
ip | string | no | The player's IP address (IPv4 or IPv6) for this event. Without it the IP checks do not run: the request's own address is your server's, so it is never used. Anything that is not an IP address gets 400 INVALID_IP. |
userAgent | string | no | The player's browser User-Agent. Only this field is read; a scripted HTTP client here counts as an automation signal.at most 1024 characters |
amount | number | yes | The withdrawal amount, 0 or more. Stored and shown in the console; it does not change the score.0 to 1000000000000000 |
currency | string | no | The ISO 4217 code of amount: 3 letters, stored in upper case.at most 3 characters; matches ^[A-Za-z]{3}$ |
payout | object | yes | The account the money goes to, as a fingerprint, never as the card number, IBAN or wallet itself. A value that looks like a raw card number or IBAN is refused with 400 RAW_PAYMENT_DATA, whatever type says. InlineShield stores only a keyed hash of the fingerprint. |
payout.type | string | yes | What kind of account it is. The same fingerprint with another type is another payout account.Values: card, iban, wallet, other |
payout.fingerprint | string | yes | Your payment provider's fingerprint of the account (for example Stripe's fp_…), or a value you compute yourself with a secret key. 16 to 128 characters of A-Z, a-z, 0-9, _, -, : and .. It must be the same string every time the same account is used.at least 16 characters; at most 128 characters; matches ^[A-Za-z0-9_\-:.]+$ |
token | string | no | The player's sign-up /check token (the one the browser script got at sign-up), for the browser's time zone and automation signals. Usable only within its 10-minute life and only if it was not already redeemed with /siteverify. Never run /check on bonus or cashier pages: that records a new sign-up screening, counts against the quota and creates false links. Without a token the event is screened from server-side data only. The event spends the token: a later /siteverify with the same token answers TOKEN_ALREADY_USED. A token of the other mode (test or live) is left unused, and a token that is unknown, used or expired is ignored.at most 512 characters |
signupRequestId | string | no | The requestId (AUD-…) that the sign-up's /verify, /check or /siteverify returned. It binds the account to that sign-up, but only while the account has none yet: the first binding stays. A sign-up of another account, mode or organisation never binds.at most 128 characters |
Request examples
{
"accountId": "player-2087",
"ip": "203.0.113.24",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"amount": 120,
"currency": "EUR",
"payout": {
"type": "wallet",
"fingerprint": "3f9a1c7e5b2d48a0b6c1e9d4f7a28b35"
}
}The first example as a cURL command:
curl -X POST https://api.inlineshield.io/api/v1/events/withdrawal \
-H "X-Api-Key: $INLINESHIELD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "payout-shared@test",
"ip": "203.0.113.24",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"amount": 250,
"currency": "EUR",
"payout": {
"type": "card",
"fingerprint": "fp_1Nq8Zt2eZvKYlo2CxY7wB3aD"
}
}'Responses
200 OK
The decision. A live hold also has reviewId. Live responses carry the quota headers; test responses do not.
Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-Quota-Limit (optional), X-Quota-Used (optional), X-Quota-Reset (optional), X-Quota-State (optional).
| Field | Type | Required | Description |
|---|---|---|---|
requestId | string | yes | The event's id, starting with EVT-. Use it for support; the webhooks carry it too.matches ^EVT-[0-9A-F]{16}$ |
decision | string | yes | ALLOWED, QUARANTINED (a rule of Settings › Rules held it) or BLOCKED (your sign-up rules would block this IP or client).Values: ALLOWED, QUARANTINED, BLOCKED |
action | string | yes | What to do with the event: ALLOWED → allow (pay), QUARANTINED → hold (do not pay until the review is decided), BLOCKED → block.Values: allow, hold, block |
recommendedAction | string | yes | Advice next to action. Bonus claims: deny_bonus (do not pay this bonus) when another account of the same person already claimed one and the rule "Recommend deny_bonus" is on, or when the score is 50 or more; else review when the claim is held, else allow. Withdrawals: review when held, deny (do not pay) at a score of 70 or more, else allow. Both: block when the decision is BLOCKED.Values: allow, review, deny_bonus, deny, block |
reviewId | string | no | Live holds only (action: "hold"): the review to poll with GET /api/v1/reviews/{reviewId} or wait for in the bonus.approved / bonus.rejected (withdrawal.*) webhook. A held test event has none.matches ^rev_ |
riskScore | number | yes | 0 (lowest risk) to 100.0 to 100 |
reasons | array of string | yes | Plain-language reasons for the result. |
triggers | array of string | yes | Machine-readable signal codes: EVENT_LINKED_TO_FRAUD, EVENT_COUNTRY_CHANGED, EVENT_DATACENTER_OR_VPN_IP, EVENT_TOR_IP, EVENT_TIMEZONE_MISMATCH, EVENT_BOT_SIGNALS, SIGNUP_WAS_FLAGGED, BONUS_ALREADY_CLAIMED_IN_GROUP, PAYOUT_SHARED, PAYOUT_SHARED_WITH_FRAUD, BONUS_THEN_CASHOUT and WITHDRAWAL_VELOCITY; TEST_MAGIC_ADDRESS on a magic test account id. New codes may be added. |
linked | Linked or null | yes | The linked-accounts summary of the account's bound sign-up (see Linked), including the links that a withdrawal with a shared payout account just added (same_payout), or null: no bound sign-up, no links, test mode, or the lookup did not finish within its budget. |
linked.groupId | string or null | yes | The linked-accounts group of this screening, as the console names it. null when there are only possible links (they never form a group). Groups can merge later, and then the merged group keeps one of the ids, so a later screening can report another id for the same people. In rare cases (the links could not be stored) the console has no group with this id.matches ^lg_[A-Za-z0-9_-]{16}$ |
linked.size | integer | yes | The number of screenings in the group, this one included. With only possible links: 1 plus the number of possibly linked screenings.at least 1 |
linked.strength | string | yes | strong when at least one link is strong, otherwise possible.Values: strong, possible |
linked.linkedToFraud | boolean | yes | True when at least one strong link goes to a screening that was BLOCKED or that you reported as fraud (feedback, a rejected review or a mark in the console). It is the condition of the Linked-to-fraud rule (Settings › Rules, off by default), and is reported whether the rule is on or off. |
{
"requestId": "EVT-71E5B3C90D4A2F86",
"decision": "ALLOWED",
"action": "allow",
"recommendedAction": "allow",
"riskScore": 0,
"reasons": [
"Sign-up not linked to this account"
],
"triggers": [],
"linked": null
}400 Bad Request
A field is missing or invalid (field names it): accountId (INVALID_ACCOUNT_ID), ip (INVALID_IP), a text field over its maxLength, amount, currency, payout (a raw card number or IBAN: RAW_PAYMENT_DATA), or a body that is not valid JSON (no field). Nothing is screened, stored or counted.
Headers: X-RateLimit-Limit (optional), X-RateLimit-Remaining (optional), X-RateLimit-Reset (optional).
| Field | Type | Required | Description |
|---|---|---|---|
error | string | yes | Values: BAD_REQUEST, INVALID_ACCOUNT_ID, INVALID_IP, RAW_PAYMENT_DATA |
message | string | yes | |
field | string | no | The request field that was refused, when one field is to blame (for example payout, amount or accountId). |
{
"error": "RAW_PAYMENT_DATA",
"message": "payout.fingerprint looks like a raw card number or IBAN. Send the payment provider's fingerprint or the SDK's payoutFingerprint(), never raw payment data.",
"field": "payout"
}{
"error": "BAD_REQUEST",
"message": "Field \"amount\" is required: a number of 0 or more.",
"field": "amount"
}{
"error": "INVALID_ACCOUNT_ID",
"message": "accountId must be 1-128 printable characters.",
"field": "accountId"
}401 Unauthorized
No X-Api-Key header, or an unknown, revoked or expired key.
| Field | Type | Required | Description |
|---|---|---|---|
error | string | yes | Values: UNAUTHORIZED, INVALID_API_KEY, EXPIRED_API_KEY, ORGANIZATION_NOT_FOUND |
message | string | yes |
{
"error": "UNAUTHORIZED",
"message": "Access denied: Missing required X-Api-Key header."
}{
"error": "INVALID_API_KEY",
"message": "Invalid API key provided."
}402 Payment Required
The monthly quota is used up. Test requests of an account over its quota get this too (they are still not counted).
Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.
| Field | Type | Required | Description |
|---|---|---|---|
error | "QUOTA_EXCEEDED" | yes | |
message | string | yes | |
used | integer | yes | at least 0 |
limit | integer | yes | at least 0 |
resetsAt | string (date-time) | yes | The start of the next UTC month. |
403 Forbidden
The account is suspended.
| Field | Type | Required | Description |
|---|---|---|---|
error | string | yes | Values: TENANT_SUSPENDED |
message | string | yes |
429 Too Many Requests
RATE_LIMIT_EXCEEDED: the per-minute limit (the key's own limit; 300 for a test key's account bucket). TEST_LIMIT: the daily cap of test screenings of the account; Retry-After is the time to the next UTC midnight.
Headers: Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.
| Field | Type | Required | Description |
|---|---|---|---|
error | string | yes | Values: RATE_LIMIT_EXCEEDED, TEST_LIMIT |
message | string | yes |
{
"error": "RATE_LIMIT_EXCEEDED",
"message": "Event rate limit of 1200 req/min exceeded. Try again in 12 seconds."
}500 Internal Server Error
The event was screened but could not be stored (PERSISTENCE_FAILURE), or the screening failed (EVENT_ERROR). Nothing is counted. Your integration decides whether to pay or wait.
Headers: X-RateLimit-Limit (optional), X-RateLimit-Remaining (optional), X-RateLimit-Reset (optional).
| Field | Type | Required | Description |
|---|---|---|---|
error | string | yes | Values: PERSISTENCE_FAILURE, EVENT_ERROR, AUTH_ERROR |
message | string | yes |
503 Service Unavailable
The key could not be checked right now. Try again.
| Field | Type | Required | Description |
|---|---|---|---|
error | string | yes | Values: DATABASE_ERROR |
message | string | yes |
Errors
Errors are JSON, { "error": "CODE", "message": "…" }. Branch on error, never on message.
| HTTP status | error | When |
|---|---|---|
400 | BAD_REQUEST, INVALID_ACCOUNT_ID, INVALID_IP, RAW_PAYMENT_DATA | A field is missing or invalid (field names it): accountId (INVALID_ACCOUNT_ID), ip (INVALID_IP), a text field over its maxLength, amount, currency, payout (a raw card number or IBAN: RAW_PAYMENT_DATA), or a body that is not valid JSON (no field). Nothing is screened, stored or counted. |
401 | UNAUTHORIZED, INVALID_API_KEY, EXPIRED_API_KEY, ORGANIZATION_NOT_FOUND | No X-Api-Key header, or an unknown, revoked or expired key. |
402 | QUOTA_EXCEEDED | The monthly quota is used up. Test requests of an account over its quota get this too (they are still not counted). |
403 | TENANT_SUSPENDED | The account is suspended. |
429 | RATE_LIMIT_EXCEEDED, TEST_LIMIT | RATE_LIMIT_EXCEEDED: the per-minute limit (the key's own limit; 300 for a test key's account bucket). TEST_LIMIT: the daily cap of test screenings of the account; Retry-After is the time to the next UTC midnight. |
500 | PERSISTENCE_FAILURE, EVENT_ERROR, AUTH_ERROR | The event was screened but could not be stored (PERSISTENCE_FAILURE), or the screening failed (EVENT_ERROR). Nothing is counted. Your integration decides whether to pay or wait. |
503 | DATABASE_ERROR | The key could not be checked right now. Try again. |
See all errors for every endpoint.
Headers and limits
| Response header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current one-minute window. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Seconds until the current window ends. |
X-Quota-Limit | Live requests only: the monthly quota of the plan. |
X-Quota-Used | Live requests only: screenings counted this month, this one included. |
X-Quota-Reset | Live requests only: when the count resets (the start of the next UTC month). |
X-Quota-State | Live requests only: ok, warn (80% used), limit (100%), grace (over 100%, still served) or blocked. |
Retry-After | Seconds to wait before trying again. |
The limits per endpoint and plan are on Rate limits and quotas.