Appearance
Holds API
Buy items that are still under a Steam trade hold. You pay for the item while it is locked, and receive it once the hold ends — either by withdrawing it yourself or by letting the system deliver it automatically.
Base path: /api/items
How It Works
Items under a Steam trade hold (trade-locked items) can be purchased before the hold ends. There are two purchase modes:
- Manual withdraw —
POST /api/items/buy-hold: you buy the item now and callPOST /api/items/withdraw-holdyourself once the hold ends. - Auto withdraw —
POST /api/items/buy-withdraw: you buy the item now and the system sends the withdraw trade offer to your trade URL automatically as soon as the hold ends.
Money: you pay the full price at purchase time. The seller's revenue is held in frozen balance until the delivery settles. Prices are integers on the same 1$ = 1000 scale as the rest of the API. The listed hold price is derived from the seller's hold price range, and the price you send must match it exactly or the purchase is rejected.
Cancellation: you can cancel at any time before the withdraw trade offer exists for a 100% refund to your balance.
Idempotency
custom_id acts as an idempotency key and shares the same namespace as regular buys (/buy, /buy-by-name). Reusing a custom_id is rejected instead of creating a duplicate purchase.
Purchase States
A hold purchase moves through the following states (state field):
| State | Description |
|---|---|
reserved | Paid. Waiting for the Steam trade hold to end |
withdrawable | Hold ended (manual mode). Call withdraw-hold to receive the item |
processing | Withdraw trade offer sent to your trade URL. Accept it within 12 hours |
accepted | Offer accepted — item delivered. Seller money is still frozen until settlement: item unlock + 8 days (a late withdrawal does not extend the freeze; mirrors transaction_status: accepted 1 = 1) |
completed | Settled — the settlement window closed and the seller was paid out (mirrors transaction_status: completed) |
canceled | Canceled by you, or the withdraw offer was declined / canceled / expired on Steam. Always fully refunded |
item_unavailable | The item vanished before delivery, or the bot holding it became trade-banned. Fully refunded, automatic |
withdraw_failed | The withdraw offer could not be created. Retried automatically with backoff (up to 5 attempts); you can also retry via withdraw-hold or cancel for a refund |
The happy path:
┌─ auto mode ───── processing (offer sent, 12h expiry) ─┐
reserved ──(hold ──┤ ├── accepted ── completed
ends) └─ manual mode ─── withdrawable ──(withdraw-hold)── │ (offer (settled: unlock
processing ──────────┘ accepted) + 8 days passed)Failure paths always refund the full price: canceling a purchase, a declined / canceled / expired withdraw offer (→ canceled), or the item disappearing before delivery (→ item_unavailable). If the withdraw offer cannot be created, the purchase parks in withdraw_failed and is retried automatically; it keeps your money reserved on the item until you cancel or a retry succeeds.
12-hour offer expiry
The withdraw trade offer expires after 12 hours if not accepted. Expiry cancels the purchase with a full refund — the item goes back on sale.
Listing Held Items
GET /api/items?include_hold=true
Held items are not part of the default catalog. Pass include_hold=true to GET /api/items to append them: each held item carries hold: true and an unhold_at ISO 8601 timestamp for when its Steam trade hold ends. Held results are cached separately for 60 seconds.
Held items can only be bought via the hold endpoints on this page. The plain POST /api/items/buy rejects them with a trade_hold error, and POST /api/items/check-availability returns reason: "trade_hold" together with unhold_at.
bash
curl -X GET "https://market.vpsbot.io/api/items?game_id=730&include_hold=true" \
-H "x-api-key: YOUR_API_KEY"js
const { data } = await client.get('/items', {
params: { game_id: 730, include_hold: true }
});
const heldItems = data.data.filter((item) => item.hold);See the Items API for the full item schema.
Buy a Held Item (Manual Withdraw)
POST /api/items/buy-hold
Purchase a trade-locked item. The full price is deducted from your balance and the purchase starts in reserved. Once the hold ends, the purchase becomes withdrawable and you call withdraw-hold to receive the item.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
asset_id | string | Yes | Steam asset ID to purchase |
game_id | number | Yes | Game ID (e.g., 730) |
price | number | Yes | Price (1$ = 1000). Must equal the listed hold price exactly, otherwise the purchase is rejected |
custom_id | string | null | No | Your custom reference ID (idempotency key, shared namespace with plain buys) |
trade_url | string | null | No | Steam trade offer URL, stored for the later withdraw. You can also pass it at withdraw time instead |
Why is trade_url optional here?
By design: for manual holds the delivery address is only needed at withdraw time, and withdraw-hold accepts (and overrides with) a fresh trade_url. This mirrors skin.place, which also defers the trade token to the withdraw call; skinify requires the URL upfront only because its flow is automatic — which is exactly what our buy-withdraw does.
WARNING
Returns 403 while hold purchasing is disabled server-side (feature flag).
bash
curl -X POST "https://market.vpsbot.io/api/items/buy-hold" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"asset_id": "30000000001",
"game_id": 730,
"price": 1245,
"custom_id": "hold-001",
"trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=XXXXX&token=YYYYY"
}'js
const { data } = await client.post('/items/buy-hold', {
asset_id: '30000000001',
game_id: 730,
price: 1245,
custom_id: 'hold-001',
trade_url: 'https://steamcommunity.com/tradeoffer/new/?partner=XXXXX&token=YYYYY'
});Example Response
json
{
"success": true,
"data": {
"purchase_id": "d4e5f6a7-b8c9-0123-cdef-234567890123",
"custom_id": "hold-001",
"asset_id": "30000000001",
"game_id": 730,
"market_hash_name": "AK-47 | Redline (Field-Tested)",
"phase": null,
"price": 1245,
"auto_withdraw": false,
"state": "reserved",
"unhold_at": "2026-08-19T07:00:00Z"
}
}Buy a Held Item (Auto Withdraw)
POST /api/items/buy-withdraw
Same as buy-hold, but delivery is automatic: as soon as the hold ends, the system creates the withdraw trade offer to your trade URL (12-hour expiry) and notifies you via websocket. You never need to call withdraw-hold yourself.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
asset_id | string | Yes | Steam asset ID to purchase |
game_id | number | Yes | Game ID (e.g., 730) |
price | number | Yes | Price (1$ = 1000). Must equal the listed hold price exactly, otherwise the purchase is rejected |
custom_id | string | null | No | Your custom reference ID (idempotency key, shared namespace with plain buys) |
trade_url | string | Yes | Steam trade offer URL the item is delivered to at hold end |
bash
curl -X POST "https://market.vpsbot.io/api/items/buy-withdraw" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"asset_id": "30000000001",
"game_id": 730,
"price": 1245,
"custom_id": "hold-002",
"trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=XXXXX&token=YYYYY"
}'js
const { data } = await client.post('/items/buy-withdraw', {
asset_id: '30000000001',
game_id: 730,
price: 1245,
custom_id: 'hold-002',
trade_url: 'https://steamcommunity.com/tradeoffer/new/?partner=XXXXX&token=YYYYY'
});Example Response
json
{
"success": true,
"data": {
"purchase_id": "e5f6a7b8-c9d0-1234-def0-345678901234",
"custom_id": "hold-002",
"asset_id": "30000000001",
"game_id": 730,
"market_hash_name": "AK-47 | Redline (Field-Tested)",
"phase": null,
"price": 1245,
"auto_withdraw": true,
"state": "reserved",
"unhold_at": "2026-08-19T07:00:00Z"
}
}Buy by Name
POST /api/items/buy-hold-by-name · POST /api/items/buy-withdraw-by-name
Mirror of the plain /api/items/buy-by-name: buys the cheapest hold-sellable item matching a name (phase-aware), without knowing an asset_id up front. buy-hold-by-name keeps the item reserved for a manual withdraw (trade_url optional); buy-withdraw-by-name schedules auto-delivery at unlock (trade_url required).
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
market_hash_name | string | Yes | Item name to buy |
game_id | number | Yes | Steam game ID |
phase | string | null | No | Doppler phase filter |
max_price | number | Yes | Highest hold price you accept — you pay the item's actual hold price, never more than this |
custom_id | string | No | Idempotency id (shared namespace with all purchases) |
trade_url | string | buy-withdraw only | Delivery URL — optional on buy-hold-by-name, required on buy-withdraw-by-name |
bash
curl -X POST "https://market.vpsbot.io/api/items/buy-hold-by-name" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"market_hash_name": "AK-47 | Redline (Field-Tested)",
"game_id": 730,
"max_price": 5000,
"custom_id": "hold-byname-001"
}'js
const { data } = await client.post('/items/buy-hold-by-name', {
market_hash_name: 'AK-47 | Redline (Field-Tested)',
game_id: 730,
max_price: 5000,
custom_id: 'hold-byname-001'
});The response shape is identical to buy-hold / buy-withdraw — asset_id and price tell you which item was picked and what you actually paid.
Cancel a Hold Purchase
POST /api/items/cancel-hold
Cancel a hold purchase and get a 100% refund to your balance. Allowed while the purchase is in reserved, withdrawable, or withdraw_failed — i.e. any time before the withdraw trade offer exists. A buyer-initiated cancel records transaction_status: declined (buyer-side word); system-side invalidations record cancelled.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
purchase_id | string | One of | Hold purchase ID |
custom_id | string | One of | Your custom reference ID |
market_hash_name | string | One of | Cancels the oldest hold of this name still in a cancelable state |
game_id | number | No | Narrows market_hash_name addressing to one game |
WARNING
Exactly one of purchase_id, custom_id or market_hash_name must be provided. Name addressing picks the oldest matching purchase — use purchase_id/custom_id when you hold several of the same item and need a specific one.
bash
curl -X POST "https://market.vpsbot.io/api/items/cancel-hold" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"custom_id": "hold-001"}'js
const { data } = await client.post('/items/cancel-hold', {
custom_id: 'hold-001'
});Example Response
json
{
"success": true,
"data": {
"purchase_id": "d4e5f6a7-b8c9-0123-cdef-234567890123",
"custom_id": "hold-001",
"state": "canceled"
}
}Possible Errors
| Error | Description |
|---|---|
| Purchase not found | No hold purchase matches the given purchase_id / custom_id |
| Wrong state | The purchase is not in a cancelable state (reserved, withdrawable, withdraw_failed) |
Withdraw already in flight for this purchase; it can no longer be canceled | The withdraw trade offer already exists — accept it, or a decline/expiry on Steam will cancel and refund automatically |
Withdraw a Held Item
POST /api/items/withdraw-hold
Trigger the withdraw trade offer for a manual purchase (or retry a failed one). Allowed in withdrawable, withdraw_failed, and in reserved if the hold has actually ended (i.e. unhold_at has passed).
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
purchase_id | string | One of | Hold purchase ID |
custom_id | string | One of | Your custom reference ID |
market_hash_name | string | One of | Withdraws the oldest hold of this name in a withdrawable state |
game_id | number | No | Narrows market_hash_name addressing to one game |
trade_url | string | null | No | Steam trade offer URL to deliver to. Overrides/sets the delivery URL; falls back to the URL stored on the purchase |
bash
curl -X POST "https://market.vpsbot.io/api/items/withdraw-hold" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"custom_id": "hold-001",
"trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=XXXXX&token=YYYYY"
}'js
const { data } = await client.post('/items/withdraw-hold', {
custom_id: 'hold-001',
trade_url: 'https://steamcommunity.com/tradeoffer/new/?partner=XXXXX&token=YYYYY'
});Example Response
json
{
"success": true,
"data": {
"purchase_id": "d4e5f6a7-b8c9-0123-cdef-234567890123",
"custom_id": "hold-001",
"state": "processing",
"trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=XXXXX&token=YYYYY",
"steam_trade_id": "5234567890",
"steam_trade_status": "Active"
}
}Accept the Steam trade offer within 12 hours — expiry cancels the purchase with a full refund.
Failed Send
If the withdraw offer could not be created, the purchase moves to withdraw_failed and the response carries the failure:
json
{
"success": true,
"data": {
"purchase_id": "d4e5f6a7-b8c9-0123-cdef-234567890123",
"custom_id": "hold-001",
"state": "withdraw_failed",
"error": "Failed to create the withdraw trade offer"
}
}Failed withdraws are retried automatically with backoff (up to 5 attempts). You can also retry manually by calling withdraw-hold again, or cancel for a refund.
Live-Follow for Hold Prices
Hold ranges can follow a market's minimum price live, independently of the regular range: run Auto-Prices with Set hold ranges ON and a market-min follow method — the follow is stored on the hold range itself and re-applied automatically whenever the source market's minimum moves (only while the item is trade-locked). A manual hold-range edit stops the follow for that item, and a seller's custom_price pin does not freeze hold following (it pins the listing price, not the hold sale).
Balance History
Hold trades are marked distinctly in balance history: every ledger entry on the hold lifecycle uses a hold_-prefixed reason, so they never blend with plain purchases.
| Reason | Side | Meaning |
|---|---|---|
hold_bought | buyer | debited at buy-hold / buy-withdraw |
hold_refund | buyer | 100% refund on cancel / invalidation / failed delivery |
hold_sold | seller | net credited to frozen balance at purchase |
hold_sold_reversed | seller | frozen net reversed when the hold dies |
hold_fee_collected / hold_fee_reversed | platform | fee frozen at purchase / reversed |
hold_frozen_release / hold_fee_released | seller / platform | frozen → available at settlement |
List Hold Purchases
GET /api/items/holds
Returns your hold purchases with optional filtering and pagination. This is the source of truth for purchase state — reconcile against it after any websocket reconnect.
Check a single purchase
Filter by the custom_id you passed at purchase (or the returned purchase_id) — one call returns both layers of status: the hold state, the money-side transaction_status, plus steam_trade_id / steam_trade_status and the settlement timestamp once the offer is delivered.
bash
curl "https://market.vpsbot.io/api/items/holds?custom_ids=YOUR_CUSTOM_ID" \
-H "x-api-key: YOUR_API_KEY"Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
purchase_ids | string | No | Comma-separated list of hold purchase IDs |
custom_ids | string | No | Comma-separated list of custom IDs |
market_hash_names | string | No | Comma-separated list of item names |
state | string | No | Filter by state (see Purchase States) |
limit | number | No | Results per page. Default: 50, max: 2000 |
offset | number | No | Number of results to skip. Default: 0 |
bash
curl -X GET "https://market.vpsbot.io/api/items/holds?state=reserved&limit=50&offset=0" \
-H "x-api-key: YOUR_API_KEY"js
const { data } = await client.get('/items/holds', {
params: { state: 'reserved', limit: 50, offset: 0 }
});Example Response
json
{
"success": true,
"data": [
{
"purchase_id": "d4e5f6a7-b8c9-0123-cdef-234567890123",
"custom_id": "hold-001",
"asset_id": "30000000001",
"game_id": 730,
"market_hash_name": "AK-47 | Redline (Field-Tested)",
"price": 1245,
"auto_withdraw": false,
"state": "processing",
"unhold_at": "2026-08-19T07:00:00Z",
"retry_count": 0,
"next_retry_at": null,
"last_error": null,
"transaction_status": "completed",
"transaction_error": null,
"steam_trade_id": "5234567890",
"steam_full_trade_id": null,
"steam_trade_status": "Active",
"settlement": null,
"created_at": "2026-08-12T07:00:00Z",
"updated_at": "2026-08-19T07:01:00Z"
}
]
}Hold Purchase Object Schema
| Field | Type | Description |
|---|---|---|
purchase_id | string | Hold purchase identifier (UUID) |
custom_id | string | null | Your custom reference ID |
asset_id | string | Steam asset ID of the purchased item |
game_id | number | Game ID (730, 570, 252490, 440) |
market_hash_name | string | Steam market hash name |
price | number | Price paid (1$ = 1000) |
auto_withdraw | boolean | true for buy-withdraw purchases, false for buy-hold |
state | string | Purchase state (see Purchase States) |
unhold_at | string | null | ISO timestamp when the Steam trade hold ends — a live value that can shift slightly with Steam |
retry_count | number | Automatic withdraw retry attempts made so far (max 5) |
next_retry_at | string | null | ISO timestamp of the next automatic withdraw retry |
last_error | string | null | Last withdraw failure reason |
transaction_status | string | Status of the balance transaction for this purchase |
transaction_error | string | null | Balance transaction error, if any |
steam_trade_id | string | null | Steam trade offer ID of the withdraw offer |
steam_full_trade_id | string | null | Steam full trade ID (set after acceptance) |
steam_trade_status | string | null | Steam trade status string (see Steam Trade Statuses) |
settlement | string | null | ISO timestamp of when Steam settles the delivered trade (CS2 only) |
created_at | string | ISO timestamp of purchase creation |
updated_at | string | ISO timestamp of last update |
Trade history
Hold purchases appear in the GET /api/trades history only once their withdraw trade offer exists — the trade record carries the same custom_id, so you can correlate the two. Before that, track them via GET /api/items/holds.