Buy

Create merchant purchases. All require Authorization: Bearer mer_live_… (Authentication).

To fetch or list orders after creation, see Purchases.

Create a purchase

POST /api/v1/merchant/buy

One item per call. To buy multiple items, fan out client-side — each call is independent, debits separately, and emits its own webhook chain.

Request body

FieldTypeRequiredNotes
item_iduuidyesFrom CatalogItem.id.
max_pricedecimal (USD)yesHighest price you accept. Actual debit is ≤ max_price.
custom_idstring ≤ 100yesYour idempotency key — see idempotency.
target_trade_urlstringyesFull Steam trade URL of the end-user. SteamID64 is derived from the partner query parameter.
target_steam_idstringnoSteamID64 of the recipient. Omit to derive from target_trade_url. When supplied, must match the derived value or you get 400 MERCHANT_STEAM_ID_MISMATCH.

Example

curl -X POST https://api.tradeon.market/api/v1/merchant/buy \
  -H "Authorization: Bearer mer_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "item_id": "8e42bb9c-2e11-4d40-9d51-6e5d2d4b2cf1",
    "max_price": 20.00,
    "custom_id": "order-2026-05-05-0001",
    "target_steam_id": "76561198000000000",
    "target_trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=12345&token=abc123"
  }'

201 Created:

{
  "purchase_id": "1a2b3c4d-5e6f-7080-91a2-b3c4d5e6f708",
  "custom_id": "order-2026-05-05-0001",
  "status": "created",
  "item": {
    "market_hash_name": "AK-47 | Redline (Field-Tested)",
    "price": 18.40,
    "delivery_type": "instant",
    "estimated_delivery_minutes": 2
  },
  "balance_after": 481.60,
  "created_at": "2026-05-05T16:00:00.000Z"
}

balance_after reflects the debit (max_price minus actual fill — refunded immediately if the live price was lower). delivery_type / estimated_delivery_minutes are a snapshot of the catalogue values at purchase time; see CatalogItem schema for semantics.

Errors

HTTPcodeWhen
400MERCHANT_INVALID_ITEM_IDitem_id missing or not a UUID.
400MERCHANT_INVALID_MAX_PRICEmax_price ≤ 0.
400MERCHANT_INVALID_CUSTOM_IDcustom_id missing or > 100 chars.
400MERCHANT_INVALID_STEAM_IDtarget_steam_id supplied but not a valid SteamID64.
400MERCHANT_STEAM_ID_MISMATCHtarget_steam_id supplied and does not match the SteamID64 derived from target_trade_url (partner query). Omit it or supply a matching value.
400MERCHANT_INVALID_TRADE_URLtarget_trade_url malformed.
402MERCHANT_INSUFFICIENT_BALANCEAvailable balance < live item price. (Not max_price — only the actual fill is debited.)
404MERCHANT_ITEM_NOT_FOUNDitem_id does not exist.
409MERCHANT_DUPLICATE_CUSTOM_IDcustom_id reused with different parameters.
409MERCHANT_ITEM_UNAVAILABLEItem no longer active (sold, reserved, removed by sync).
409MERCHANT_PRICE_CHANGEDLive price now > max_price. Refetch and decide.
409MERCHANT_CONFLICTConcurrent-update conflict — safe to retry once.

Create a purchase (synchronous)

POST /api/v1/merchant/buy-sync

Same request body, validation, debit, idempotency, and webhook chain as POST /buy — the only difference is when the response returns. POST /buy returns 201 immediately with status: "created"; POST /buy-sync holds the response until the upstream provider has acknowledged the purchase (or rejected it), up to a 30-second ceiling.

Use it when you want a single round-trip to tell you whether the provider accepted the order, instead of creating the order and then watching webhooks / polling for the first state transition.

Response

HTTPWhenBody
200 OKProvider acknowledged the purchase.Same shape as POST /buy’s 201 body. status reflects the current order state (typically processing).
202 Accepted30s elapsed and the order is still being acquired, or the order is parked waiting on a provider top-up.Same body shape; status: "processing". The order is live — track it via webhooks or GET /purchases/{id}.
409Provider rejected the purchase. The order was created, debited, and then automatically refunded.Error body — see below.

A 202 is not a failure: the purchase exists and keeps progressing exactly like a POST /buy order. If your HTTP client disconnects while waiting, the order is not cancelled — it continues server-side.

Errors

All POST /buy pre-check errors apply identically (returned before the order is created). Additionally, when the provider rejects an already-created order:

HTTPcodeWhen
409MERCHANT_ITEM_UNAVAILABLEItem was taken upstream between your request and the provider call.
409MERCHANT_PRICE_CHANGEDUpstream price moved above max_price before the provider call.
409MERCHANT_PURCHASE_FAILEDProvider rejected the purchase for any other reason. The debit was refunded.

Example

