Errors
All error responses share one shape:
{
"error": {
"code": "MERCHANT_PRICE_CHANGED",
"message": "Live price exceeds max_price"
}
}code is the stable contract — match on it programmatically. message is human
prose; do not pattern-match on it. The HTTP status helps you split transient (5xx /
429) from permanent (4xx) failures, but the code is what you branch on.
Catalogue of codes
Auth (every endpoint)
| HTTP | code | Meaning |
|---|---|---|
| 401 | MERCHANT_API_KEY_MISSING | Authorization header missing, malformed, or not a Bearer mer_live_… token. |
| 403 | MERCHANT_API_KEY_INVALID | Key not recognised (revoked, mistyped, or wrong). |
| 403 | MERCHANT_BLOCKED | Account blocked by the TradeOn admin team. Contact support. |
| 429 | MERCHANT_RATE_LIMITED | Per-merchant request budget exceeded. Honour Retry-After. |
Catalog (GET /merchant/items*)
| HTTP | code | Meaning |
|---|---|---|
| 400 | MERCHANT_INVALID_QUERY | market_hash_name missing in /items/search, or invalid filter. |
| 503 | FEED_NOT_READY | /items/full-dump cache not warm yet (≤ 60 s after deploy). Retry. |
Purchase (POST /merchant/buy, POST /merchant/buy-sync, POST /merchant/buy-by-name, GET /merchant/purchases*)
| HTTP | code | Meaning |
|---|---|---|
| 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 (or trade_url) malformed. |
| 400 | MERCHANT_INVALID_MARKET_HASH_NAME | POST /buy-by-name — market_hash_name empty or > 200 chars. |
| 400 | MERCHANT_INVALID_ESTIMATED_TIME | POST /buy-by-name — estimated_time < 10 minutes. |
| 400 | MERCHANT_NO_ITEMS_FOUND | POST /buy-by-name — no items match on the very first attempt. |
| 400 | MERCHANT_INVALID_STATUS | GET /purchases?status= / GET /webhook-events?status= value not in the documented enum. |
| 400 | MERCHANT_INVALID_SORT | GET /items?sort= value not in the documented enum. |
| 402 | MERCHANT_INSUFFICIENT_BALANCE | Available balance < live item price at debit time. (Not max_price — only the actual fill is debited.) |
| 404 | MERCHANT_ITEM_NOT_FOUND | item_id does not exist. |
| 404 | MERCHANT_PURCHASE_NOT_FOUND | purchase_id does not exist (or belongs to another merchant). |
| 409 | MERCHANT_DUPLICATE_CUSTOM_ID | custom_id reused with different parameters (different item / recipient / trade URL, or a max_price now lower than the existing order’s price). |
| 409 | MERCHANT_ITEM_UNAVAILABLE | Item is no longer active (sold, reserved, or removed by sync). |
| 409 | MERCHANT_PRICE_CHANGED | Live price now > max_price. Refetch and decide. |
| 409 | MERCHANT_PURCHASE_FAILED | POST /buy-sync — provider rejected the created order; debit refunded. POST /buy-by-name — all 5 attempts rejected, no items left after exclusions, or the 30s budget expired between attempts. |
| 409 | MERCHANT_CONFLICT | Generic concurrent-update conflict — safe to retry once. |
Anything else
| HTTP | code | Meaning |
|---|---|---|
| 500 | MERCHANT_INTERNAL_ERROR | Unhandled server-side error. Transient — retry with backoff. |
| 5xx | no code | Treat as transient. Retry with backoff and jitter. |
If you see > 5 transient errors in a minute, pause traffic for ~30s and retry behind a circuit breaker.
Retry guidance
| Class | Retry? |
|---|---|
4xx with stable code | No — fix the request. Exception: 409 MERCHANT_CONFLICT (retry once). |
401 (auth) | No — refresh / rotate the API key. |
402 / 404 / 409 (business) | No — surface to the caller, do not auto-retry. |
429 | Yes, exponential backoff. Honour Retry-After. |
5xx | Yes, with backoff + jitter. |
Network / timeout on POST /buy or POST /buy-sync | Yes — but reuse the same custom_id so the retry is idempotent. |
202 on POST /buy-sync | No — the order exists and is progressing; track it, don’t re-send. |
Idempotency
POST /merchant/buy, POST /merchant/buy-sync, and POST /merchant/buy-by-name are the
mutating endpoints, and they share one idempotency key per merchant: custom_id. Always
send a stable value (e.g. your own internal order ID). Replays return the original purchase
body untouched and never debit twice.
The identity check on /buy and /buy-sync matches when the new request has the same
(item_id, target_steam_id, target_trade_url) and the existing order’s price still fits
under the new max_price. Any mismatch — including a max_price lowered below the
existing order’s debited price — returns 409 MERCHANT_DUPLICATE_CUSTOM_ID.
For POST /buy-by-name, identity keys off custom_id alone. A re-submit after the previous
call reached a terminal state replays the final order. A re-submit while a previous call is
still in flight returns 202 with the in-flight order; it does not start a fresh retry
cycle (that would double-charge under one custom_id).
The idempotency window is the lifetime of the purchase row — for all practical purposes,
forever. Do not reuse a custom_id across different orders, ever.