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. Each distinct purchase debits separately and has its own events.
Persist one custom_id with your business order before sending the POST, and keep it
for recovery after timeouts. The example identifiers and trade URL below are synthetic;
replace them with an active item and the intended recipient before making a real purchase.
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://merchant-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_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"
}max_price is your consent ceiling, not a reserved amount. For a $20.00 ceiling and
a $18.40 actual price, only $18.40 is debited; there is no $1.60 refund.
balance_after is the balance after creation, or the current balance on an idempotent repeat. 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 the empty 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 after creation (or the existing purchase on a repeat); 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; current in-flight status. The order is live — track it via webhooks or GET /purchases/{id}. |
409 | The created purchase failed. Check its detail and refund events to establish whether a refund was applied. | 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:
A failed existing purchase returns error together with purchase_id, custom_id,
failure: { code, message }, trade_offer_id, and trade_offer_url.
error.code remains an HTTP MERCHANT_* code; failure.code uses the
shared purchase reasons.
Unknown offer IDs are null. See the failed purchase response
for compatibility and refund semantics.
| 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 | Purchase failed for another reason; a refund is not implied by this error alone. |
Example
curl -X POST https://merchant-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_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 success body has the same shape as POST /buy-sync. An initial call returns the
accepted attempt or the last in-flight attempt. Attempts have separate purchase_id
values and share your custom_id; lookup by custom_id can return more than one purchase.
Intermediate failures do not emit purchase.created, and a 202 does not guarantee that
event. A concurrent repeat reports the latest existing attempt at that moment; the original
call can still advance to another attempt. See idempotency.
When a repeated request reports a specific failed attempt, its 409 body includes
that attempt’s purchase_id, shared failure, and known trade offer fields, as for
/buy-sync. Retry-cycle exhaustion remains an ordinary HTTP error body; reconcile
individual attempts through the purchase list and events.
| 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, or a repeated request found a failed attempt with another failure code. |
| 409 | MERCHANT_ITEM_UNAVAILABLE | A repeat found an attempt that failed because the item became unavailable. |
| 409 | MERCHANT_PRICE_CHANGED | A repeat found an attempt that failed because the price changed. |
Example
curl -X POST https://merchant-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
custom_id identifies your logical operation and is scoped to your merchant. Persist it
before the first request. Keep the endpoint and request parameters unchanged on retries;
never generate a fresh key just because the response was lost.
| Endpoint | Matching and repeat result | Additional debit from the repeat |
|---|---|---|
POST /buy | Same item, derived recipient and trade URL; the saved purchase price must fit the supplied max_price. Returns 201 with the existing purchase_id. | No. |
POST /buy-sync | Same checks as /buy. Waits on the existing purchase; returns 200 accepted, 202 in flight, or a 409 error body for a failed purchase. | No. |
POST /buy-by-name | After request validation, looks up the latest attempt by custom_id alone; other fields are not compared to the original request. Returns 200, 202, or a 409 error body according to that attempt. | Does not start a second attempt cycle. The original cycle may still create and debit its next attempt. |
For /buy and /buy-sync, changing the item, recipient or trade URL, or lowering
max_price below the saved price returns 409 MERCHANT_DUPLICATE_CUSTOM_ID.
The response is not a saved copy of the first HTTP body: status can advance and
balance_after on a repeat can reflect later balance activity.
For /buy-by-name, custom_id stays stable; purchase_id identifies one attempt.
A repeat during the original cycle can return attempt A, while that cycle later creates
attempt B with a different ID. Even a failed attempt observed by a concurrent repeat does
not prove the original cycle has stopped. Do not create a new business order from that
observation. Track the logical order by custom_id, keep the purchase IDs you receive,
and reconcile all matching purchases.
Recover after an unknown result
- Keep the saved
custom_id, endpoint and body when a timeout or disconnect leaves the outcome unknown. - Repeat that same request to recover the existing purchase or latest by-name attempt.
- If no
purchase_idwas received, queryGET /api/v1/merchant/purchases?custom_id=YOUR_IDand follownext_cursor. An empty result while the POST is still running is not proof that no purchase will be created. - Track the returned purchases and webhooks; confirm refunds separately from failure status.
Full retry semantics: Errors → Idempotency.