curl -X POST https://api.tradeon.market/api/v1/merchant/buy-sync \
  -H "Authorization: Bearer mer_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "item_id": "8e42bb9c-2e11-4d40-9d51-6e5d2d4b2cf1",
    "max_price": 20.00,
    "custom_id": "order-2026-05-05-0001",
    "target_steam_id": "76561198000000000",
    "target_trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=12345&token=abc123"
  }'

Buy by name (with auto-retry)

POST /api/v1/merchant/buy-by-name

Instead of pinning a specific item_id, you describe what you want to buy (market_hash_name + max_price + optional delivery-time ceiling) and we pick the cheapest matching active item, attempt purchase, and — if the provider rejects it — automatically retry on the next cheapest item. Up to 5 attempts within a shared 30-second budget.

Use it for fungible inventory (“buy me any MIBR Budapest 2025 sticker under $0.05”) where you do not want to manage retries on item-specific races (locked / sold / price moved) yourself.

Request body

{
  "market_hash_name": "Sticker | MIBR | Budapest 2025",
  "max_price": 0.05,
  "trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=…&token=…",
  "custom_id": "order-2026-05-05-0001",
  "estimated_time": 60
}
FieldTypeRequiredDescription
market_hash_namestringyesExact Steam name, case-sensitive. Max 200 chars.
max_pricedecimalyesUSD ceiling per item. Items above are excluded.
trade_urlstringyesSteam trade URL of the recipient. SteamID is derived from the partner query parameter.
custom_idstringyesIdempotency key. Max 100 chars. Unique per merchant.
estimated_timeintnoMax acceptable delivery time, in minutes. Minimum: 10. When set, items with estimated_delivery_minutes > estimated_time or unknown delivery time are excluded.

Response

The response body is identical to POST /buy-sync and describes the final winning order (or the last in-flight order if the timeout elapsed). Intermediate failed attempts are not visible in the response — they exist as separate Order rows in the database (sharing the same custom_id, indexed 1..5) but never trigger a purchase.created webhook.

HTTPWhenBody
200 OKProvider accepted the purchase on attempt N.Final winning order.
202 Accepted30s budget elapsed with an order still in flight.Last-attempted order — track it via webhooks / GET /purchases/{id}.
400 MERCHANT_NO_ITEMS_FOUNDNo items match market_hash_name + max_price + estimated_time on the very first attempt.Error body.
402 MERCHANT_INSUFFICIENT_BALANCEBalance is below the cheapest matching item.Error body.
409 MERCHANT_PURCHASE_FAILEDAll 5 attempts exhausted, items ran out after at least one attempt, or the 30s budget expired before another item could be picked.Error body.

Errors

HTTPcodeWhen
400MERCHANT_INVALID_MARKET_HASH_NAMEEmpty or > 200 chars.
400MERCHANT_INVALID_MAX_PRICEmax_price ≤ 0.
400MERCHANT_INVALID_TRADE_URLNot a valid Steam trade URL.
400MERCHANT_INVALID_CUSTOM_IDEmpty or > 100 chars.
400MERCHANT_INVALID_ESTIMATED_TIMEestimated_time < 10.
400MERCHANT_NO_ITEMS_FOUNDFirst-attempt picker found nothing.
402MERCHANT_INSUFFICIENT_BALANCENot enough balance — no retry, balance won’t recover.
409MERCHANT_PURCHASE_FAILEDRetry budget exhausted — all 5 attempts rejected by the provider, no items left after exclusions, or the 30s budget expired between attempts.

Example

curl -X POST https://api.tradeon.market/api/v1/merchant/buy-by-name \
  -H "Authorization: Bearer mer_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "market_hash_name": "Sticker | MIBR | Budapest 2025",
    "max_price": 0.05,
    "trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=12345&token=abc123",
    "custom_id": "order-2026-05-05-0001",
    "estimated_time": 60
  }'

When to use which endpoint

You know…Use
Exact item_id (from /items / /items/search), don’t need to wait for provider.POST /buy
Exact item_id, want to know the provider verdict in one call.POST /buy-sync
Just a market_hash_name and a price ceiling, want us to handle item-level races.POST /buy-by-name

Idempotency

Same custom_id + identical (item_id, target_steam_id, target_trade_url) and the existing order’s live price still ≤ the new max_price → replays the original 201 body without debiting again. Same custom_id with different parameters (different item, different recipient, or a max_price now lower than the existing order’s price) → 409 MERCHANT_DUPLICATE_CUSTOM_ID.

For POST /buy-by-name, idempotency keys off custom_id alone: a re-submit after the previous call reached a terminal state (completed / sent_to_steam / failed / refunded) replays that final order. A re-submit while a previous call is still in flight returns 202 with the in-flight order unchanged — it does not start a second retry cycle (that would double-charge the merchant under one custom_id).

Full retry semantics: Errors → Idempotency.