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

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

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 rejected
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.

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

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 rejected
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'
});

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'
});

The response shape is identical to buy-hold / buy-withdrawasset_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

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'
});

Example Response

json
{
  "success": true,
  "data": {
    "purchase_id": "d4e5f6a7-b8c9-0123-cdef-234567890123",
    "custom_id": "hold-001",
    "state": "canceled"
  }
}

Possible Errors

ErrorDescription
Purchase not foundNo hold purchase matches the given purchase_id / custom_id
Wrong stateThe purchase is not in a cancelable state (reserved, withdrawable, withdraw_failed)
Withdraw already in flight for this purchase; it can no longer be canceledThe 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

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'
});

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.

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