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 refused with price_changed.
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 refused with code: "duplicate_custom_id" 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 refuses them with code: "item_on_trade_hold", 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 refused with price_changed |
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.
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'
});
if (!data.success) {
// A refused hold purchase answers HTTP 200: branch on data.code
console.log(`Hold purchase refused: ${data.code} (${data.error})`);
}A refused purchase answers HTTP 200 with success: false and a code — see Refused Hold Purchase.
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 refused with price_changed |
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'
});
if (!data.success) {
// A refused hold purchase answers HTTP 200: branch on data.code
console.log(`Hold purchase refused: ${data.code} (${data.error})`);
}A refused purchase answers HTTP 200 with success: false and a code — see Refused Hold Purchase.
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'
});
if (!data.success) {
// A refused hold purchase answers HTTP 200: branch on data.code
console.log(`Hold purchase refused: ${data.code} (${data.error})`);
}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.
Refused Hold Purchase
buy-hold, buy-withdraw, buy-hold-by-name and buy-withdraw-by-name refuse a purchase that cannot go through, and your balance is not charged. The response is HTTP 200 with success: false, a machine-readable code and a human-readable error:
json
{
"success": false,
"code": "price_changed",
"error": "Price mismatch: listing price does not match the offered price"
}code | error | Meaning |
|---|---|---|
item_not_found | Item not found or already sold | buy-hold / buy-withdraw: the item cannot be bought as a held item — it was sold, it is no longer under a trade hold, or its seller has not set a hold price range. buy-hold-by-name / buy-withdraw-by-name: no held item matches the name and phase within max_price |
price_changed | Price mismatch: listing price does not match the offered price | buy-hold / buy-withdraw: the price you sent differs from the current hold price. Fetch the current price and retry |
insufficient_balance | Insufficient balance to buy the item | Your available balance is below the hold price |
duplicate_custom_id | A trade with this custom_id already exists | A purchase with this custom_id already exists (the namespace is shared with plain buys). Send a new custom_id |
Branch on code, not on the error text. An invalid request answers HTTP 400 with code: "validation_failed", a server failure answers HTTP 500 with code: "internal_error" — see Response Format.
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'
});
if (!data.success) {
// A refused cancel answers HTTP 200: branch on data.code
console.log(`Cancel refused: ${data.code} (${data.error})`);
}Example Response
json
{
"success": true,
"data": {
"purchase_id": "d4e5f6a7-b8c9-0123-cdef-234567890123",
"custom_id": "hold-001",
"state": "canceled"
}
}Possible Errors
A refused cancel answers HTTP 200 with success: false, a machine-readable code and a human-readable error. The purchase is left as it was.
| HTTP | code | error | Meaning |
|---|---|---|---|
| 200 | purchase_not_found | Purchase not found | No hold purchase of yours matches the given purchase_id / custom_id, or none of the given market_hash_name is in a cancelable state |
| 200 | not_cancellable | Hold purchase cannot be canceled in state '<state>' | The purchase is not in a cancelable state (reserved, withdrawable, withdraw_failed) |
| 200 | withdraw_in_progress | A withdraw is already in progress for this purchase | The withdraw trade offer already exists — accept it, or a decline/expiry on Steam will cancel and refund automatically |
| 400 | validation_failed | Validation error | None of the three fields was sent. data lists all three with code: "required_without_all" |
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'
});
if (!data.success) {
// A refused withdraw answers HTTP 200: branch on data.code
console.log(`Withdraw refused: ${data.code} (${data.error})`);
} else if (data.data.state === 'withdraw_failed') {
// The offer could not be created: see Failed Send below
console.log(`Withdraw failed: ${data.data.code} (${data.data.error})`);
}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.
Possible Errors
A refused withdraw answers HTTP 200 with success: false, a machine-readable code and a human-readable error. No trade offer is sent, and the purchase is left as it was.
| HTTP | code | error | Meaning |
|---|---|---|---|
| 200 | purchase_not_found | Purchase not found | No hold purchase of yours matches the given purchase_id / custom_id, or none of the given market_hash_name is in a withdrawable state |
| 200 | not_withdrawable | Hold purchase cannot be withdrawn in state '<state>' | The purchase is not in a withdrawable state (reserved, withdrawable, withdraw_failed) |
| 200 | item_on_trade_hold | Item is on trade hold | The Steam trade hold has not ended yet. Retry after unhold_at |
| 200 | item_not_found | Item not found or already sold | The item is no longer available for delivery (see item_unavailable in Purchase States) |
| 200 | trade_banned | The bot for this trade has a Steam trade ban | The bot holding the item has a Steam trade ban and cannot deliver it. The item stays on this bot, so a retry fails the same way while the ban lasts. Cancel for a refund |
| 200 | trade_url_required | No trade_url on record for this purchase; pass trade_url in the request | No trade_url was stored at purchase time and none was sent. Pass trade_url |
| 400 | validation_failed | Validation error | None of the three fields was sent. data lists all three with code: "required_without_all" |
A temporary failure answers HTTP 500 with code: "internal_error". Retry later.
Failed Send
If the request was accepted but the withdraw offer could not be created, the purchase moves to withdraw_failed. The response is still success: true and, like every successful response, has no top-level code; the failure is reported inside data: data.code is machine-readable, data.error is a human-readable text:
json
{
"success": true,
"data": {
"purchase_id": "d4e5f6a7-b8c9-0123-cdef-234567890123",
"custom_id": "hold-001",
"state": "withdraw_failed",
"error": "Your Steam account has a trade ban and cannot receive trade offers",
"code": "trade_partner_banned"
}
}data.code | data.error | Meaning |
|---|---|---|
invalid_trade_url | The trade URL cannot receive trade offers | The trade_url cannot receive trade offers: it is malformed or out of date (regenerate it in Steam), or the account cannot trade right now. Check the URL and retry, or cancel for a refund |
trade_partner_banned | Your Steam account has a trade ban and cannot receive trade offers | The Steam account behind the trade_url has a trade ban and cannot receive trade offers. Retry with a trade_url of another account, or cancel for a refund |
trade_banned | The bot for this trade has a Steam trade ban | The bot holding the item has a Steam trade ban and cannot deliver it. The item stays on this bot, so a retry fails the same way while the ban lasts. Cancel for a refund |
item_unavailable | Item is no longer available | The seller can no longer deliver this item, so a retry is unlikely to succeed. Cancel for a refund. Unlike the item_unavailable state, this code does not refund you: the purchase stays withdraw_failed with your money reserved on it until you cancel or a retry succeeds |
internal_error | Internal server error | The offer could not be created for another reason |
data.code and data.error are present on every failed send, and only in the withdraw-hold response. GET /api/items/holds keeps the failure reason in last_error as free text, without a code.
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.