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)

HTTPcodeMeaning
401MERCHANT_API_KEY_MISSINGAuthorization header missing, malformed, or not a Bearer mer_live_… token.
403MERCHANT_API_KEY_INVALIDKey not recognised (revoked, mistyped, or wrong).
403MERCHANT_BLOCKEDAccount blocked by the TradeOn admin team. Contact support.
429MERCHANT_RATE_LIMITEDPer-merchant request budget exceeded. Honour Retry-After.

Catalog (GET /merchant/items*)

HTTPcodeMeaning
400MERCHANT_INVALID_QUERYmarket_hash_name missing in /items/search, or invalid filter.
503FEED_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*)

HTTPcodeMeaning
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 (or trade_url) malformed.
400MERCHANT_INVALID_MARKET_HASH_NAMEPOST /buy-by-namemarket_hash_name empty or > 200 chars.
400MERCHANT_INVALID_ESTIMATED_TIMEPOST /buy-by-nameestimated_time < 10 minutes.
400MERCHANT_NO_ITEMS_FOUNDPOST /buy-by-name — no items match on the very first attempt.
400MERCHANT_INVALID_STATUSGET /purchases?status= / GET /webhook-events?status= value not in the documented enum.
400MERCHANT_INVALID_SORTGET /items?sort= value not in the documented enum.
402MERCHANT_INSUFFICIENT_BALANCEAvailable balance < live item price at debit time. (Not max_price — only the actual fill is debited.)
404MERCHANT_ITEM_NOT_FOUNDitem_id does not exist.
404MERCHANT_PURCHASE_NOT_FOUNDpurchase_id does not exist (or belongs to another merchant).
409MERCHANT_DUPLICATE_CUSTOM_IDcustom_id reused with different parameters (different item / recipient / trade URL, or a max_price now lower than the existing order’s price).
409MERCHANT_ITEM_UNAVAILABLEItem is no longer active (sold, reserved, or removed by sync).
409MERCHANT_PRICE_CHANGEDLive price now > max_price. Refetch and decide.
409MERCHANT_PURCHASE_FAILEDPOST /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.
409MERCHANT_CONFLICTGeneric concurrent-update conflict — safe to retry once.

Anything else

HTTPcodeMeaning
500MERCHANT_INTERNAL_ERRORUnhandled server-side error. Transient — retry with backoff.
5xxno codeTreat 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

ClassRetry?
4xx with stable codeNo — 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.
429Yes, exponential backoff. Honour Retry-After.
5xxYes, with backoff + jitter.
Network / timeout on POST /buy or POST /buy-syncYes — but reuse the same custom_id so the retry is idempotent.
202 on POST /buy-syncNo — 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.