Skip to main content
AssetPay sends HTTP POST requests to your callback URL whenever a trade changes state. These webhooks are your primary mechanism for tracking trade progress and updating user balances.

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 2xx within 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 of trade: 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 the X-AssetPay-Signature header, not in the body.

Header format

If your API secret was recently rotated, the header may also include 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 the id= field in the header
  • timestamp: value from the t= 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

You must verify against the raw request body bytes, not the parsed-then-re-stringified JSON. Most HTTP frameworks need explicit configuration to expose the raw body (e.g. express.raw(), fastify-raw-body).
The 5-minute timestamp tolerance protects against replay attacks. Reject anything older.

Handling Events

The event you’re handling is the new value of trade.status. AssetPay dispatches a webhook on status transitions, with two exceptions:
  • active is withheld until the trade has a Steam offer id, and is sent once the id lands.
  • A pending callback is dropped if the trade has already moved past pending when the callback is queued.
Each delivery is retried independently (see Retry Behavior), so callbacks are not guaranteed to arrive in order: a retried 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:
Identify this as the approval gate by checking 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:
  1. Verify the signature
  2. Look up the user by trade.clientSteamID or trade.externalClientUserId
  3. Check if they can afford trade.totalPrice
  4. If yes: deduct the balance and respond with 2xx
  5. 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)
Any 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
Also accepted: { "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
If a callback URL fails continuously for 72 hours, it is automatically disabled and removed from rotation. You can view callback history and resend a delivery from the AssetPay dashboard (the withdrawal approval callback cannot be resent). Delivery records stay with their trade: a 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:
If you reject the 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.