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
Initiate withdrawal
The user (or your backend) calls
POST /client/trading/withdraw with the selected item details. The trade is created with initiated status.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.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.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).initiated callback is the gate. Your backend is always in control of whether a withdrawal goes through.
Initiating a Withdrawal
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
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
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. |
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. |
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 a catalogitemId (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.
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).
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. CallPOST /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. Refund the user’s balance when you receive that reverted callback. See the API reference for the full contract.
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:
| 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 |
Per-Item Failure Reasons
When an item ends infailed, it carries an error field with a stable 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 |
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.