> ## Documentation Index
> Fetch the complete documentation index at: https://assetpay.gg/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Callbacks

> Receiving and processing webhook callbacks from AssetPay.

# 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:

```json theme={null}
{
  "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"
  }
}
```

| Field          | Description                                                                                                                            |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `trade`        | Full [Trade](/reference/types#trade) object with current state                                                                         |
| `trade.status` | The 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.

<Note>
  The withdrawal `initiated` callback acts as an approval gate — your handler must explicitly approve or reject it. See [The `initiated` callback for withdrawals](#the-initiated-callback-for-withdrawals) below.
</Note>

## 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

```typescript theme={null}
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');
  },
);
```

<Warning>
  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`).
</Warning>

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.status` | What to Do                                                                                                                   |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `initiated`    | Trade created, no Steam offer sent yet. Acknowledge with `200`. No balance action.                                           |
| `active`       | Steam trade offer **sent to the user** — awaiting their acceptance. Acknowledge with `200`.                                  |
| `hold`         | User accepted, items received, Steam hold started. Credit `preCredit` if using instant deposits; otherwise just acknowledge. |
| `completed`    | Hold ended cleanly (or no hold required for Rust). Credit `pendingCredit` (or `totalPrice` if not using instant deposits).   |
| `failed`       | Trade did not go through. No credit needed. Optionally notify the user.                                                      |
| `canceled`     | Trade was canceled before any state was applied (incl. an unaccepted offer auto-canceled past its window). No action needed. |
| `declined`     | The end user declined the Steam trade offer. No items received, no credit needed. Optionally notify the user.                |
| `reverted`     | A previously completed deposit was reversed. Reverse any `preCredit` + `pendingCredit` already credited. Check `revertedBy`. |

### Withdrawal Callbacks

| `trade.status` | What to Do                                                                                                                                      |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `initiated`    | **Approval gate** — see below. The merchant's wallet hasn't been debited yet; your response decides whether the withdrawal proceeds.            |
| `pending`      | Supplier is sourcing the item. Acknowledge with `200`. No balance action.                                                                       |
| `active`       | Steam trade offer sent to the user — awaiting acceptance (`offerID` now present). Acknowledge with `200`.                                       |
| `hold`         | Items sourced and **Steam trade offer accepted by the user** — hold period running (`offerID` + `holdEndDate` present). Acknowledge with `200`. |
| `completed`    | Hold ended cleanly. No action needed.                                                                                                           |
| `failed`       | Trade did not go through. Refund `totalPrice` to the user's balance if you previously deducted it.                                              |
| `canceled`     | Trade was canceled before processing. Refund `totalPrice` to the user's balance if you previously deducted it.                                  |
| `reverted`     | A 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:

```json theme={null}
{
  "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)

<Note>
  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"`.
</Note>

### Rejecting a Withdrawal

The withdrawal approval gate accepts several rejection signals:

**Option 1 — HTTP 4xx (recommended):**

```http theme={null}
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:**

```json theme={null}
{ "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.

<Note>
  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.
</Note>

## 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:

```typescript theme={null}
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.
