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*)
| HTTP | code | Meaning |
|---|---|---|
| 400 | MERCHANT_INVALID_QUERY | market_hash_name missing in /items/search, or invalid filter. |
| 503 | FEED_NOT_READY | Full 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*)
| 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; 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. |
| 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? |
|---|---|
Other 4xx with stable code | Fix 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. |
429 | Yes, exponential backoff. Honour Retry-After. |
5xx | Retry with backoff and jitter. For purchases, preserve the original custom_id and request. |
Network / timeout / 408 on any purchase POST | Outcome 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-name | The 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.
| Code | Meaning |
|---|---|
INVENTORY_PRIVATE | Buyer inventory is private. |
TRADING_UNAVAILABLE | Trading is unavailable for the buyer account; this does not prove an invalid URL. |
INVALID_TRADE_URL | The trade URL or token is invalid or no longer valid. |
TRADE_DECLINED | The offer was declined. |
TRADE_COUNTERED | An explicit counteroffer was reported. |
TRADE_CANCELLED | The trade was cancelled; a more specific action or initiator is not known. |
TRADE_EXPIRED | The offer expired. |
SELLER_CANCELLED | Seller or provider cancelled the purchase. |
ITEM_UNAVAILABLE | The item is unavailable. |
PRICE_CHANGED | The price changed. |
PROVIDER_ERROR | Provider or Steam processing/delivery error. |
MANUAL_REVIEW | Purchase outcome requires manual review. |
REVERTED_BY_BUYER | Buyer reversed an accepted trade; no refund. |
REVERTED_BY_SELLER | Seller reversed an accepted trade; full refund. |
REVERT_UNKNOWN | Accepted trade reversed, initiator unknown; manual review, no automatic refund. |
UNKNOWN | No 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.