Skip to main content

Callbacks

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 — keep it private. Your callback endpoint must:
  • Accept POST requests with JSON body
  • Respond with HTTP 200 within 15 seconds
  • Be publicly accessible (no auth required from our side)

Callback Payload

The body of every state-change callback is the full trade object directly:
{
  "trade": {
    "id": "trade-uuid",
    "type": "deposit",
    "source": "client",
    "status": "completed",
    "game": "730",
    "externalId": "your-tracking-id",
    "merchantId": "merchant-uuid",
    "clientUserId": "client-uuid",
    "clientSteamID": "76561198012345678",
    "clientTradeUrl": "https://steamcommunity.com/tradeoffer/new/?partner=...",
    "items": [...],
    "totalPrice": 10.75,
    "preCredit": 8.60,
    "pendingCredit": 2.15,
    "isInstant": true,
    "holdEndDate": "2026-03-11T10:00:00.000Z",
    "createdAt": "2026-03-04T10:00:00.000Z",
    "updatedAt": "2026-03-11T10:00:00.000Z"
  }
}
FieldDescription
tradeFull Trade object with current state
trade.statusThe trade’s new status — this is the event you’re being notified about
trade.source"client" (end-user via /client/trading/*) or "self" (merchant self-trade via /secure/*). The same callback URL fires for both.
The “event” you’re handling is trade.status — there is no separate event field on the body.
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

X-AssetPay-Signature: t=2026-03-11T10:00:00.000Z,id=<delivery-id>,s=<hex-signature>
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>.<timestamp>.<rawBody>
  • 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

import { createHmac, timingSafeEqual } from 'crypto';

const TOLERANCE_MS = 5 * 60_000; // 5 minutes

function verifyAssetPaySignature(
  header: string | undefined,
  rawBody: string,
  acceptedSecrets: string[],
): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(',').map(p => {
      const idx = p.indexOf('=');
      return idx === -1 ? [p, ''] : [p.slice(0, idx).trim(), p.slice(idx + 1).trim()];
    }),
  );
  const t = parts.t;
  const deliveryId = parts.id;
  const sig = parts.s ?? parts.s1;
  if (!t || !deliveryId || !sig) return false;

  const ts = Date.parse(t);
  if (Number.isNaN(ts)) return false;
  if (Math.abs(Date.now() - ts) > TOLERANCE_MS) return false;

  const sigBuf = Buffer.from(sig);
  for (const secret of acceptedSecrets) {
    if (!secret) continue;
    const expected = Buffer.from(
      createHmac('sha256', secret)
        .update(`${deliveryId}.${t}.${rawBody}`)
        .digest('hex'),
    );
    if (expected.length === sigBuf.length && timingSafeEqual(expected, sigBuf)) {
      return true;
    }
  }
  return false;
}

// Usage in an Express handler (keep the raw body!)
app.post(
  '/assetpay/callback',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8');
    if (!verifyAssetPaySignature(req.header('X-AssetPay-Signature'), rawBody, [YOUR_API_SECRET])) {
      return res.status(401).send('Invalid signature');
    }
    const { trade } = JSON.parse(rawBody);
    // ... handle trade.status, trade.type, etc.
    res.status(200).send('OK');
  },
);
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 every status transition.

Deposit Callbacks

trade.statusWhat to Do
initiatedTrade created, no Steam offer sent yet. Acknowledge with 200. No balance action.
activeSteam trade offer sent to the user — awaiting their acceptance. Acknowledge with 200.
holdUser accepted, items received, Steam hold started. Credit preCredit if using instant deposits; otherwise just acknowledge.
completedHold ended cleanly (or no hold required for Rust). Credit pendingCredit (or totalPrice if not using instant deposits).
failedTrade did not go through. No credit needed. Optionally notify the user.
canceledTrade was canceled before any state was applied (incl. an unaccepted offer auto-canceled past its window). No action needed.
declinedThe end user declined the Steam trade offer. No items received, no credit needed. Optionally notify the user.
revertedA previously completed deposit was reversed. Reverse any preCredit + pendingCredit already credited. Check revertedBy.

Withdrawal Callbacks

trade.statusWhat to Do
initiatedApproval gate — see below. The merchant’s wallet hasn’t been debited yet; your response decides whether the withdrawal proceeds.
pendingSupplier is sourcing the item. Acknowledge with 200. No balance action.
activeSteam trade offer sent to the user — awaiting acceptance (offerID now present). Acknowledge with 200.
holdItems sourced and Steam trade offer accepted by the user — hold period running (offerID + holdEndDate present). Acknowledge with 200.
completedHold ended cleanly. No action needed.
failedTrade did not go through. Refund totalPrice to the user’s balance if you previously deducted it.
canceledTrade was canceled before processing. Refund totalPrice to the user’s balance if you previously deducted it.
revertedA previously completed withdrawal was reversed (by supplier or user). Refund totalPrice. Check revertedBy.
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": { ... }
}
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 configured — withdrawals fail immediately with MERCHANT_NO_CALLBACK_URL (1705) if none is registered. 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):
HTTP/1.1 402 Payment Required
Content-Type: application/json

{ "reason": "Insufficient balance" }
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:
{ "action": "reject", "reason": "Insufficient balance" }
Also accepted: { "status": "rejected" }, { "errorCode": "INSUFFICIENT_BALANCE" }, { "code": "INSUFFICIENT_BALANCE" }. In either case, the trade is marked failed, the merchant wallet is refunded automatically, and you receive a follow-up failed callback.
Only 4xx responses or one of the explicit rejection bodies above are treated as intentional rejections. A plain 200 with any other body counts as approval. 5xx responses and timeouts are treated as delivery failures and retried.

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:
app.post('/assetpay/callback', async (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!verifyAssetPaySignature(req.header('X-AssetPay-Signature'), rawBody, [API_SECRET])) {
    return res.status(401).send('Invalid signature');
  }
  const { trade } = JSON.parse(rawBody);

  const processed = await db.processedCallbacks.findOne({
    tradeId: trade.id,
    event: trade.status,
  });
  if (processed) {
    return res.status(200).send('Already processed');
  }

  switch (trade.type) {
    case 'deposit':
      await handleDepositEvent(trade);
      break;
    case 'withdraw':
      await handleWithdrawalEvent(trade);
      break;
  }

  await db.processedCallbacks.create({
    tradeId: trade.id,
    event: trade.status,
    processedAt: new Date(),
  });

  res.status(200).send('OK');
});

Retry Behavior

If your endpoint returns a non-2xx status, times out, or doesn’t respond within 15 seconds, AssetPay retries the callback:
  • Total attempts: 11 (initial + 10 retries)
  • Backoff schedule: 30s, 30s, 5m, 5m, 15m, 15m, 1h, 1h, 6h, 6h
  • Total window: ~14h 31m 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. Delivery records are retained for 14 days for debugging — you can view callback history and manually retry failed deliveries from the AssetPay dashboard.

Callback Example: Complete Deposit Flow

Here’s the sequence of callbacks you’d receive for a typical CS2 deposit:
1. initiated  →  Trade created, offer about to be sent
2. active     →  Steam trade offer sent to user (sitting in their inbox)
3. hold       →  User accepted, items received, 7-day hold started (credit preCredit here)
4. completed  →  Hold ended, no reversal (credit pendingCredit here)
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 a reversal happens after completed, you’ll get reverted. Rust deposits skip the hold step entirely and go straight from active to completed.

Callback Example: Complete Withdrawal Flow

Here’s the sequence of callbacks you’d receive for a typical CS2 withdrawal:
1. initiated  →  Approval gate — deduct balance and respond 2xx to approve
2. pending    →  Supplier sourcing the item
3. active     →  Steam trade offer sent to the user (offerID now present)
4. hold       →  User accepted, Steam hold running (offerID + holdEndDate present)
5. completed  →  Hold ended, delivery final
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.