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 total this settlement credited, before the fee. Usually one transfer, but a sub-minimum transfer is held in the deposit address until later transfers push the total over the minimum — then all of them are swept and credited in one event, and grossAmount is their sum (see the callout below) |
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. When one event covers several held transfers, this is the transfer that triggered the settlement — so it can be smaller than grossAmount |
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.
One event can cover several transfers. Each chain/token has a minimum on-chain
amount. A transfer below it is not swept — it stays in the user's deposit address
and produces no event. When a later transfer brings the address's untouched total
to or above the minimum, everything there is swept in a single transaction and
credited in a single event: grossAmount is the sum, and transactionHash
names only the transfer that triggered it.
So grossAmount can exceed the value of transactionHash, and a user may deposit
without you hearing anything until they top up. Reconcile against grossAmount,
never against the on-chain value of transactionHash alone. idempotencyKey still
identifies the event exactly once.
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.
Treat this key as opaque: store it verbatim, do not derive or parse it from a transaction hash. New Tron token deposit keys distinguish the chain, transaction, recipient and token. Different recipients or tokens in one transaction produce distinct deposits and keys. Multiple transfers of the same token to the same recipient within one Tron transaction are still treated as one deposit. Existing queued events retain their original key and payload on retry; EVM/native keys and callback fields are unchanged.
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.
Deposit recovery and combined credits
A normal/force sweep may carry several previously held deposits in one transfer. The single DEPOSIT callback reports that sweep's recorded grossAmount, net amount, and feeAmount; do not separately credit the covered deposits. Retry uses the same recorded ownership, amounts, session correlation and callback identity, without adding later deposits. Treat Tron callback keys as opaque values; never reconstruct or parse them.
An admin refund is an operational return, not a withdrawal or another deposit credit. A held deposit swept specifically for a refund does not generate a DEPOSIT credit. An uncertain refund/sweep is held for operational verification; HTTP 503 from an admin action does not prove no funds moved and must not trigger a new transfer. Operator callback request/payload shapes are unchanged.
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.