Webhooks
Event Types

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:

FieldTypeDescription
eventstringDEPOSIT
depositIdnumberInternal deposit ID
idempotencyKeystringUnique deduplication key for this deposit
externalUserIdstringThe user identifier you provided when creating the deposit ticket
sessionRefstring | nullOpaque 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
sessionAmountstring | nullThe 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
amountstringNet raw amount that reached the bankroll (gross − fee), token's smallest unit
amountInDollarstring | nullNet USD value — credit this to the user
amountInLocalCurrencystring | nullNet value in your platform's local fiat (e.g. TRY). Equals amountInDollar when your platform currency is USD
localCurrencyCodestringYour platform's fiat code used for the conversion ("USD" when none is configured)
currentRatestringUSD → localCurrencyCode rate used at settlement ("1" for USD)
pairstringConversion pair, e.g. "USD/TRY"
grossAmountstringRaw amount the user actually sent on-chain
feeAmountstringRaw fee deducted on settlement ("0" when no fee)
feeInDollarstring | nullUSD value of the fee ("0.000000" when no fee)
typestringERC20 or NATIVE
contractAddressstring | nullToken contract address (null for native transfers)
transactionHashstring | nullInbound (deposit) transaction hash
chainIdnumberChain ID where the deposit occurred
confirmedAtstringISO 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: 0 to approve, non-zero to deny. withdrawRequestId is 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 to POST /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_REQUESTWITHDRAW_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:

FieldTypeDescription
eventstringWITHDRAW_REQUEST
withdrawRequestIdstringThe 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
sessionRefstringOpaque 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
externalUserIdstringThe user identifier
typestringDIRECT_TRANSFER or OFF_RAMP
addressstringDestination wallet address
cryptoCurrencyCodestringThe token that will actually be sent, e.g. USDT, ETH
fiatCurrencyCodestringe.g. USD, EUR
fiatAmountstringThe operator-set fiat amount from the withdraw session (what you sent on /session/withdraw)
cryptoAmountstring | nullAlways "null" — the withdrawal is denominated in the operator-set fiat fiatAmount; the crypto to send is cryptoAmountSent
cryptoAmountSentstring | nullThe crypto amount that will actually be sent on-chain — reconcile your token ledger against this
amountInUsdstring | nullUSD value — check this against the user's balance
requestedAtstringISO 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:

CodeMessage Shown to User
0(approved)
1Withdrawal request declined by operator
2Insufficient balance for withdrawal
3Withdrawal temporarily unavailable
4User is not eligible for withdrawal
5Daily withdrawal limit exceeded
6Withdrawal amount too low
7Withdrawal amount too high
8Account verification required
9Withdrawal 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:

FieldTypeDescription
eventstringWITHDRAW_COMPLETE
withdrawRequestIdstringThe 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
sessionRefstringOpaque handle for the owning session — the same value returned when you created the withdraw session. Provided for integrations that group by session
externalUserIdstringThe user identifier
typestringDIRECT_TRANSFER or OFF_RAMP
statusstringCOMPLETED
addressstringDestination wallet address
cryptoCurrencyCodestringThe token sent, e.g. USDT, ETH
fiatCurrencyCodestringe.g. USD, EUR
fiatAmountstringThe operator-set fiat amount from the withdraw session
cryptoAmountstring | nullAlways "null" — the crypto sent is cryptoAmountSent
cryptoAmountSentstring | nullThe crypto amount actually sent on-chain (matches WITHDRAW_REQUEST.cryptoAmountSent) — reconcile your token ledger against this
amountInUsdstring | nullUSD value of the withdrawal
completedAtstringISO 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:

FieldTypeDescription
eventstringWITHDRAW_FAILED
withdrawRequestIdstringThe withdrawal UUID
sessionRefstringThe owning session handle — the value returned when you created the headless intent
externalUserIdstringThe user identifier
typestringDIRECT_TRANSFER
statusstringFAILED
addressstringDestination wallet address
cryptoCurrencyCodestringThe token, e.g. USDC
fiatCurrencyCodestringe.g. USD
fiatAmountstringThe fiat amount you locked on the intent
cryptoAmountstring | nullAlways "null" — the withdrawal is denominated in fiatAmount
cryptoAmountSentstring | nullThe crypto amount that was to be sent, in token units (8 decimals). "null" if the intent was never priced (failed before execute)
amountInUsdstring | nullUSD value. "null" if the intent was never priced
retryablestringheadless-only. "true" = non-terminal, keep the reserve; "false" = terminal, release the reserve
reasonCodestringheadless-only. Stable safe reason code for the failure
eventIdstringheadless-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.