Errors

API-generated error responses use this 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. Gateways or transport failures may return no JSON body. Use the code and the recovery rules below together; a timeout or 5xx does not prove that a purchase failed.

Catalogue of codes

Auth (every endpoint)

See the canonical authentication failure table. Both missing and invalid keys return 401 MERCHANT_API_KEY_MISSING.

Catalog (GET /merchant/items*)

HTTPcodeMeaning
400MERCHANT_INVALID_QUERYmarket_hash_name missing in /items/search, or invalid filter.
503FEED_NOT_READYFull snapshot or WebSocket feed is not ready. Keep the last complete snapshot and retry with backoff.

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-name — market_hash_name empty or > 200 chars.
400MERCHANT_INVALID_ESTIMATED_TIMEPOST /buy-by-name — estimated_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; a refund is not implied. 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?
Other 4xx with stable codeFix the request or resolve the business failure. 409 MERCHANT_CONFLICT can be retried once with the same custom_id.
401 (auth)No — refresh / rotate the API key.
402 / 404 / other 409 (business)Surface the error. Do not create a replacement order automatically.
429Yes, exponential backoff. Honour Retry-After.
5xxRetry with backoff and jitter. For purchases, preserve the original custom_id and request.
Network / timeout / 408 on any purchase POSTOutcome is unknown. Recover with the same custom_id and request; never generate a replacement ID just because the response was lost.
202 on POST /buy-sync or POST /buy-by-nameThe attempt exists and is progressing. Track its result. See the by-name retry-cycle caveat below.

These are rules for calling the HTTP API. Your webhook receiver has a separate delivery retry policy. After a WebSocket disconnect, follow the close-code recovery table.

Idempotency

POST /merchant/buy, POST /merchant/buy-sync, and POST /merchant/buy-by-name are the purchase endpoints, and they share one idempotency key per merchant: custom_id. Persist a stable value (for example, your internal order ID) before sending the first POST. A replay does not start a second purchase operation; fields such as status and balance may reflect their current values rather than the original response.

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’s retry cycle has finished returns its existing final attempt. A re-submit while that cycle is still running can return the current attempt. The original cycle may later create another attempt with a different purchase_id; a failed attempt alone does not prove that the whole cycle has ended. The replay does not start a fresh cycle. Correlate your business order by custom_id and retain the purchase IDs of its attempts. /buy and /buy-sync replays return the existing purchase ID.

Idempotency is tied to the stored purchase. Do not reuse a custom_id for another business order. See purchase idempotency for the response semantics of each method.

Purchase failure codes

failure.code describes an existing purchase. It is separate from HTTP error.code (MERCHANT_*), which describes the request outcome. The same reasons are used by GET purchase, the purchase list, synchronous failure details and new purchase webhooks (data.failure_code). Match the code, not the message.

CodeMeaning
INVENTORY_PRIVATEBuyer inventory is private.
TRADING_UNAVAILABLETrading is unavailable for the buyer account; this does not prove an invalid URL.
INVALID_TRADE_URLThe trade URL or token is invalid or no longer valid.
TRADE_DECLINEDThe offer was declined.
TRADE_COUNTEREDAn explicit counteroffer was reported.
TRADE_CANCELLEDThe trade was cancelled; a more specific action or initiator is not known.
TRADE_EXPIREDThe offer expired.
SELLER_CANCELLEDSeller or provider cancelled the purchase.
ITEM_UNAVAILABLEThe item is unavailable.
PRICE_CHANGEDThe price changed.
PROVIDER_ERRORProvider or Steam processing/delivery error.
MANUAL_REVIEWPurchase outcome requires manual review.
REVERTED_BY_BUYERBuyer reversed an accepted trade; no refund.
REVERTED_BY_SELLERSeller reversed an accepted trade; full refund.
REVERT_UNKNOWNAccepted trade reversed, initiator unknown; manual review, no automatic refund.
UNKNOWNNo specific reason is available.

A malformed trade URL is rejected before purchase creation as MERCHANT_INVALID_TRADE_URL. A syntactically valid URL with a revoked token can fail later as INVALID_TRADE_URL. Generic trading restrictions do not establish which of these happened. Some providers cannot distinguish decline from a counteroffer and return TRADE_CANCELLED or UNKNOWN.

Codes describe reasons, not refund eligibility. Check refunded_amount: a positive value confirms the recorded refund, 0 explicitly means no refund, and null (or an omitted webhook field) means no refund is reported by that response. Ordinary merchant expiry refunds retain their existing fee exemption. A buyer’s rollback after delivery receives no refund, a seller’s rollback receives the full price, and an unknown rollback initiator requires manual review without automatic refund.

trade_offer_id is retained whenever supplied, including on failure. Null means the ID is unknown; it does not prove that no offer was sent. A later response with no ID does not erase an earlier known ID. No ID is synthesized.

Compatibility

Old purchases without a stored normalized reason use recognized shared categories or UNKNOWN; we cannot reconstruct a lost reason from an old generic message. GET/list do not rewrite purchase history. Already-saved webhook payloads and their retries retain their original codes/messages and may omit the newer offer fields. No events are automatically recreated or replayed. Accept historical and future codes as an unknown reason, and reconcile by purchase ID and the reported amounts.