API referenceCreate purchases

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/buy

One 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

FieldTypeRequiredNotes
item_iduuidyesFrom CatalogItem.id.
max_pricedecimal (USD)yesHighest price you accept. Actual debit is ≤ max_price.
custom_idstring ≤ 100yesYour idempotency key — see idempotency.
target_trade_urlstringyesFull Steam trade URL of the end-user. SteamID64 is derived from the partner query parameter.
target_steam_idstringnoSteamID64 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

HTTPcodeWhen
400MERCHANT_INVALID_ITEM_IDitem_id missing or the empty 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 malformed.
402MERCHANT_INSUFFICIENT_BALANCEAvailable balance < live item price. (Not max_price — only the actual fill is debited.)
404MERCHANT_ITEM_NOT_FOUNDitem_id does not exist.
409MERCHANT_DUPLICATE_CUSTOM_IDcustom_id reused with different parameters.
409MERCHANT_ITEM_UNAVAILABLEItem no longer active (sold, reserved, removed by sync).
409MERCHANT_PRICE_CHANGEDLive price now > max_price. Refetch and decide.
409MERCHANT_CONFLICTConcurrent-update conflict — safe to retry once.

Create a purchase (synchronous)

POST /api/v1/merchant/buy-sync

Same 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

HTTPWhenBody
200 OKProvider acknowledged the purchase.Same shape as POST /buy’s 201 body. status reflects the current order state (typically processing).
202 Accepted30s 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}.
409The 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.

HTTPcodeWhen
409MERCHANT_ITEM_UNAVAILABLEItem was taken upstream between your request and the provider call.
409MERCHANT_PRICE_CHANGEDUpstream price moved above max_price before the provider call.
409MERCHANT_PURCHASE_FAILEDPurchase 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-name

Instead 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
}
FieldTypeRequiredDescription
market_hash_namestringyesExact Steam name, case-sensitive. Max 200 chars.
max_pricedecimalyesUSD ceiling per item. Items above are excluded.
trade_urlstringyesSteam trade URL of the recipient. SteamID is derived from the partner query parameter.
custom_idstringyesIdempotency key. Max 100 chars. Unique per merchant.
estimated_timeintnoMax 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.

HTTPWhenBody
200 OKProvider accepted the purchase on attempt N.Final winning order.
202 Accepted30s budget elapsed with an order still in flight.Last-attempted order — track it via webhooks / GET /purchases/{id}.
400 MERCHANT_NO_ITEMS_FOUNDNo items match market_hash_name + max_price + estimated_time on the very first attempt.Error body.
402 MERCHANT_INSUFFICIENT_BALANCEBalance is below the cheapest matching item.Error body.
409 MERCHANT_PURCHASE_FAILEDAll 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

HTTPcodeWhen
400MERCHANT_INVALID_MARKET_HASH_NAMEEmpty or > 200 chars.
400MERCHANT_INVALID_MAX_PRICEmax_price ≤ 0.
400MERCHANT_INVALID_TRADE_URLNot a valid Steam trade URL.
400MERCHANT_INVALID_CUSTOM_IDEmpty or > 100 chars.
400MERCHANT_INVALID_ESTIMATED_TIMEestimated_time < 10.
400MERCHANT_NO_ITEMS_FOUNDFirst-attempt picker found nothing.
402MERCHANT_INSUFFICIENT_BALANCENot enough balance — no retry, balance won’t recover.
409MERCHANT_PURCHASE_FAILEDRetry budget exhausted, or a repeated request found a failed attempt with another failure code.
409MERCHANT_ITEM_UNAVAILABLEA repeat found an attempt that failed because the item became unavailable.
409MERCHANT_PRICE_CHANGEDA 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.

EndpointMatching and repeat resultAdditional debit from the repeat
POST /buySame 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-syncSame 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-nameAfter 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

  1. Keep the saved custom_id, endpoint and body when a timeout or disconnect leaves the outcome unknown.
  2. Repeat that same request to recover the existing purchase or latest by-name attempt.
  3. If no purchase_id was received, query GET /api/v1/merchant/purchases?custom_id=YOUR_ID and follow next_cursor. An empty result while the POST is still running is not proof that no purchase will be created.
  4. Track the returned purchases and webhooks; confirm refunds separately from failure status.

Full retry semantics: Errors → Idempotency.