Realtime feed
Maintain a local copy of the CS2 catalogue with a full dump followed by WebSocket updates. The feed covers the entire visible market and uses the same prices and item fields as the REST catalogue.
wss://merchant-api.tradeon.market/api/v1/merchant/ws?cursor=<stream-id>
Authorization: Bearer mer_live_…Authenticate with the header on the upgrade request. API keys in the URL and browser session
cookies are not accepted. The authentication contract
also applies to this handshake: missing or invalid keys return
401 MERCHANT_API_KEY_MISSING; blocked merchants return 403 MERCHANT_BLOCKED.
Use a server-side WebSocket client that supports custom headers. Compression (permessage-deflate)
is negotiated when supported by the client.
Start from a snapshot
- Download
/items/full-dumpwithAccept-Encoding: gzipinto temporary storage. Finish decompression and JSON validation before changing your working catalogue. - Atomically install the complete snapshot and its
ETag,X-Catalog-Cursor, andX-Catalog-Epochmetadata. Keep your previous complete state if the download fails. - Connect using the snapshot cursor. Check that
hello.epochmatches the snapshot epoch, then process replay and live batches in order. A mismatch requires a fresh snapshot; do not combine epochs. - Persist a batch’s cursor after applying all its events. On disconnect, reconnect with that committed cursor. Reply to each JSON ping as described below.
The cursor is captured before the database is read, so replay can overlap the dump. Apply events
idempotently as described below. If a dump has no cursor headers, the engine is restarting or the
dump crossed an epoch change. Continue using REST and wait for a freshly generated dump with headers.
The headers on a 304 belong to the previously cached body. Reuse a 304 only when
you still have that body and its metadata; never replace a working snapshot with an
empty response. Keep the snapshot cursor separate from the cursor committed after
live updates.
Connecting without a cursor starts live at the current head. This is useful for monitoring,
but cannot build a consistent catalogue from an earlier dump. A malformed cursor returns HTTP 400.
If the stream is not ready, the handshake returns HTTP 503 FEED_NOT_READY.
Frames
All frames are UTF-8 JSON with v: 1. Ignore unknown fields and event types.
The first frame is hello:
{"v":1,"type":"hello","epoch":"1789592476778-0","cursor":"1789594021603-0","replay":true,"server_time":"2026-09-16T21:27:01Z"}Its cursor is the head captured at connection time. Do not save the hello cursor as processed:
the replay up to that point follows it. replay is false when no cursor was supplied.
Each batch corresponds to one stored stream entry, with at most 1,000 events:
{
"v": 1,
"type": "batch",
"cursor": "1789594022100-0",
"epoch": "1789592476778-0",
"ts": "2026-09-16T21:27:02Z",
"events": [
{"type":"offer.price_changed","id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","price":12.34,"previous_price":12.50},
{"type":"offer.removed","id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","reason":"reserved"}
]
}ts is the start of the engine tick, not the time the entry reached Redis. Resume using cursor,
never a timestamp. Treat cursor and epoch as opaque strings; numeric stream ids do not sort lexically.
Every 15 seconds the server sends:
{"v":1,"type":"ping","cursor":"1789594022100-0","engine":"live"}Reply with {"type":"pong"} as a text message. Automatic WebSocket control-frame pong replies
do not replace this JSON response. After 60 seconds without a JSON pong the server closes with 1001.
Other client messages are ignored. The ping cursor only covers batches sent earlier on this connection;
save it only after those batches have been applied.
engine: "degraded" means the engine heartbeat is missing, invalid, or at least 60 seconds old.
The REST catalogue continues to work,
but changes may not be entering the feed. Temporarily use full dumps. When the engine starts a new
epoch, the connection closes with 4001 and a fresh snapshot is required.
Events
| Type | Fields | Apply to your catalogue |
|---|---|---|
offer.added | id, offer (full CatalogItem) | Insert or replace the item. |
offer.price_changed | id, price, previous_price | Set the absolute price. Ignore unknown ids. |
offer.removed | id, reason | Delete the item. Ignore unknown ids. |
Prices are JSON numbers in the same currency and format as REST. reason is removed (listing gone),
reserved, sold, or hidden (no longer visible, including deduplication). An item returning to
visibility receives a new offer.added with the complete object.
Changes within an engine tick are combined into one final event per item. Corrections are retained
when a dump could have captured an intermediate state: a price round trip can produce
price_changed with price equal to previous_price; a temporary disappearance can produce
added for an item you already hold; an offer created and deleted in one tick can produce removed
for an id you never saw. Always apply the absolute final value, using the idempotent rules above.
The feed tracks visibility and price; use an hourly full dump to refresh other item attributes.
Item added: offer.added
The next three examples show one listing appearing, changing price, and disappearing from
an already initialized local catalogue. Each JSON block is a complete WebSocket batch
message with one event. Real batches can contain events for many different listings.
The IDs, timestamps, and item data below are synthetic.
id identifies the listing, not a unique event. It stays the same across all three
events and matches offer.id. Save progress using the batch cursor; there is no separate
webhook-style event_id in this feed.
When a listing becomes visible, offer.added includes its full CatalogItem:
{
"v": 1,
"type": "batch",
"cursor": "1789594023000-0",
"epoch": "1789592476778-0",
"ts": "2026-09-16T21:27:03Z",
"events": [
{
"type": "offer.added",
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"offer": {
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"market_hash_name": "AK-47 | Redline (Field-Tested)",
"image_url": "https://cdn.tradeon.market/items/ak47-redline-ft.png",
"price": 12.50,
"exterior": "Field-Tested",
"exterior_code": "FT",
"float": 0.2451,
"paint_seed": 462,
"paint_index": 316,
"inspect_url": null,
"asset_id": null,
"steam_id": null,
"class_id": null,
"instance_id": null,
"is_stat_trak": false,
"is_souvenir": false,
"has_stickers": false,
"stickers_count": 0,
"stickers": [],
"category": "rifle",
"weapon_type": "AK-47",
"rarity": "classified",
"rarity_color": "#d32ce6",
"delivery_type": "instant",
"estimated_delivery_minutes": 2
}
}
]
}Apply: insert offer into your catalogue, keyed by id. If that ID already exists,
replace its entire stored object. The local listing now has a price of $12.50.
An add can repeat during replay or when a previously hidden listing becomes visible again;
it does not always mean a brand-new item.
Price changed: offer.price_changed
A price update contains the listing ID and prices, without resending the full item:
{
"v": 1,
"type": "batch",
"cursor": "1789594024000-0",
"epoch": "1789592476778-0",
"ts": "2026-09-16T21:27:04Z",
"events": [
{
"type": "offer.price_changed",
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"price": 12.34,
"previous_price": 12.50
}
]
}Apply: find the listing by id and set its price to $12.34, keeping the other
item fields. price is an absolute value, not an amount to add or subtract.
previous_price is informational: do not require your local price to match it before
applying the update, because a snapshot and replay can overlap. Ignore an unknown ID.
The feed has no general-purpose “item changed” event for other attributes.
Item removed: offer.removed
A listing leaves the visible catalogue with an ID and a reason, without the item object:
{
"v": 1,
"type": "batch",
"cursor": "1789594025000-0",
"epoch": "1789592476778-0",
"ts": "2026-09-16T21:27:05Z",
"events": [
{
"type": "offer.removed",
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"reason": "reserved"
}
]
}Apply: delete the listing from your local catalogue so it is no longer offered for sale.
If the ID is already absent, do nothing. Apply the same deletion for reserved, sold,
hidden, and removed; the reason explains why visibility changed. A removal does not
confirm the outcome of your purchase — use purchase status
and purchase webhooks for that.
After each complete batch, commit its catalogue changes and cursor together. In this sequence, the listing goes from absent → $12.50 → $12.34 → absent. See the runnable Node.js and Python clients for the full processing loop.
Reconnect and limits
| Close code | Reason | Recovery |
|---|---|---|
| 4001 | resync_required: cursor outside the retained tail, from an old epoch, or in the future | Wait for a newly generated full dump, replace your catalogue, and use its cursor. |
| 4002 | slow_consumer: queue exceeds 5 MiB or waits over 30 seconds | Reconnect with your last fully processed batch cursor. |
| 4003 | too_many_connections: a third connection on the key replaced the oldest | Keep at most two connections per key. |
| 1001 | API shutdown, missing JSON pong, or interrupted stream reader | Reconnect with the last processed cursor. |
A broken or stalled transport may disconnect without delivering a close frame. Resume from the last processed cursor in that case too. Use a short delay with backoff between connection attempts.
The connection limit applies per API process; design for at most two connections per key. The retained tail varies with activity. An initial rollout measurement at 10,000 entries covered about 1.6 hours; this is a historical observation, not a guaranteed time window. A restart of the catalogue engine starts a new epoch even if older events are still retained. Full dumps regenerate about every 120 seconds; their limit remains one request per 60 seconds. Normal per-merchant request limits apply to the handshake, not to WebSocket frames.
Delivery is ordered and at least once when resuming. Reapplying a batch must be safe. Events outside the retained tail or current epoch require a full resync; the server does not silently skip the gap.