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

# Withdrawals

> Process skin withdrawals for your users: the withdrawal flow, initiating a purchase, request fields, the initiated callback, balance handling and reversals.

A withdrawal is when a user buys a skin from the market using their balance on your platform. AssetPay purchases the item from a marketplace supplier and delivers it to the user via Steam trade offer.

## The Withdrawal Flow

<Steps>
  <Step title="Fetch market">
    Call `GET /client/market` to get available items with current prices.
  </Step>

  <Step title="User selects an item">
    Display market items in your UI. The user picks what they want to buy.
  </Step>

  <Step title="Initiate withdrawal">
    The user (or your backend) calls `POST /client/trading/withdraw` with the selected item details. The trade is created with `initiated` status.
  </Step>

  <Step title="AssetPay calls your backend (initiated callback)">
    Before purchasing anything, AssetPay sends an `initiated` callback to your backend. This is where you check the user's balance and approve or reject the trade.
  </Step>

  <Step title="Your backend approves or rejects">
    If the user has enough balance: deduct it and respond `2xx`. AssetPay proceeds with the purchase. If not: respond with `4xx` (e.g. `402`) and the trade is marked `failed`.
  </Step>

  <Step title="Item sourced and offer sent">
    AssetPay sources the item from the supplier (`pending`), then sends the Steam trade offer to the user (`active`). When the user accepts, the trade moves to `hold` for the Steam reversal-protection window (and the callback now carries `offerID` + `holdEndDate`).
  </Step>

  <Step title="Completed">
    After the hold period, the trade reaches `completed`. No further action needed.
  </Step>
</Steps>

The key thing here: **nothing gets purchased until your backend approves it**. The `initiated` callback is the gate. Your backend is always in control of whether a withdrawal goes through.

## Initiating a Withdrawal

```http theme={null}
POST https://api.assetpay.gg/client/trading/withdraw
Content-Type: application/json
Authorization: CLIENT_TOKEN

{
  "items": [
    { "itemId": "e5f6g7h8-...", "price": 45.00 }
  ],
  "game": "730",
  "externalId": "wd_unique_789"
}
```

## Request Fields

