Skip to main content
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

1

Fetch market

Call GET /client/market to get available items with current prices.
2

User selects an item

Display market items in your UI. The user picks what they want to buy.
3

Initiate withdrawal

The user (or your backend) calls POST /client/trading/withdraw with the selected item details. The trade is created with initiated status.
4

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

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

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

Completed

After the hold period, the trade reaches completed. No further action needed.
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

Request Fields

Response

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

Balance Handling for Withdrawals

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).
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 (and the merchant self-trade variant POST /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.
Stacked Rust rows stand for several items at once and carry no externalId.

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: 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 autoseller-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, autoseller-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 or the whole order.
A cancel lands on canceled, not reverted. reverted means a delivered item came back afterwards (see Trade Reversals) — a different situation with the same refund shape.

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. 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: 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 and mirrors how the individual purchase progresses: 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 so you can react programmatically: 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.