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.initiated callback is the gate. Your backend is always in control of whether a withdrawal goes through.
Initiating a Withdrawal
Request Fields
Response
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:
- Verify the
X-AssetPay-Signatureheader - Look up the user by
trade.clientSteamIDortrade.externalClientUserId - Check if they have enough balance for
trade.totalPrice - If yes: deduct the balance and respond with HTTP
2xx - If no: respond with HTTP
4xx(e.g.402) to reject
Approving a withdrawal
Respond with200 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 any4xx status code. 402 (Payment Required) is the recommended choice:
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" }
failed and refund your merchant wallet automatically. You’ll receive a follow-up failed callback.
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 arevertedBy field:
"supplier"— the marketplace supplier reversed the trade"user"— the user declined or cancelled the trade offer
Quick Withdrawals
If you don’t want to pick specific listings, use quick withdrawal: pass anitemId (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), andtotalPriceset 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
amountlistings are available at or below your ceiling, AssetPay buys what it can; the unfilled rows go tofailed, the unfilled units and any below-ceiling savings are refunded to your balance, andtotalPriceis trued up to the actual spend once settled.
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-levelexternalId, 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 optionalautoCancel 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.
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 theitems 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 infailed, 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.