Event Types
All webhook bodies are application/x-www-form-urlencoded, so every value is a string. A field whose value is null arrives as the literal 4-character string "null" (not JSON null, not an absent key). Before parsing numbers, treat "null" and "" as absent — e.g. Number("null") is NaN, and if (contractAddress) is truthy for the string "null". Fields typed string | null below follow this rule.
Test events — the test field. A test webhook can be triggered for any event below — by you, via POST /session/:uuid/test-webhook, or by AbstraPay during onboarding. A test event is byte-for-byte identical to a real one — same fields, same HMAC signature — except it carries an extra test field with the string value "true". No funds have moved on-chain for a test event. Your handler MUST check for test === "true" and skip all real side effects (crediting a user, marking a withdrawal settled, updating balances) for those events — acknowledge with { "error": 0 } without applying ledger changes. Real (production) events never include the test field.
On a DEPOSIT test event the depositId is a negative sentinel (e.g. -1234567), derived deterministically from the session so repeat test sends of the same session carry the same id. Real deposits always have a positive depositId, so a negative value is an unambiguous "synthetic test" marker — never treat it as a real deposit id or persist it as one.
1. DEPOSIT
Sent when a user's deposit is confirmed on-chain and funds have been swept to the bankroll.
Delivery: Asynchronous, via a durable outbox — retried up to 5× with backoff, then dead-lettered (see Retries). Return { "error": 0 } to acknowledge; any non-zero error (or a non-2xx / non-JSON response) is treated as a failed delivery and retried — so acknowledge with 0 even for duplicates or events you choose to ignore.
Payload fields:
| Field | Type | Description |
|---|---|---|
event | string | DEPOSIT |
depositId | number | Internal deposit ID |
idempotencyKey | string | Unique deduplication key for this deposit |
externalUserId | string | The user identifier you provided when creating the deposit ticket |
sessionRef | string | null | Opaque handle for the session that produced this deposit — the same value returned when you created the session. Arrives as "null" when the deposit had no active session (a direct on-chain send); attribute by externalUserId + dedupe by idempotencyKey in that case |
sessionAmount | string | null | The expected deposit amount you set on /session/deposit, in your platform fiat (localCurrencyCode). Reconcile it against the received amountInLocalCurrency before crediting. "null" for a direct send with no session |
amount | string | Net raw amount that reached the bankroll (gross − fee), token's smallest unit |
amountInDollar | string | null | Net USD value — credit this to the user |
amountInLocalCurrency | string | null | Net value in your platform's local fiat (e.g. TRY). Equals amountInDollar when your platform currency is USD |
localCurrencyCode | string | Your platform's fiat code used for the conversion ("USD" when none is configured) |
currentRate | string | USD → localCurrencyCode rate used at settlement ("1" for USD) |
pair | string | Conversion pair, e.g. "USD/TRY" |
grossAmount | string | Raw amount the user actually sent on-chain |
feeAmount | string | Raw fee deducted on settlement ("0" when no fee) |
feeInDollar | string | null | USD value of the fee ("0.000000" when no fee) |
type | string | ERC20 or NATIVE |
contractAddress | string | null | Token contract address (null for native transfers) |
transactionHash | string | null | Inbound (deposit) transaction hash |
chainId | number | Chain ID where the deposit occurred |
confirmedAt | string | ISO 8601 timestamp |
Amounts: amount / amountInDollar are the net credited to you (fee already deducted). Reconciles as amount + feeAmount = grossAmount. With no fee configured, feeAmount = "0" and grossAmount = amount. Credit amountInDollar, not the raw amount.
Local currency: amountInLocalCurrency / currentRate are a snapshot taken at settlement in your platform's configured fiat, so you can book the deposit in your own currency without a second lookup. The rate can move afterwards — these values are the ones in effect when the deposit confirmed. When your platform currency is USD, localCurrencyCode = "USD", currentRate = "1", and amountInLocalCurrency = amountInDollar.
Expected response:
{
"error": 0,
"description": "ok",
"transactionId": "your-internal-id"
}Idempotency: Use idempotencyKey to deduplicate. You may receive the same deposit event more than once.
2. WITHDRAW_REQUEST
Sent to request your approval of a withdrawal. Delivered one of two ways depending on your operator's approval mode (see Withdrawals):
- Synchronous mode (default): sent before the withdrawal record is persisted, and the system waits for your response — return
error: 0to approve, non-zero to deny.withdrawRequestIdis populated here too. - External-approval mode: sent asynchronously (retried via our outbox) after the request is created as
AWAITING_APPROVAL. Your response only acknowledges receipt (error: 0); the real decision is a separate callback you make toPOST /withdraw/{withdrawRequestId}/decision.
Both modes send the identical event: "WITHDRAW_REQUEST" payload, and withdrawRequestId is always populated — use it as the single correlator across WITHDRAW_REQUEST → WITHDRAW_COMPLETE. In external-approval mode, always acknowledge with { "error": 0 } and drive the outcome from your callback.
Delivery: Synchronous (default mode) or asynchronous + retried (external-approval mode).
Payload fields:
| Field | Type | Description |
|---|---|---|
event | string | WITHDRAW_REQUEST |
withdrawRequestId | string | The withdrawal UUID — always populated (both modes). Use it in your /withdraw/{id}/decision callback (external-approval mode) and as the correlator to the later WITHDRAW_COMPLETE |
sessionRef | string | Opaque handle for the owning session — the same value returned when you created the withdraw session. A session-level grouping handle; for per-withdrawal correlation use withdrawRequestId |
externalUserId | string | The user identifier |
type | string | DIRECT_TRANSFER or OFF_RAMP |
address | string | Destination wallet address |
cryptoCurrencyCode | string | The token that will actually be sent, e.g. USDT, ETH |
fiatCurrencyCode | string | e.g. USD, EUR |
fiatAmount | string | The operator-set fiat amount from the withdraw session (what you sent on /session/withdraw) |
cryptoAmount | string | null | Always "null" — the withdrawal is denominated in the operator-set fiat fiatAmount; the crypto to send is cryptoAmountSent |
cryptoAmountSent | string | null | The crypto amount that will actually be sent on-chain — reconcile your token ledger against this |
amountInUsd | string | null | USD value — check this against the user's balance |
requestedAt | string | ISO 8601 timestamp |
To approve - return:
{
"error": 0,
"description": "ok"
}To deny - return a non-zero error code:
{
"error": 5,
"description": "Daily withdrawal limit exceeded"
}Error codes:
| Code | Message Shown to User |
|---|---|
0 | (approved) |
1 | Withdrawal request declined by operator |
2 | Insufficient balance for withdrawal |
3 | Withdrawal temporarily unavailable |
4 | User is not eligible for withdrawal |
5 | Daily withdrawal limit exceeded |
6 | Withdrawal amount too low |
7 | Withdrawal amount too high |
8 | Account verification required |
9 | Withdrawal suspended for this account |
Any other non-zero code will show the description field from your response as the error message.
3. WITHDRAW_COMPLETE
Sent after a withdrawal has been completed (funds paid out on-chain).
Delivery: Asynchronous, via a durable outbox — retried up to 5× with backoff, then dead-lettered (see Retries). Return { "error": 0 } to acknowledge; any non-zero error (or a non-2xx / non-JSON response) is treated as a failed delivery and retried, so acknowledge even for duplicates.
Payload fields:
| Field | Type | Description |
|---|---|---|
event | string | WITHDRAW_COMPLETE |
withdrawRequestId | string | The withdrawal UUID — the same id sent on the WITHDRAW_REQUEST event (always populated), so you can correlate a completion back to the request you approved |
sessionRef | string | Opaque handle for the owning session — the same value returned when you created the withdraw session. Provided for integrations that group by session |
externalUserId | string | The user identifier |
type | string | DIRECT_TRANSFER or OFF_RAMP |
status | string | COMPLETED |
address | string | Destination wallet address |
cryptoCurrencyCode | string | The token sent, e.g. USDT, ETH |
fiatCurrencyCode | string | e.g. USD, EUR |
fiatAmount | string | The operator-set fiat amount from the withdraw session |
cryptoAmount | string | null | Always "null" — the crypto sent is cryptoAmountSent |
cryptoAmountSent | string | null | The crypto amount actually sent on-chain (matches WITHDRAW_REQUEST.cryptoAmountSent) — reconcile your token ledger against this |
amountInUsd | string | null | USD value of the withdrawal |
completedAt | string | ISO 8601 timestamp |
Expected response:
{
"error": 0,
"description": "ok"
}Headless withdrawals (see Withdrawals → Mode C) send exactly this WITHDRAW_COMPLETE payload — same fields, same units, same signature — so one handler serves both the widget and the headless flow. Correlate by withdrawRequestId or by sessionRef (the handle returned when you created the intent). A headless completion fires only after the chain-configured confirmation threshold is reached — it is your debit trigger.
4. WITHDRAW_FAILED (headless only)
Sent when a headless withdrawal fails. Delivered asynchronously via the durable outbox (same retry/dedup as WITHDRAW_COMPLETE).
retryable: "true" means the failure is NOT terminal. The withdrawal may still reach a later WITHDRAW_COMPLETE, a terminal WITHDRAW_FAILED { retryable:"false" }, or a WITHDRAW_CANCELLED. Keep the user's reserve on a retryable failure — release it only on retryable:"false" or WITHDRAW_CANCELLED. This is an accounting signal about the withdrawal, entirely separate from the outbox's own webhook delivery-retry (see Retries).
Payload fields — the WITHDRAW_COMPLETE shape (same fields, same units) with status: "FAILED", no completedAt, plus the two fields marked headless-only:
| Field | Type | Description |
|---|---|---|
event | string | WITHDRAW_FAILED |
withdrawRequestId | string | The withdrawal UUID |
sessionRef | string | The owning session handle — the value returned when you created the headless intent |
externalUserId | string | The user identifier |
type | string | DIRECT_TRANSFER |
status | string | FAILED |
address | string | Destination wallet address |
cryptoCurrencyCode | string | The token, e.g. USDC |
fiatCurrencyCode | string | e.g. USD |
fiatAmount | string | The fiat amount you locked on the intent |
cryptoAmount | string | null | Always "null" — the withdrawal is denominated in fiatAmount |
cryptoAmountSent | string | null | The crypto amount that was to be sent, in token units (8 decimals). "null" if the intent was never priced (failed before execute) |
amountInUsd | string | null | USD value. "null" if the intent was never priced |
retryable | string | headless-only. "true" = non-terminal, keep the reserve; "false" = terminal, release the reserve |
reasonCode | string | headless-only. Stable safe reason code for the failure |
eventId | string | headless-only. Stable per event — deduplicate on this. A redelivery carries the identical eventId and body. Needed because one withdrawal can legitimately emit WITHDRAW_FAILED more than once (a retryable:"true" first, a terminal retryable:"false" later) |
Expected response: { "error": 0, "description": "ok" } — acknowledge (even duplicates). Acknowledging does not decide the outcome; drive your ledger from retryable.
5. WITHDRAW_CANCELLED (headless only)
Sent when a headless withdrawal is cancelled before any on-chain broadcast (via POST /withdrawals/{sessionRef}/cancel, or an equivalent terminal cancellation). Delivered asynchronously via the durable outbox.
Cancellation is terminal and releases the reserve — the reservation is freed and no payout was or will be sent.
Payload fields: the same shape as WITHDRAW_FAILED with event: "WITHDRAW_CANCELLED", status: "CANCELLED", a reasonCode and an eventId, and no retryable field (the event itself is terminal). cryptoAmountSent / amountInUsd are "null" when the intent was cancelled before it was priced — the common case.
Expected response: { "error": 0, "description": "ok" }.
Correlation & dedup for headless events. All three headless terminal events (WITHDRAW_COMPLETE, WITHDRAW_FAILED, WITHDRAW_CANCELLED) carry the same withdrawRequestId and sessionRef. Deduplicate WITHDRAW_COMPLETE on withdrawRequestId (it fires at most once per withdrawal, exactly like the widget's) and WITHDRAW_FAILED / WITHDRAW_CANCELLED on eventId — never on withdrawRequestId alone, because one withdrawal can emit WITHDRAW_FAILED more than once (a non-terminal retryable:"true", later a terminal retryable:"false" that releases the reserve). A retried delivery is byte-identical (same eventId, same body, same signature). Ordering is never required for correct accounting: WITHDRAW_COMPLETE, WITHDRAW_FAILED { retryable:"false" } and WITHDRAW_CANCELLED are each terminal, and a WITHDRAW_FAILED { retryable:"true" } asks nothing of you — so a late one arriving after the terminal event is harmless.