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/buyOne 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
| Field | Type | Required | Notes |
|---|---|---|---|
item_id | uuid | yes | From CatalogItem.id. |
max_price | decimal (USD) | yes | Highest price you accept. Actual debit is ≤ max_price. |
custom_id | string ≤ 100 | yes | Your idempotency key — see idempotency. |
target_trade_url | string | yes | Full Steam trade URL of the end-user. SteamID64 is derived from the partner query parameter. |
target_steam_id | string | no | SteamID64 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
| HTTP | code | When |
|---|---|---|
| 400 | MERCHANT_INVALID_ITEM_ID | item_id missing or not a UUID. |
| 400 | MERCHANT_INVALID_MAX_PRICE | max_price ≤ 0. |
| 400 | MERCHANT_INVALID_CUSTOM_ID | custom_id missing or > 100 chars. |
| 400 | MERCHANT_INVALID_STEAM_ID | target_steam_id supplied but not a valid SteamID64. |
| 400 | MERCHANT_STEAM_ID_MISMATCH | target_steam_id supplied and does not match the SteamID64 derived from target_trade_url (partner query). Omit it or supply a matching value. |
| 400 | MERCHANT_INVALID_TRADE_URL | target_trade_url malformed. |
| 402 | MERCHANT_INSUFFICIENT_BALANCE | Available balance < live item price. (Not max_price — only the actual fill is debited.) |
| 404 | MERCHANT_ITEM_NOT_FOUND | item_id does not exist. |
| 409 | MERCHANT_DUPLICATE_CUSTOM_ID | custom_id reused with different parameters. |
| 409 | MERCHANT_ITEM_UNAVAILABLE | Item no longer active (sold, reserved, removed by sync). |
| 409 | MERCHANT_PRICE_CHANGED | Live price now > max_price. Refetch and decide. |
| 409 | MERCHANT_CONFLICT | Concurrent-update conflict — safe to retry once. |
Create a purchase (synchronous)
POST /api/v1/merchant/buy-syncSame 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
| HTTP | When | Body |
|---|---|---|
200 OK | Provider acknowledged the purchase. | Same shape as POST /buy’s 201 body. status reflects the current order state (typically processing). |
202 Accepted | 30s 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}. |
409 | Provider 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:
| HTTP | code | When |
|---|---|---|
| 409 | MERCHANT_ITEM_UNAVAILABLE | Item was taken upstream between your request and the provider call. |
| 409 | MERCHANT_PRICE_CHANGED | Upstream price moved above max_price before the provider call. |
| 409 | MERCHANT_PURCHASE_FAILED | Provider 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-nameInstead 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
}| Field | Type | Required | Description |
|---|---|---|---|
market_hash_name | string | yes | Exact Steam name, case-sensitive. Max 200 chars. |
max_price | decimal | yes | USD ceiling per item. Items above are excluded. |
trade_url | string | yes | Steam trade URL of the recipient. SteamID is derived from the partner query parameter. |
custom_id | string | yes | Idempotency key. Max 100 chars. Unique per merchant. |
estimated_time | int | no | Max 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.
| HTTP | When | Body |
|---|---|---|
200 OK | Provider accepted the purchase on attempt N. | Final winning order. |
202 Accepted | 30s budget elapsed with an order still in flight. | Last-attempted order — track it via webhooks / GET /purchases/{id}. |
400 MERCHANT_NO_ITEMS_FOUND | No items match market_hash_name + max_price + estimated_time on the very first attempt. | Error body. |
402 MERCHANT_INSUFFICIENT_BALANCE | Balance is below the cheapest matching item. | Error body. |
409 MERCHANT_PURCHASE_FAILED | All 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
| HTTP | code | When |
|---|---|---|
| 400 | MERCHANT_INVALID_MARKET_HASH_NAME | Empty or > 200 chars. |
| 400 | MERCHANT_INVALID_MAX_PRICE | max_price ≤ 0. |
| 400 | MERCHANT_INVALID_TRADE_URL | Not a valid Steam trade URL. |
| 400 | MERCHANT_INVALID_CUSTOM_ID | Empty or > 100 chars. |
| 400 | MERCHANT_INVALID_ESTIMATED_TIME | estimated_time < 10. |
| 400 | MERCHANT_NO_ITEMS_FOUND | First-attempt picker found nothing. |
| 402 | MERCHANT_INSUFFICIENT_BALANCE | Not enough balance — no retry, balance won’t recover. |
| 409 | MERCHANT_PURCHASE_FAILED | Retry 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.