Required on every server-to-server request.
Merchant API
Integrate CS2Shop inventory and purchases into your store.
Read the catalog, purchase items for your buyers, and receive delivery updates.
Fetch the catalog, then keep it updated over WebSocket.
All prices are in US dollars.
Use webhooks first and history as the fallback source.
Fetch the catalog, create a purchase, then track delivery.
Set CS2SHOP_MERCHANT_ID and CS2SHOP_API_KEY in your backend environment. These cURL examples use a POSIX shell. The purchase request creates a real order.
1. Read the catalog
# Set CS2SHOP_MERCHANT_ID and CS2SHOP_API_KEY in your server environment.
curl --fail-with-body "https://cs2shop.net/v1/merchant/listings/catalog" \
-H "x-merchant-id: $CS2SHOP_MERCHANT_ID" \
-H "x-api-key: $CS2SHOP_API_KEY"Store items[].listingId, items[].price and the snapshot sequence. For ongoing updates, follow Realtime catalog.
2. Create one trade
# Creates a real order. Replace the listing, price, URL and order reference.
curl --fail-with-body -X POST "https://cs2shop.net/v1/merchant/trades" \
-H "x-merchant-id: $CS2SHOP_MERCHANT_ID" \
-H "x-api-key: $CS2SHOP_API_KEY" \
-H "Content-Type: application/json" \
--data '{
"externalRef": "your-unique-order-reference",
"listingId": "listing-id-from-catalog",
"expectedPriceUsd": 15.00,
"tradeUrl": "https://steamcommunity.com/tradeoffer/new/?partner=123456789&token=AbCdEfGh"
}'Persist your unique externalRef before sending. HTTP 200 means the request was accepted; PENDING does not mean the buyer has received the item.
3. Track the order
curl --fail-with-body --get "https://cs2shop.net/v1/merchant/trades/by-external-ref" \
-H "x-merchant-id: $CS2SHOP_MERCHANT_ID" \
-H "x-api-key: $CS2SHOP_API_KEY" \
--data-urlencode "externalRef=your-unique-order-reference"Register your webhook URL for updates and use lookup as the recovery path. If the POST times out, follow Retries & idempotency before sending again.
Connection details, authentication and request limits.
Base URL
https://cs2shop.netUse the /v1/merchant endpoints below from your backend. Send JSON request bodies with Content-Type: application/json.
Authentication
x-merchant-id | Your merchant ID |
|---|---|
x-api-key | Your merchant API key |
Content-Type | application/json for POST requests |
curl "https://cs2shop.net/v1/merchant/listings/catalog" \
-H "x-merchant-id: your-merchant-id" \
-H "x-api-key: cs2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H 'If-None-Match: "previous-etag-if-you-have-one"'Use your merchant ID and API key on every request. Keep the key on your server; do not include it in browser code or URLs. Invalid credentials return 401 unauthorized.
Webhooks are signed with the same API key. Update your signature verifier when you rotate the key.
Values and timestamps
| Prices | USD numbers: 15 and 15.00 both mean $15.00. Use cent precision for monetary calculations. |
|---|---|
| Steam identifiers | Keep Steam IDs, asset IDs and offer IDs as strings to avoid precision loss. |
| Timestamps | UTC ISO 8601, for example 2026-09-30T08:35:12.000Z. Convert to local time for display. |
| Missing values | Optional fields such as floatValue, inspectLink and offerId may be null. |
Rate limits
All REST endpoints share a limit of 10 requests per second per merchant ID. This includes catalog requests that return 304. On 429 rate_limited, wait at least one second and retry with backoff; a Retry-After header is not guaranteed.
- Use the full catalog snapshot and WebSocket updates instead of frequent full downloads. As a fallback, refresh every five seconds with
If-None-Match. - Use webhooks for trade notifications and lookup/history to recover missed updates.
- Limit simultaneous orders to the same recipient. The default active-trade limit is 20; exceeding your account limit returns
403 too_many_active_trades.
WebSocket connections have separate limits.
Fetch listings and keep your store's inventory up to date.
Full catalog
/v1/merchant/listings/catalogcurl "https://cs2shop.net/v1/merchant/listings/catalog" \
-H "x-merchant-id: your-merchant-id" \
-H "x-api-key: cs2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H 'If-None-Match: "previous-etag-if-you-have-one"'| Optional header | If-None-Match: previous ETag |
|---|---|
| Success | 200 with full catalog JSON |
| Unchanged | 304 with empty body when ETag still matches |
| Response headers | ETag, X-Catalog-Sequence, X-Catalog-Generated-At; Cache-Control: private, no-cache. Preserve ETag verbatim, including quotes. |
| Scope | Full merchant catalog. This endpoint does not accept pagination or search parameters. |
Save ETag and send it as If-None-Match on the next refresh. A 304 has no body; keep your existing snapshot.
Response
{
"merchantId": "your-merchant-id",
"mode": "catalog",
"sequence": 1234,
"generatedAt": "2026-06-14T05:07:10.720Z",
"items": [
{
"listingId": "a1b2c3d4-0000-0000-0000-000000000000",
"itemId": 4162118646,
"assetId": "50985016185",
"market_hash_name": "★ StatTrak™ Falchion Knife | Autotronic (Field-Tested)",
"phase": null,
"paint_index": null,
"paint_seed": null,
"price": 129.54,
"tradableAt": "2026-04-18T07:00:00.000Z",
"availableAfter": "2026-04-18T07:00:00.000Z",
"steamId": "76561199835130929",
"classId": "7993046236",
"instanceId": "8364272612",
"floatValue": 0.1845123917,
"inspectLink": "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20..."
}
],
"total": 9970,
"totalValue": 17000.88
}Use listingId to purchase an item. Save sequence for WebSocket updates. Images are not included.
Catalog fields
| sequence | Monotonic catalog event sequence. Persist it and pass it as catalogSequence when subscribing to WebSocket deltas. |
|---|---|
items[].listingId | Stable listing identity to send into POST /v1/merchant/trades. |
items[].itemId | Compatibility numeric item id. Do not use this for new checkout integrations. |
items[].assetId | Steam asset id for display/reconciliation. |
items[].phase | Doppler variant derived from Steam inspect metadata: Phase 1-4, Ruby, Sapphire, Black Pearl, or Emerald; null for non-Doppler or unresolved items. |
items[].paint_index | Steam paint kit ID (integer), or null when unavailable. |
items[].paint_seed | Steam paint pattern seed (integer), or null when unavailable. Zero is a valid value, not a missing-data marker. |
items[].price | Current USD sell price. Send this or a higher ceiling as expectedPriceUsd when creating a trade. |
items[].tradableAt | Steam unlock time, or null if not locked. |
items[].availableAfter | Latest hold/trade-lock time, or null if immediately movable. |
items[].floatValue | CS2 float value, or null when Steam did not provide one. |
items[].inspectLink | Steam inspect link, or null when unavailable. |
Search and item lookup
| Method | Path | Description |
|---|---|---|
| GET | /v1/merchant/listings | Search the catalog with pagination. Supports page, limit, and search. |
| GET | /v1/merchant/listings/:itemId | Read one listing by merchant-facing itemId or listingId. |
| page / limit | page defaults to 1; limit defaults to 80, maximum 500. |
|---|---|
| search | Case-insensitive substring of market_hash_name. |
| Search response | { merchantId, items, total, limit, page, totalValue }. total and totalValue cover all matching items. |
| Item lookup | Pass listingId as :itemId. Returns { merchantId, item }. Numeric itemId is also accepted for existing integrations. |
Use the full catalog for the initial WebSocket snapshot; search pages do not include sequence.
Availability
Locked items stay in the catalog. Check availableAfter and tradableAt before offering immediate delivery. Listings are not reserved; price and availability are checked again when you create a purchase.
Purchase one listing for a buyer's Steam trade URL.
/v1/merchant/tradesRequest
# Creates a real order. Replace the listing, price, URL and order reference.
curl --fail-with-body -X POST "https://cs2shop.net/v1/merchant/trades" \
-H "x-merchant-id: $CS2SHOP_MERCHANT_ID" \
-H "x-api-key: $CS2SHOP_API_KEY" \
-H "Content-Type: application/json" \
--data '{
"externalRef": "your-unique-order-reference",
"listingId": "listing-id-from-catalog",
"expectedPriceUsd": 15.00,
"tradeUrl": "https://steamcommunity.com/tradeoffer/new/?partner=123456789&token=AbCdEfGh"
}'listingId | Required. Use items[].listingId from the catalog response. |
|---|---|
expectedPriceUsd | Required. Maximum acceptable USD price. If the verified item price is lower, the trade proceeds at the lower price; if it is higher, the request is rejected. |
tradeUrl | Required. Buyer Steam trade URL. |
externalRef | Required non-empty string, trimmed by the API and unique per merchant. Persist before sending. Duplicates return 400 duplicate_external_ref; resolve with trade lookup. |
tradeDurationSeconds | Optional integer, default 1800; validated range 600-43200. Track the order status to confirm completion. |
Response - 200 OK
{
"merchantId": "your-merchant-id",
"externalRef": "order_1001",
"tradeId": "0b6fbb5a-50cf-4d63-9a31-cb612bdff550",
"status": "PENDING",
"price": 15,
"priceUsd": 15,
"offerId": null,
"tradeUrl": "https://steamcommunity.com/tradeoffer/new/?partner=123456789&token=AbCdEfGh",
"market_hash_name": "AK-47 | Redline (Field-Tested)",
"marketHashName": "AK-47 | Redline (Field-Tested)",
"webhookStatus": null,
"failReason": null,
"errorCode": null,
"createdAt": "2026-04-06T08:30:00.000Z",
"updatedAt": "2026-04-06T08:30:00.000Z",
"completedAt": null,
"cancelledAt": null
}For a failed delivery, failReason contains Steam error details when available. Some delivery failures return Failed to send the item via Steam. instead. Use status to track fulfillment; do not depend on exact error-message text.
200 accepts the order; it does not confirm delivery. Save externalRef and track the current state through webhooks or trade lookup.
Trade response fields
| Field | Type | Required | Description |
|---|---|---|---|
merchantId | string | Yes | Authenticated merchant identity. |
externalRef | string | Yes | Your order reference. Use it as the durable key for reconciliation. |
tradeId | string | Yes | CS2Shop trade ID. This can change while the order is pending; use externalRef as your stable order key. |
status | string | Yes | PENDING, SENT, COMPLETED, REVERSED, CANCELLED, FAILED or EXPIRED. |
priceUsd / price | number | Yes | Charged USD amount; price is a compatibility alias. |
offerId | string | null | Yes | Steam offer ID when available. Keep it as a string. |
tradeUrl | string | Yes | Buyer's Steam trade URL from the order. |
marketHashName / market_hash_name | string | null | Yes | Item name; both aliases are returned. |
webhookStatus | string | null | Yes | Webhook delivery state, independent of trade status; may be null before a delivery update. |
failReason / errorCode | string | null / number | null | Yes | Failure details from Steam. Internal delivery errors return "Failed to send the item via Steam." Reversal reasons identify the reversal side. errorCode is not the HTTP response status. |
createdAt / updatedAt | ISO 8601 strings | Yes | Order record timestamps in UTC. |
completedAt / cancelledAt | ISO 8601 string | null | Yes | Lifecycle timestamps when available. Reversals can clear completedAt. |
Order statuses
PENDING | The request is accepted but no Steam offer has been sent yet. |
|---|---|
SENT | The Steam offer was created and is waiting for the buyer or Steam settlement. |
COMPLETED | Steam accepted and completed the item transfer. |
REVERSED | A previously completed Steam transfer was rolled back. Inspect failReason to identify the reversal side. |
CANCELLED | The request or Steam offer was cancelled before completion. |
FAILED | Delivery failed and will not continue automatically. |
EXPIRED | The fulfillment window expired before completion. |
Reversals
A completed trade can become REVERSED. The purchase amount is refunded to your merchant balance; use the current lookup result to update your order.
| Buyer reversal | status is REVERSED; failReason is "Trade was reversed by Buyer after completion". |
|---|---|
| CS2Shop/seller reversal | status is REVERSED; failReason is "Trade was reversed by Seller after completion". |
Reversal response examples
// Buyer reversal
{
"status": "REVERSED",
"failReason": "Trade was reversed by Buyer after completion",
"errorCode": 500,
"completedAt": null,
"cancelledAt": "2026-09-23T12:30:00.000Z"
}
// CS2Shop/seller reversal
{
"status": "REVERSED",
"failReason": "Trade was reversed by Seller after completion",
"errorCode": 500,
"completedAt": null,
"cancelledAt": "2026-09-23T12:30:00.000Z"
}errorCode describes the trade, not the HTTP status. Successful lookup still returns HTTP 200.
Purchase errors
400 invalid_request | Required fields are missing or expectedPriceUsd is invalid. |
|---|---|
400 invalid_price | The submitted exact-item price must be greater than zero. |
400 invalid_trade_duration | tradeDurationSeconds must be between 600 and 43200. |
400 price_changed | The verified item price is now above expectedPriceUsd; refresh catalog and retry with a higher ceiling if acceptable. |
400 item_unavailable | The listing is no longer available or is not tradable right now. |
400 asset_mismatch | The legacy itemId/assetId pair does not identify the same listing. |
400 duplicate_external_ref | externalRef was already used for another trade. |
400 invalid_tradeurl | tradeUrl is not a valid Steam trade offer URL. |
400 insufficient_balance | The merchant does not have enough purchasing power. |
403 partner_blocked | This trade URL owner is temporarily blocked by risk rules. |
403 too_many_active_trades | The trade URL owner already has the merchant's maximum number of active trades. |
429 rate_limited | Merchant API request budget exceeded. |
503 inventory_check_pending | A fresh inventory check is already running; retry shortly. |
503 inventory_check_unavailable | Availability could not be checked. Back off and look up externalRef before retrying. |
500 internal_error | Unexpected server failure. Look up externalRef before retrying the purchase. |
After a timeout or server error, look up externalRef before retrying.
Read your balance and remaining purchasing power. All amounts are USD.
Read balance
/v1/merchant/walletcurl "https://cs2shop.net/v1/merchant/wallet" \
-H "x-merchant-id: your-merchant-id" \
-H "x-api-key: cs2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"Use the same x-merchant-id and x-api-key headers as other merchant endpoints. No query parameters or request body are required.
Response - 200 OK
Core fields for integration:
{
"merchantId": "your-merchant-id",
"balance": 1000,
"total_available": 1000,
"total_frozen": 0
}merchantId | Authenticated merchant ID. |
|---|---|
| balance | Actual unlocked USD balance. |
total_available | USD available for purchases. Use this field for a preliminary purchase balance check. |
total_frozen | Locked USD balance. Excluded from balance and total_available. |
Balance history
/v1/merchant/wallet/historyRead debits and refunds to reconcile your balance with purchases.
curl --fail-with-body --get "https://cs2shop.net/v1/merchant/wallet/history" \
-H "x-merchant-id: your-merchant-id" \
-H "x-api-key: cs2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
--data-urlencode "page=1" \
--data-urlencode "limit=50" \
--data-urlencode "from=2026-10-01" \
--data-urlencode "to=2026-10-08"| page / limit | page defaults to 1; limit defaults to 50, maximum 500. |
|---|---|
tradeId | Optional. Exact match against transactions[].tradeId; omit it to include entries without a trade. |
| type | Optional. Exact, case-sensitive match against a type returned in transactions. |
| from / to | Optional, inclusive transaction creation-time bounds. Accept ISO timestamps or YYYY-MM-DD dates. Date-only bounds use the full UTC day. |
{
"merchantId": "your-merchant-id",
"transactions": [
{
"id": 1,
"type": "FREEZE",
"amount": -15,
"balanceBefore": 1000,
"balanceAfter": 985,
"tradeId": "trade-id",
"note": "Balance frozen for pending buy",
"createdAt": "2026-10-08T09:00:00.000Z",
"market_hash_name": "AK-47 | Searing Rage (Minimal Wear)"
}
],
"total": 1,
"limit": 50,
"page": 1
}transactions[].amount | Signed USD change: negative deducts, positive credits, zero leaves the balance unchanged. |
|---|---|
balanceBefore / balanceAfter | USD balance immediately before and after the entry. These are historical values, not the current purchasing power. |
tradeId / market_hash_name / note | Associated trade, item name and description. Each can be null. |
createdAt | Transaction creation time as an ISO timestamp. |
| total / page / limit | Matching entry count and pagination. Results are newest first. |
Purchase transaction types
FREEZE | Purchase debit when funds are committed to an order. |
|---|---|
SETTLE | Purchase settlement; amount is usually zero because the debit already occurred. |
REFUND | Funds returned after a failed or cancelled delivery. |
ROLLBACK | Funds returned after a trade reversal. |
Other balance changes can have different types. Use the returned type value to filter them. Invalid date bounds return 400 invalid_request. New entries can shift page boundaries; filter a bounded period and use trade lookup to confirm fulfillment status.
Purchases and balance
The purchase amount is deducted when an order is accepted and refunded if delivery fails or the trade is reversed. A deposit or top-up adds to the actual balance.
A balance check does not reserve funds. The API checks the available funds again when accepting a purchase. A purchase that exceeds them returns 400 insufficient_balance. This endpoint shares the 10 requests per second merchant limit and standard authentication and availability errors.
Receive trade updates at your HTTPS callback URL.
For price and inventory updates, use the catalog WebSocket.
| Method | Path | Description |
|---|---|---|
| POST | /v1/merchant/webhooks/SetEventWebhook | Set the callback URL for the authenticated merchant. |
Registration request
curl -X POST "https://cs2shop.net/v1/merchant/webhooks/SetEventWebhook" \
-H "x-merchant-id: your-merchant-id" \
-H "x-api-key: cs2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://merchant.example/webhooks/cs2shop"
}'| url | Required absolute HTTP/HTTPS callback URL; use HTTPS. Localhost is rejected in production. Saving a new value replaces the previous callback. |
|---|---|
eventTypes | Optional. This field does not filter delivery. Omit it and handle events using x-webhook-type. |
| Response | HTTP 200: { success, merchantId, webhookUrl, eventTypes, updatedAt }. |
Response - 200 OK
{
"success": true,
"merchantId": "your-merchant-id",
"webhookUrl": "https://merchant.example/webhooks/cs2shop",
"eventTypes": ["trade_updated", "item_removed"],
"updatedAt": "2026-09-30T08:35:12.000Z"
}trade_updated payload
{
"tradeId": "0b6fbb5a-50cf-4d63-9a31-cb612bdff550",
"externalId": "order_1001",
"status": "COMPLETED",
"offerId": "1234567890",
"failReason": null,
"errorCode": null
}externalId is your original externalRef. Use it to look up the order.
tradeId / externalId | CS2Shop trade ID and your order reference. |
|---|---|
status / offerId | Current fulfillment status and Steam offer ID (string or null). |
failReason / errorCode | Steam failure details or a public delivery message, and trade error code; both may be null. Reversal reasons identify the reversal side. |
item_removed payload
{ "itemId": 4162118646 }x-webhook-type: item_removed identifies this event. itemId is the compatibility numeric item ID, or null when unavailable. Use WebSocket deltas for full inventory updates.
Request headers
Content-Type | application/json. Preserve the exact body bytes before JSON parsing. |
|---|---|
x-merchant-id | Merchant receiving this callback. Check it against the intended account. |
x-webhook-type | Event discriminator: trade_updated or item_removed. Ignore other event types safely. |
x-signature | Hex HMAC-SHA256 of the raw request body, signed with your merchant API key. Reject missing/invalid signatures. |
Webhook and request-validation errors
400 INVALID_WEBHOOK_URL | URL is missing, is not absolute, does not use HTTP/HTTPS, or targets localhost in production. |
|---|---|
404 MERCHANT_NOT_FOUND | The merchant account could not be found. Check your credentials or contact support. |
400 HTTP_ERROR | Invalid request body. message describes the invalid field or fields. |
Signature verification
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the original Buffer, before a JSON body parser runs.
export function verifyWebhook(rawBody, receivedSignature, apiKey) {
if (!Buffer.isBuffer(rawBody) || !apiKey) return false;
if (typeof receivedSignature !== "string" ||
!/^[0-9a-f]{64}$/i.test(receivedSignature)) return false;
const expected = createHmac("sha256", apiKey).update(rawBody).digest();
const received = Buffer.from(receivedSignature, "hex");
return timingSafeEqual(received, expected);
}Pass the original body Buffer, the x-signature header and your API key to this function before parsing or processing the body. In Express, mount express.raw({ type: "application/json" }) on the callback route before a global JSON parser. Do not verify a reserialized JSON object.
Handling callbacks
- Verify
x-merchant-idand the signature against the raw request body. - Read
x-webhook-type. Return2xxfor event types your integration does not use. - Save the event to a durable queue, then return
2xxpromptly. - For
trade_updated, look up the order usingexternalIdasexternalRefand save the current trade state.
Callbacks may be duplicated or arrive late. The trade payload has no delivery ID or timestamp, so use the lookup result rather than callback arrival order to update an order.
Do not rely on automatic retries of failed callbacks. Reconcile unresolved orders through lookup/history; repeated failures can pause callbacks.
Read an order by reference and recover missed state updates
Lookup by external reference
curl --fail-with-body --get "https://cs2shop.net/v1/merchant/trades/by-external-ref" \
-H "x-merchant-id: $CS2SHOP_MERCHANT_ID" \
-H "x-api-key: $CS2SHOP_API_KEY" \
--data-urlencode "externalRef=your-unique-order-reference"externalRef is required and must be URL encoded. HTTP 200 returns one trade with the trade response schema. A missing reference returns 400 invalid_request; no matching record returns 404 trade_not_found.
History query
curl --fail-with-body --get "https://cs2shop.net/v1/merchant/trades/history" \
-H "x-merchant-id: $CS2SHOP_MERCHANT_ID" \
-H "x-api-key: $CS2SHOP_API_KEY" \
--data-urlencode "page=1" \
--data-urlencode "limit=50" \
--data-urlencode "from=2026-09-01T00:00:00.000Z" \
--data-urlencode "to=2026-09-27T23:59:59.999Z"| page | Positive integer; default 1, maximum 100000. |
|---|---|
| limit | Positive integer; default 50, maximum 500. |
externalRef | Exact merchant order reference; externalId is a compatibility alias. Prefer this to locate an order. |
tradeId | CS2Shop trade ID, distinct from the Steam offerId. Use externalRef to locate an order before a tradeId is available. |
| status | PENDING, SENT, COMPLETED, REVERSED, CANCELLED, FAILED or EXPIRED. Use one of these values. |
webhookStatus | Exact delivery-state string, such as delivered, failed or paused; match the value returned by the API. |
| from / to | Inclusive createdAt bounds. Use UTC ISO 8601 timestamps, or YYYY-MM-DD for the start/end of a full UTC day. These do not filter updatedAt. |
| Date aliases | createdFrom/dateFrom and createdTo/dateTo are accepted; prefer from/to. Invalid bounds or from > to return 400 invalid_request. |
History response
{
"merchantId": "your-merchant-id",
"trades": [
{
"merchantId": "your-merchant-id",
"externalRef": "order_1001",
"tradeId": "0b6fbb5a-50cf-4d63-9a31-cb612bdff550",
"status": "PENDING",
"price": 15,
"priceUsd": 15,
"offerId": null,
"tradeUrl": "https://steamcommunity.com/tradeoffer/new/?partner=123456789&token=AbCdEfGh",
"market_hash_name": "AK-47 | Redline (Field-Tested)",
"marketHashName": "AK-47 | Redline (Field-Tested)",
"webhookStatus": null,
"failReason": null,
"errorCode": null,
"createdAt": "2026-04-06T08:30:00.000Z",
"updatedAt": "2026-04-06T08:30:00.000Z",
"completedAt": null,
"cancelledAt": null
}
],
"total": 1,
"limit": 50,
"page": 1
}trades uses the same schema as single-trade lookup. Results are ordered by createdAt descending. total is the number of matching records; pagination is page based, not cursor based.
Reconciliation rules
- Use webhook notifications to trigger lookup by
externalRef, and periodically reconcile unresolved orders. - Date filters select creation time. An older order can change status later, so a recent createdAt window alone will miss that change.
- New orders can shift page boundaries while you read. Deduplicate by your order reference, overlap reconciliation windows, and use direct lookup for known orders.
COMPLETEDcan later becomeREVERSED. Continue handling those notifications and use the current lookup result rather than assuming statuses only move forward.- Store webhook delivery status separately from fulfillment status. A failed callback does not mean the Steam trade failed.
Keep prices and availability updated without downloading the full catalog again.
Connect
wss://cs2shop.net/v1/merchant/streamConnect from your backend with x-merchant-id and x-api-key. Keep the API key out of browser code and URLs. Send UTF-8 JSON messages.
import WebSocket from "ws";
let lastAppliedSequence = loadPersistedSequence();
const socket = new WebSocket("wss://cs2shop.net/v1/merchant/stream", {
headers: {
"x-merchant-id": process.env.CS2SHOP_MERCHANT_ID,
"x-api-key": process.env.CS2SHOP_API_KEY
}
});
let processing = Promise.resolve();
socket.on("message", (raw) => {
const event = JSON.parse(raw.toString());
processing = processing
.then(() => handleEvent(event))
.catch((error) => {
console.error("Catalog stream processing failed", error);
socket.close(1011, "client_processing_failed");
});
});
async function handleEvent(event) {
if (event.type === "session.ready") {
socket.send(JSON.stringify({
id: 1,
method: "SUBSCRIBE",
params: { topics: ["catalog"], catalogSequence: lastAppliedSequence }
}));
return;
}
if (event.type === "catalog.delta") {
if (event.sequence <= lastAppliedSequence) return; // duplicate
if (event.sequence !== lastAppliedSequence + 1) return resyncAndReconnect();
await applyDeltaAndPersistSequenceAtomically(event);
lastAppliedSequence = event.sequence;
return;
}
if (event.type === "catalog.resync_required") {
await resyncAndReconnect();
}
}The example shows the message flow; implement its storage and resync functions, or use the downloadable client above.
Subscribe and sync
- Fetch the full REST catalog. Replace your local snapshot and save its
sequencetogether. - After
session.ready, sendSUBSCRIBEwithtopics: ["catalog"]and your savedcatalogSequence. - Apply replayed events in order.
catalog.syncmeans replay has finished through that sequence.
Session and subscription examples
// Server immediately after a successful upgrade
{
"schemaVersion": 1,
"type": "session.ready",
"connectionId": "e6533b6e-...",
"heartbeatIntervalMs": 25000,
"serverTime": "2026-08-16T08:20:00.000Z"
}
// Response to SUBSCRIBE; replay events follow this response
{
"id": 1,
"result": {
"subscribed": ["catalog"],
"sequence": 1235,
"resumedFrom": 1234
}
}
// Sent after all retained replay events have been delivered
{
"schemaVersion": 1,
"type": "catalog.sync",
"sequence": 1235,
"generatedAt": "2026-08-16T08:20:10.000Z"
}SUBSCRIBE fields
| Field | Type | Required | Description |
|---|---|---|---|
params.topics | array<"catalog"> | Yes | Must contain exactly one value: "catalog". |
params.catalogSequence | safe integer >= 0 | null | No | Last atomically committed sequence. Omit only immediately after a fresh REST sync. |
result.subscribed | string[] | Response | Active topics; currently always ["catalog"]. |
result.sequence | safe integer | Response | Server sequence captured when subscription initialization started. |
result.resumedFrom | safe integer | null | Response | The requested catalogSequence, or null when it was omitted. |
Apply updates
Ignore events with sequence at or below your saved value. Apply the next event only when its sequence is exactly one higher; any gap requires a fresh snapshot.
| upserts | Complete records for new listings or listings whose metadata changed. Replace by listingId. |
|---|---|
priceUpdates | Compact price-only changes containing listingId and price. |
| removals | Listings to delete, identified by listingId plus compatibility itemId and Steam assetId. |
Save the updated catalog and sequence in one transaction. Process messages in order, including after reconnecting.
catalog.delta example
{
"schemaVersion": 1,
"type": "catalog.delta",
"sequence": 1235,
"generatedAt": "2026-08-16T08:20:10.000Z",
"upserts": [{
"listingId": "a1b2c3d4-0000-0000-0000-000000000000",
"itemId": 4162118646,
"market_hash_name": "AK-47 | Redline (Field-Tested)",
"phase": null,
"paint_index": null,
"paint_seed": null,
"price": 15.42,
"tradableAt": null,
"availableAfter": null,
"steamId": "76561199835130929",
"assetId": "50985016185",
"classId": "7993046236",
"instanceId": "8364272612",
"floatValue": 0.1845123917,
"inspectLink": "steam://..."
}],
"priceUpdates": [{ "listingId": "existing-listing-id", "price": 19.84 }],
"removals": [{ "listingId": "removed-listing-id", "itemId": 123, "assetId": "456" }]
}CatalogItem fields
| Field | Type | Required | Description |
|---|---|---|---|
listingId | UUID string | Yes | Stable listing identity and the key used by POST /v1/merchant/trades. |
itemId | safe integer | Yes | Legacy compatibility identifier; do not use for new checkout integrations. |
market_hash_name | string | Yes | Steam market hash name. |
phase | string | null | Yes | Doppler phase/gem variant when resolved. |
paint_index | integer | null | Yes | Steam paint kit ID, or null when unavailable. |
paint_seed | integer | null | Yes | Steam paint pattern seed; zero is valid, null means unavailable. Paint metadata changes arrive as full upserts, not priceUpdates. |
price | number | Yes | Current USD price. |
tradableAt | ISO-8601 string | null | Yes | Steam unlock timestamp when present. |
availableAfter | ISO-8601 string | null | Yes | Latest hold/trade-lock timestamp, or null when movable now. |
steamId | string | null | Yes | Steam ID of the delivery bot. |
assetId | string | Yes | Steam asset ID. |
classId | string | null | Yes | Steam class ID when available. |
instanceId | string | null | Yes | Steam instance ID when available. |
floatValue | number | null | Yes | CS2 float value when available. |
inspectLink | string | null | Yes | Steam inspect link when available. |
Reconnect and recover
On catalog.resync_required or a sequence gap, stop applying queued deltas, fetch a fresh REST snapshot, and reconnect with its sequence. Reconnect with exponential backoff and jitter.
Replay covers up to 1,000 retained events. Sequence does not reset when you reconnect. Standard WebSocket clients answer the server's ping frames automatically.
Resync event and reasons
{
"schemaVersion": 1,
"type": "catalog.resync_required",
"sequence": 1300,
"generatedAt": "2026-08-16T08:25:00.000Z",
"reason": "history_unavailable"
}Reasons: initial_snapshot, delta_too_large, sequence_ahead, history_unavailable, pending_overflow, sequence_gap. Fetch a fresh snapshot for any of these reasons.
Protocol reference
Client request fields
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | string | null | No | Correlation ID echoed in the response. Strings are limited to 64 characters. |
method | string | Yes | One of SUBSCRIBE, UNSUBSCRIBE, LIST_SUBSCRIPTIONS, or PING. |
params | object | Method-specific | Required for SUBSCRIBE; optional for the other methods. |
Client methods
SUBSCRIBE | params: { topics: ["catalog"], catalogSequence?: number }. Replays at most 1,000 retained deltas, then emits catalog.sync. |
|---|---|
LIST_SUBSCRIPTIONS | Returns the active topic array, for example { "id": 2, "result": ["catalog"] }. |
PING | Returns the request id plus result.serverTime. WebSocket protocol ping/pong is handled separately. |
UNSUBSCRIBE | Stops catalog events without closing the socket and returns result: null; params are optional. |
Server event fields
| Field | Type | Required | Description |
|---|---|---|---|
schemaVersion | integer | Yes | Event contract version; currently 1. |
type | string | Yes | session.ready, catalog.sync, catalog.delta, or catalog.resync_required. |
connectionId | UUID string | session.ready | Unique connection identifier for support and diagnostics. |
heartbeatIntervalMs | integer | session.ready | Server protocol-ping interval; currently 25,000 ms. |
serverTime | ISO-8601 string | session.ready | Server wall-clock time at connection acceptance. |
sequence | safe integer | Catalog events | Monotonic global catalog publication sequence. |
generatedAt | ISO-8601 string | Catalog events | Time the catalog content represented by this sequence was generated. |
Version compatibility
Current schemaVersion: 1. Ignore unknown optional fields. Use the same snapshot recovery flow for new resync reasons.
Errors and limits
Fix credentials or access before retrying 401/403. Retry 503 and capacity-related 429 with backoff.
| Connections | Maximum 5 sockets per merchant |
|---|---|
| Client messages | Maximum 10 messages per second per socket |
| Reconnect attempts | Maximum 30 upgrade attempts per minute per IP |
| Client payload | Maximum 16 KiB per UTF-8 JSON message |
| Heartbeat | Protocol ping every 25 seconds; standard clients answer pong automatically |
Connection HTTP statuses
| 401 | Missing or invalid merchant ID/API key |
|---|---|
| 403 | The authenticated merchant does not have wholesale access |
| 429 | Upgrade-attempt or concurrent-connection limit exceeded |
| 503 | Authentication backend or WebSocket capacity is temporarily unavailable |
Request error codes
{
"id": 1,
"error": {
"code": "invalid_subscription",
"message": "params must contain topics=[catalog]; catalogSequence is optional for replay"
}
}invalid_json | Frame is not valid JSON text. |
|---|---|
invalid_request_id | id is not null, a safe integer, or a string of at most 64 characters. |
unknown_method | method is not one of the four supported client methods. |
subscription_in_progress | Another SUBSCRIBE request is still initializing on this socket. |
invalid_subscription | SUBSCRIBE params are malformed or topics is not exactly ["catalog"]. |
invalid_catalog_sequence | catalogSequence is ahead of the current server sequence; a resync event follows. |
service_unavailable | Snapshot or replay initialization failed temporarily. |
Request errors normally leave the socket open.
Connection close codes
| 1001 | Server shutdown; reconnect with backoff |
|---|---|
| 1003 | Binary client message rejected; send UTF-8 JSON text |
| 1008 | Client message-rate limit or slow-consumer buffer exceeded |
| 1009 | Client request or server event exceeded its size limit |
Handle failed requests without placing duplicate orders.
Error responses
Catalog and purchase endpoints return error and message.
{
"error": "price_changed",
"message": "Price has changed to 15.5"
}Validation and webhook registration errors
{
"code": "INVALID_WEBHOOK_URL",
"message": "Webhook URL must be an absolute URL",
"requestId": "req_..."
}These responses use code and message; details and requestId may also be present.
HTTP 502/504 responses may be text or HTML. Check Content-Type before parsing JSON.
Common errors
400 HTTP_ERROR | Malformed JSON or invalid request fields. message may be a string or an array. |
|---|---|
401 unauthorized | Invalid API key or Merchant ID. |
404 HTTP_ERROR | The requested API route or HTTP method does not exist. |
413 PAYLOAD_TOO_LARGE | The JSON or URL-encoded request body exceeds the 2 MB application limit. |
429 rate_limited | Rate limit exceeded for merchant API. |
503 service_unavailable | Server is still initializing; retry in one minute. |
503 maintenance | The complete merchant API is in maintenance mode. |
503 temporary_maintenance | The requested listing or trade operation is temporarily paused; retry in five minutes. |
500 internal_error / INTERNAL_ERROR | Unexpected server failure. After a trade POST, look up externalRef before deciding whether to retry; an order may already exist. |
Endpoint-specific codes: purchases, webhooks, and WebSocket.
Listing and trade lookup errors
400 invalid_request | Required query, path, or body input is missing or invalid. |
|---|---|
404 item_not_found | The requested wholesale listing was not found. |
404 trade_not_found | No trade exists for the supplied externalRef. |
Retries and duplicate protection
Store one externalRef per purchase before sending the request. After a timeout or 5xx, look up that reference first: the order may already exist. Keep one request in flight for each order.
A brief 404 after a timeout does not prove the original request has stopped. Reconcile before retrying. Never use a new reference to bypass a duplicate response or replace a pending order.
| GET failed or 429 | Retry with backoff and jitter. On 429, wait at least one second. Share the request limit across services using the same merchant ID. |
|---|---|
| POST timed out / connection lost / 5xx | The result is unknown. Look up the same externalRef first. A short-lived 404 is not proof the original request cannot still finish. Serialize attempts for that order and reconcile before any retry. |
400 duplicate_external_ref | Fetch by externalRef and use the existing order. This is duplicate protection, not a replay of the original 200 response. Never generate a new reference merely to retry. |
400 price_changed | Refresh the catalog and require acceptance of the new ceiling. This rejection does not create a trade; a deliberate retry may reuse the same externalRef. |
400 item_unavailable | Remove or refresh the stale listing. Do not loop on the same unavailable item. |
400 invalid_* / insufficient_balance | Correct the input or account balance before another attempt. |
401 / 403 | Resolve credentials, partner_blocked or too_many_active_trades. Do not treat these as transient rate-limit failures. |
503 inventory_check_* | Back off, verify the same order reference, and refresh availability. Avoid concurrent retries for the same order. |
503 maintenance / service_unavailable / temporary_maintenance | Pause requests and respect the response guidance. For an uncertain POST result, reconcile the original order before retrying. |
Contact support
Include the UTC time, method, path, HTTP status, error body, externalRef and x-request-id. Remove API keys and Steam trade URL tokens.