Skip to content

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:

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

StateDescription
reservedPaid. Waiting for the Steam trade hold to end
withdrawableHold ended (manual mode). Call withdraw-hold to receive the item
processingWithdraw trade offer sent to your trade URL. Accept it within 12 hours
acceptedOffer 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)
completedSettled — the settlement window closed and the seller was paid out (mirrors transaction_status: completed)
canceledCanceled by you, or the withdraw offer was declined / canceled / expired on Steam. Always fully refunded
item_unavailableThe item vanished before delivery, or the bot holding it became trade-banned. Fully refunded, automatic
withdraw_failedThe 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 ​

FieldTypeRequiredDescription
asset_idstringYesSteam asset ID to purchase
game_idnumberYesGame ID (e.g., 730)
pricenumberYesPrice (1$ = 1000). Must equal the listed hold price exactly, otherwise the purchase is refused with price_changed
custom_idstring | nullNoYour custom reference ID (idempotency key, shared namespace with plain buys)
trade_urlstring | nullNoSteam 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 ​

FieldTypeRequiredDescription
asset_idstringYesSteam asset ID to purchase
game_idnumberYesGame ID (e.g., 730)
pricenumberYesPrice (1$ = 1000). Must equal the listed hold price exactly, otherwise the purchase is refused with price_changed
custom_idstring | nullNoYour custom reference ID (idempotency key, shared namespace with plain buys)
trade_urlstringYesSteam 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 ​

FieldTypeRequiredDescription
market_hash_namestringYesItem name to buy
game_idnumberYesSteam game ID
phasestring | nullNoDoppler phase filter
max_pricenumberYesHighest hold price you accept — you pay the item's actual hold price, never more than this
custom_idstringNoIdempotency id (shared namespace with all purchases)
trade_urlstringbuy-withdraw onlyDelivery 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"
}
codeerrorMeaning
item_not_foundItem not found or already soldbuy-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_changedPrice mismatch: listing price does not match the offered pricebuy-hold / buy-withdraw: the price you sent differs from the current hold price. Fetch the current price and retry
insufficient_balanceInsufficient balance to buy the itemYour available balance is below the hold price
duplicate_custom_idA trade with this custom_id already existsA 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 ​

FieldTypeRequiredDescription
purchase_idstringOne ofHold purchase ID
custom_idstringOne ofYour custom reference ID
market_hash_namestringOne ofCancels the oldest hold of this name still in a cancelable state
game_idnumberNoNarrows 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.

HTTPcodeerrorMeaning
200purchase_not_foundPurchase not foundNo hold purchase of yours matches the given purchase_id / custom_id, or none of the given market_hash_name is in a cancelable state
200not_cancellableHold purchase cannot be canceled in state '<state>'The purchase is not in a cancelable state (reserved, withdrawable, withdraw_failed)
200withdraw_in_progressA withdraw is already in progress for this purchaseThe withdraw trade offer already exists — accept it, or a decline/expiry on Steam will cancel and refund automatically
400validation_failedValidation errorNone 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 ​

FieldTypeRequiredDescription
purchase_idstringOne ofHold purchase ID
custom_idstringOne ofYour custom reference ID
market_hash_namestringOne ofWithdraws the oldest hold of this name in a withdrawable state
game_idnumberNoNarrows market_hash_name addressing to one game
trade_urlstring | nullNoSteam 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.

HTTPcodeerrorMeaning
200purchase_not_foundPurchase not foundNo hold purchase of yours matches the given purchase_id / custom_id, or none of the given market_hash_name is in a withdrawable state
200not_withdrawableHold purchase cannot be withdrawn in state '<state>'The purchase is not in a withdrawable state (reserved, withdrawable, withdraw_failed)
200item_on_trade_holdItem is on trade holdThe Steam trade hold has not ended yet. Retry after unhold_at
200item_not_foundItem not found or already soldThe item is no longer available for delivery (see item_unavailable in Purchase States)
200trade_bannedThe bot for this trade has a Steam trade banThe 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
200trade_url_requiredNo trade_url on record for this purchase; pass trade_url in the requestNo trade_url was stored at purchase time and none was sent. Pass trade_url
400validation_failedValidation errorNone 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.codedata.errorMeaning
invalid_trade_urlThe trade URL cannot receive trade offersThe 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_bannedYour Steam account has a trade ban and cannot receive trade offersThe 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_bannedThe bot for this trade has a Steam trade banThe 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_unavailableItem is no longer availableThe 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_errorInternal server errorThe 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.

ReasonSideMeaning
hold_boughtbuyerdebited at buy-hold / buy-withdraw
hold_refundbuyer100% refund on cancel / invalidation / failed delivery
hold_soldsellernet credited to frozen balance at purchase
hold_sold_reversedsellerfrozen net reversed when the hold dies
hold_fee_collected / hold_fee_reversedplatformfee frozen at purchase / reversed
hold_frozen_release / hold_fee_releasedseller / platformfrozen → 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 ​

ParameterTypeRequiredDescription
purchase_idsstringNoComma-separated list of hold purchase IDs
custom_idsstringNoComma-separated list of custom IDs
market_hash_namesstringNoComma-separated list of item names
statestringNoFilter by state (see Purchase States)
limitnumberNoResults per page. Default: 50, max: 2000
offsetnumberNoNumber 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 ​

FieldTypeDescription
purchase_idstringHold purchase identifier (UUID)
custom_idstring | nullYour custom reference ID
asset_idstringSteam asset ID of the purchased item
game_idnumberGame ID (730, 570, 252490, 440)
market_hash_namestringSteam market hash name
pricenumberPrice paid (1$ = 1000)
auto_withdrawbooleantrue for buy-withdraw purchases, false for buy-hold
statestringPurchase state (see Purchase States)
unhold_atstring | nullISO timestamp when the Steam trade hold ends — a live value that can shift slightly with Steam
retry_countnumberAutomatic withdraw retry attempts made so far (max 5)
next_retry_atstring | nullISO timestamp of the next automatic withdraw retry
last_errorstring | nullLast withdraw failure reason
transaction_statusstringStatus of the balance transaction for this purchase
transaction_errorstring | nullBalance transaction error, if any
steam_trade_idstring | nullSteam trade offer ID of the withdraw offer
steam_full_trade_idstring | nullSteam full trade ID (set after acceptance)
steam_trade_statusstring | nullSteam trade status string (see Steam Trade Statuses)
settlementstring | nullISO timestamp of when Steam settles the delivered trade (CS2 only)
created_atstringISO timestamp of purchase creation
updated_atstringISO 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.

Need help? Contact our support team