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

> How to process skin withdrawals for your users.

# Withdrawals

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)                                   |
| `game`           | string | No       | `"730"` (CS2) or `"252490"` (Rust). Defaults to `"730"`.                                |
| `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](/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 a catalog `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. **CS2 only.**

```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`](/api-reference/trading/withdraw-quick) (and the merchant self-trade variant [`POST /secure/buy/quick`](/api-reference/secure/buy-quick)).

## Cancelling a Withdrawal Item

A user can cancel a withdrawal item that hasn't been delivered yet — for example when a CS2 purchase is taking too long to source. Call `POST /client/trading/withdraw/{tradeId}/items/{itemId}/cancel` with the trade ID and the item's `id`.

Cancellation is **CS2 (Assetpay) only** — Rust withdrawals use a polling supplier with no cancel API. An item can 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).

A successful response only confirms the marketplace accepted the cancel — AssetPay doesn't change the item state inline. The item transitions to `reverted` and your merchant wallet is refunded asynchronously via the usual callback, exactly like a [trade reversal](#trade-reversals). Refund the user's balance when you receive that `reverted` callback. See the [API reference](/api-reference/trading/cancel-withdraw-item) for the full contract.

## 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](/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`](/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.
