Setup
Configure your callback URL in the AssetPay dashboard under Settings. Your API secret is used to sign every webhook, so keep it private. Your callback endpoint must:- Accept POST requests with JSON body
- Respond with any HTTP
2xxwithin 15 seconds (10 seconds for the withdrawal approval callback) - Be publicly accessible (no auth required from our side)
Callback Payload
The body of every state-change callback is the full trade object directly:
The “event” you’re handling is
trade.status; there is no separate event field on the body.
Crypto callbacks
Crypto deposits and cashouts use the same callback URL and the same signature, with a different top-level key instead oftrade:
Branch on the key before reading anything else. The objects and the cashout approval call are described in Crypto Deposits & Cashouts.
The withdrawal
initiated callback acts as an approval gate: your handler must explicitly approve or reject it. See The initiated callback for withdrawals below.Signature Verification
Every webhook is signed with HMAC-SHA256 using your API secret. The signature is delivered in theX-AssetPay-Signature header, not in the body.
Header format
s1=<previous-hex-signature> so callbacks signed with the prior secret remain verifiable during the grace window.
What’s signed
The HMAC-SHA256 message is the literal string:deliveryId: value from theid=field in the headertimestamp: value from thet=field (ISO 8601)rawBody: the raw request body bytes, exactly as received. Do not re-serialize the parsed JSON; parsers may reorder keys or change spacing and break the HMAC.
Verification example
Handling Events
Theevent you’re handling is the new value of trade.status. AssetPay dispatches a webhook on status transitions, with two exceptions:
activeis withheld until the trade has a Steam offer id, and is sent once the id lands.- A
pendingcallback is dropped if the trade has already moved pastpendingwhen the callback is queued.
pending can still land after active. Ignore a callback whose status is earlier in the flow than one you have already processed for that trade.
Deposit Callbacks
Deposit callbacks carry
preCredit from creation and pendingCredit once the trade enters hold (on Rust, once it reaches completed, where preCredit equals totalPrice and pendingCredit is 0). They never include collateral; that field is only on the deposit creation response.
Withdrawal Callbacks
A withdrawal moves
initiated → pending → active → hold → completed. The intermediate statuses (pending, active) are progress signals and may be skipped depending on the provider and delivery mode, so drive balance changes off initiated and the terminal states.
The initiated callback for withdrawals
This is the most important callback in the withdrawal flow. When a withdrawal is initiated, AssetPay sends you this callback before purchasing anything. Your backend decides whether the withdrawal proceeds.
The approval callback body has the same shape as every other state callback:
trade.type === "withdraw" and trade.status === "initiated". The signature header and signing rules are also identical to regular callbacks: same X-AssetPay-Signature: t=...,id=...,s=... format, same <deliveryId>.<timestamp>.<rawBody> HMAC input.
Your handler should:
- Verify the signature
- Look up the user by
trade.clientSteamIDortrade.externalClientUserId - Check if they can afford
trade.totalPrice - If yes: deduct the balance and respond with
2xx - If no: respond with a rejection (see below)
Self-trade withdrawals (
source === "self") also go through this gate. The merchant must have a callback URL and an API secret configured. Withdrawal requests fail immediately with MERCHANT_NO_CALLBACK_URL (1705) if no callback URL is registered, MERCHANT_CALLBACK_URL_DISABLED (1706) if it was auto-disabled, or MERCHANT_NO_API_SECRET (1707) if there is no API secret. If you want to auto-approve all self-trades, your handler can return 200 whenever trade.source === "self".Rejecting a Withdrawal
The withdrawal approval gate accepts several rejection signals: Option 1: HTTP 4xx (recommended)4xx status works (400, 402, 403, 409, etc.). The reason field is optional but recommended; it appears in AssetPay logs for debugging.
Option 2: HTTP 2xx with a rejection body
{ "status": "rejected" }, { "errorCode": "INSUFFICIENT_BALANCE" }, { "code": "INSUFFICIENT_BALANCE" }.
In either case, the trade is marked failed with error: "MERCHANT_REJECTED", the merchant wallet is refunded automatically, and you receive a follow-up failed callback.
Only
4xx responses (other than 408 and 429) or one of the explicit rejection bodies above are treated as intentional rejections. A plain 2xx with any other body counts as approval. 5xx responses, 408, 429 and timeouts (10 seconds per attempt) are retried, but only briefly: the approval callback gets 3 attempts in total, 5 seconds and then 10 seconds apart. If none succeeds, or an attempt is cut short on AssetPay’s side (which ends the gate without the remaining attempts), the trade goes to failed with error: "APPROVAL_TIMEOUT" and the merchant wallet is refunded.Idempotency
You may receive the same callback more than once (network retries, duplicate deliveries). Your handler should be idempotent. Track processed (trade, status) pairs and skip duplicates:Retry Behavior
If your endpoint returns a non-2xx status, times out, or doesn’t respond within 15 seconds, AssetPay retries the callback. This schedule applies to state-change callbacks; the withdrawal approval callback has its own short schedule (see above).- Total attempts: 10 (initial + 9 retries)
- Backoff schedule:
30s, 5m, 5m, 15m, 15m, 1h, 1h, 6h, 6h - Total window: ~14h 40m from the first attempt to the last retry
failed, canceled or declined trade can be deleted together with its delivery records 30 days after its last update.
Callback Example: Complete Deposit Flow
Here’s the sequence of callbacks you’d receive for a typical CS2 deposit:pending is a progress signal only and can be skipped, so never gate logic on receiving it. If something goes wrong at any step, you’ll get a failed callback instead of the next step. If the user declines the offer you’ll get declined, and if it’s auto-canceled after sitting unaccepted you’ll get canceled. If the items are clawed back you’ll get reverted, normally out of hold, while the 7-day window is still open, so reverse the preCredit you already applied. Rust deposits skip the hold step entirely and go straight from active to completed, where you credit totalPrice.
Callback Example: Complete Withdrawal Flow
Here’s the sequence of callbacks you’d receive for a typical CS2 withdrawal:initiated callback or the supplier purchase fails, you’ll get a failed callback (refund the user’s balance). A reversal after hold surfaces as reverted. Rust withdrawals normally skip hold: Rust has no 7-day reversal window, so they usually go active → completed directly with no holdEndDate (a Steam security escrow is the exception and surfaces as hold). Act on initiated and the terminal states rather than expecting every step.