| Field                | Type   | Required | Description                                                                                                                                                        |
| -------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `items`              | array  | Yes      | Array of items to withdraw (min 1, max 50)                                                                                                                         |
| `items[].itemId`     | string | Yes      | Item ID from the market response                                                                                                                                   |
| `items[].price`      | number | Yes      | Purchase price in USD (max \$100,000). Validated against the current marketplace price.                                                                            |
| `items[].amount`     | number | No       | Quantity for stackable items (default: 1, max: 10000)                                                                                                              |
| `items[].externalId` | string | No       | Your own reference for this single item (1–128 chars). Echoed back on the item and accepted in place of the item id when cancelling.                               |
| `game`               | string | No       | `"730"` (CS2) or `"252490"` (Rust). Defaults to `"730"`.                                                                                                           |
| `autoCancel`         | number | No       | CS2 only. Minutes until undelivered items are cancelled automatically (30-720, default 30, `720` disables). See [Automatic cancellation](#automatic-cancellation). |
| `externalId`         | string | No       | Your unique tracking ID (max 128 chars)                                                                                                                            |

## Response

```json theme={null}
{
  "requestId": "...",
  "success": true,
  "data": {
    "id": "trade-uuid",
    "type": "withdraw",
    "source": "client",
    "status": "initiated",
    "game": "730",
    "externalId": "wd_unique_789",
    "items": [
      {
        "id": "e5f6g7h8-...",
        "appid": 730,
        "tradable": true,
        "amount": 1,
        "status": "initiated",
        "offer": {
          "price": 45.00,
          "reference": "ref_market_789"
        }
      }
    ],
    "totalPrice": 45.00,
    "createdAt": "2026-03-04T10:00:00.000Z",
    "updatedAt": "2026-03-04T10:00:00.000Z"
  }
}
```

On a freshly initiated withdraw the items only carry the data you submitted (`itemId`, `offer.price`) — Steam-side fields like `name`, `marketHashName`, `type`, and `iconUrl` are omitted until they resolve.

## The `initiated` callback

This is the most important callback in the entire system. When a user initiates a withdrawal, AssetPay does **not** start purchasing immediately. Instead, it sends an `initiated` callback to your backend and waits for your response.

The approval callback uses the same `{ trade: ... }` body shape as every other state callback — see [Callbacks](/docs/guides/callbacks#the-initiated-callback-for-withdrawals) for handling details and signature format.

Your backend should:

1. Verify the `X-AssetPay-Signature` header
2. Look up the user by `trade.clientSteamID` or `trade.externalClientUserId`
3. Check if they have enough balance for `trade.totalPrice`
4. If yes: deduct the balance and respond with HTTP `2xx`
5. If no: respond with HTTP `4xx` (e.g. `402`) to reject

### Approving a withdrawal

Respond with `200` and an empty body (or any body that isn't a rejection signal). AssetPay takes that as approval and starts the purchase.

### Rejecting a withdrawal

Respond with any `4xx` status code. `402` (Payment Required) is the recommended choice:

```json theme={null}
HTTP/1.1 402 Payment Required
Content-Type: application/json

{ "reason": "Insufficient balance" }
```

The `reason` field is optional but useful for debugging. Any `4xx` status (400, 402, 403, 409, etc.) is treated as a rejection.

**Alternative formats (still supported):** A `200` response with one of these bodies is also treated as a rejection:

* `{ "action": "reject", "reason": "..." }`
* `{ "status": "rejected" }`
* `{ "errorCode": "INSUFFICIENT_BALANCE" }`
* `{ "code": "INSUFFICIENT_BALANCE" }`

All rejection paths mark the trade as `failed` and refund your merchant wallet automatically. You'll receive a follow-up `failed` callback.

<Warning>
  `5xx` responses and timeouts are **not** treated as rejections. They are treated as delivery failures and retried with the standard webhook backoff. Make sure your rejection logic returns `4xx`, not `5xx`.
</Warning>

## Balance Handling for Withdrawals

| `trade.status` | What your backend should do                                                                                                                                                                                                            |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `initiated`    | Check user balance, deduct it, respond `2xx` to approve. Or reject with `4xx`.                                                                                                                                                         |
| `pending`      | Supplier is sourcing the item. Acknowledge with `200`. No balance action.                                                                                                                                                              |
| `active`       | Steam trade offer sent to the user, awaiting acceptance. Acknowledge with `200`. No balance action.                                                                                                                                    |
| `hold`         | Item sourced and the Steam trade offer accepted by the user, hold running (`offerID` + `holdEndDate` present). Acknowledge with `200`.                                                                                                 |
| `completed`    | Delivery complete. No balance action needed.                                                                                                                                                                                           |
| `failed`       | Trade did not go through. Refund `totalPrice` if you previously deducted it. The failed item(s) carry an `error` code — see [Per-Item Failure Reasons](#per-item-failure-reasons).                                                     |
| `declined`     | The user declined / let the Steam offer expire (buyer fault). AssetPay auto-refunds your merchant wallet **minus a 2% penalty** (capped at \$9). Decide your own user-facing refund — refund in full and absorb the 2%, or pass it on. |
| `canceled`     | Trade canceled before processing. Refund `totalPrice` if you previously deducted it.                                                                                                                                                   |
| `reverted`     | A completed withdrawal was reversed. Refund `totalPrice`. Check `revertedBy`.                                                                                                                                                          |

A withdrawal progresses `initiated → pending → active → hold → completed`. The intermediate statuses (`pending`, `active`) are informational; drive your balance logic off `initiated` (deduct) and the terminal states (`completed` / `failed` / `declined` / `canceled` / `reverted`).

**Rust withdrawals normally skip `hold`.** Rust items have no 7-day reversal window, so a Rust withdrawal usually goes `active → completed` directly with no `holdEndDate` (the exception is a Steam security escrow, which surfaces as `hold`). CS2 withdrawals always pass through `hold`.

**Price true-up on completion.** If the supplier fills below the price you locked, AssetPay settles the actual cost and **refunds the difference** (including the fee on it) to your merchant wallet; `totalPrice` is trued up to the actual spend. You never pay more than your locked price. This applies to both standard and quick withdrawals.

### Trade Reversals

In rare cases, a completed withdrawal can be reversed. This happens when either the supplier or the user cancels the Steam trade after acceptance. The trade object includes a `revertedBy` field:

* `"supplier"` — the marketplace supplier reversed the trade
* `"user"` — the user declined or cancelled the trade offer

When a withdrawal is reverted, your merchant wallet is automatically refunded by AssetPay.

## Quick Withdrawals

If you don't want to pick specific listings, use **quick withdrawal**: pass an `itemId` (or its exact `marketHashName` instead), a per-unit price ceiling, and an amount, and AssetPay buys the cheapest available listings at or below your ceiling. **Available for CS2 and Rust** (for Rust, `phase` and instant delivery don't apply).

```http theme={null}
POST https://api.assetpay.gg/client/trading/withdraw/quick
Content-Type: application/json
Authorization: CLIENT_TOKEN

{ "itemId": "dc3c4460d814ac35", "maxPrice": 67.39, "amount": 3, "delivery": "instant" }
```

`maxPrice` is the **gross** per-unit ceiling (fee-inclusive — the same `withdraw` price returned by `/secure/prices`). It differs from the standard withdrawal in two ways:

* **Pre-filled rows.** The response returns one item row per requested unit (same minimal shape as a normal buy — id, ceiling `price`, `delivery`), and `totalPrice` set to the worst-case lock (`amount × maxPrice`). Each row is bound to a real listing as fills arrive; the trade settles asynchronously — watch via callbacks or the WebSocket feed.
* **Partial fills are normal.** If fewer than `amount` listings are available at or below your ceiling, AssetPay buys what it can; the unfilled rows go to `failed`, the unfilled units and any below-ceiling savings are refunded to your balance, and `totalPrice` is trued up to the actual spend once settled.

Everything else — the `initiated` approval callback, balance locking, per-item status — works exactly like the standard withdrawal. See the API reference for [`POST /client/trading/withdraw/quick`](/docs/api-reference/trading/withdraw-quick) (and the merchant self-trade variant [`POST /secure/buy/quick`](/docs/api-reference/secure/buy-quick)).

Because the rows are placeholders until they bind, they all share the catalog `itemId` and are hard to tell apart. Pass an `externalIds` array — one entry per requested unit, exactly `amount` of them, applied in order — to label them yourself; each row carries its `externalId` from creation, and cancelling accepts it in place of the item id.

## Tracking individual items

Alongside the trade-level `externalId`, every item can carry one of its own. On a standard withdrawal set `items[].externalId`; on a quick withdrawal pass the `externalIds` array. Each is 1–128 characters.

It comes back on the item in every trade response and callback, and both cancel endpoints accept it wherever an item id is expected — so you can cancel using your own reference without first mapping it to an AssetPay ID.

<Note>
  Stacked Rust rows stand for several items at once and carry no `externalId`.
</Note>

## Cancelling a Withdrawal

A user can cancel a withdrawal that hasn't been delivered yet — for example when a CS2 purchase is taking too long to source. There are two endpoints, and which one you want depends on whether the user is abandoning one item or the whole order:

| Call            | Endpoint                                                        | Result                                                    |
| --------------- | --------------------------------------------------------------- | --------------------------------------------------------- |
| One item        | `POST /client/trading/withdraw/{tradeId}/items/{itemId}/cancel` | Cancels that item, or fails with a reason                 |
| The whole order | `POST /client/trading/withdraw/{tradeId}/cancel`                | Cancels everything still cancellable, reporting each item |

Both accept your own references in place of AssetPay IDs: the trade's `externalId` for `{tradeId}`, and an item's `externalId` for `{itemId}`.

Cancellation is **CS2 (Assetpay) only**, and only for items AssetPay sourced itself — Rust withdrawals use a polling supplier with no cancel API, and internal-sourced items leave nothing to reverse upstream. Both come back as `TRADE_CANCEL_UNSUPPORTED` (44). An item can also only be cancelled when it is at least **30 minutes old** and not yet in a terminal state (`completed`, `failed`, `reverted`, `canceled`); otherwise you'll get `TRADE_CANCEL_TOO_SOON` (27) or `TRADE_NOT_CANCELLABLE` (28).

The whole-order call is **best effort**, so a partial result is still a `200` — read the body rather than the status code. `requested` is how many items the supplier considered cancellable at all, `cancelled` and `failed` split those, and each entry in `items[]` carries a `status` plus a `reason` when it was refused. Items the supplier never considered — already delivered, in Steam hold, Rust, internal-sourced — simply don't appear.

A successful response only confirms the marketplace accepted the cancel — AssetPay doesn't change the item state inline. Items transition to `canceled` and your merchant wallet is refunded asynchronously via the usual callback. Refund the user's balance when you receive that `canceled` callback. See the API reference for [one item](/docs/api-reference/trading/cancel-withdraw-item) or [the whole order](/docs/api-reference/trading/cancel-withdrawal).

<Note>
  A cancel lands on `canceled`, not `reverted`. `reverted` means a delivered item came back afterwards (see [Trade Reversals](#trade-reversals)) — a different situation with the same refund shape.
</Note>

## Automatic cancellation

CS2 withdrawals can also cancel themselves. Every withdraw and quick withdraw accepts an optional `autoCancel` field: the number of minutes after creation at which AssetPay cancels anything the seller has not sent yet, so an item that never sources does not sit open indefinitely.

| Value      | Behaviour                                                                 |
| ---------- | ------------------------------------------------------------------------- |
| omitted    | Defaults to `30`, the shortest window the marketplace allows              |
| `30`-`719` | Cancel items the seller hasn't sent after that many minutes               |
| `720`      | Disabled. The trade stays open until it settles or you cancel it yourself |

Rust is not supported: its supplier has no cancel API, so sending `autoCancel` on a Rust trade is rejected with `VALIDATION_FAILED` (1001).

Two things to know about the timing:

* The marketplace refuses cancels on items younger than 30 minutes, so the 30-minute default lands a few minutes past the literal deadline rather than exactly on it. Values below 30 are rejected.
* Only items the seller has not yet sent are cancelled. Anything already offered to the user or sitting in a Steam hold is never auto-cancelled, so a partly-filled trade keeps what it received.

The trade's `autoCancelAt` field carries the moment the timer fires. It is absent when auto-cancel is disabled, and on Rust withdrawals.

Cancelled items behave exactly like a [manual cancel](#cancelling-a-withdrawal): the item moves to `canceled` and the refund reaches your wallet asynchronously through the standard callback. Refund the user's balance when that callback arrives. If the trade was never dispatched to a supplier at all, the whole trade goes to `canceled` and the entire lock is returned in one go.

## Per-Item Status

For multi-item withdrawals, each item in the `items` array carries its own `status` field. It uses the same values as the trade-level [TradeStatus](/docs/reference/types#tradestatus) and mirrors how the individual purchase progresses:

| Status      | Meaning                                                             |
| ----------- | ------------------------------------------------------------------- |
| `initiated` | Purchase not started yet                                            |
| `pending`   | Supplier sourcing the item                                          |
| `active`    | Steam trade offer sent, awaiting acceptance                         |
| `hold`      | Item delivered to user, hold period running                         |
| `completed` | Item delivered successfully and hold cleared                        |
| `failed`    | Purchase or delivery failed for this item (carries an `error` code) |
| `declined`  | The user declined the Steam offer for this item                     |
| `canceled`  | Trade canceled before processing                                    |
| `reverted`  | Trade was reversed for this item                                    |

This lets you show per-item progress in your UI for multi-item withdrawals.

## Per-Item Failure Reasons

When an item ends in `failed`, it carries an `error` field with a stable [`TradeFailureCode`](/docs/reference/types#tradefailurecode) so you can react programmatically:

| `error`                    | What happened                                                | Suggested handling                           |
| -------------------------- | ------------------------------------------------------------ | -------------------------------------------- |
| `LISTING_UNAVAILABLE`      | Listing sold / delisted / out of stock                       | Retry with another listing                   |
| `PRICE_CHANGED`            | Price moved above your ceiling                               | Re-quote and retry                           |
| `TRADE_URL_INVALID`        | Recipient's Steam trade URL is invalid or escrow-blocked     | Ask the user to fix their trade URL          |
| `STEAM_ACCOUNT_RESTRICTED` | Recipient's Steam can't receive (VAC / ban / hold / private) | User must resolve the Steam restriction      |
| `MARKET_UNAVAILABLE`       | Temporary upstream / our-side issue                          | Retry later                                  |
| `PURCHASE_FAILED`          | Unclassified failure                                         | Reconcile, then retry or surface to the user |

The **trade-level** `error` carries this code only when the whole withdrawal failed for one shared reason; for mixed outcomes it's `null`, so read the per-item `error` values. `canceled` and `declined` items carry no `error` — the status itself is the reason. The raw upstream detail is kept internal and never surfaced.
