API

Webhooks

Get trade status changes pushed to your server instead of polling for them.

Open raw markdownFor pasting into AI assistants

A trade changes status several times between being created and finishing: the offer is sent, you accept it, Steam puts it in escrow, it completes. GET /public/v1/trades will tell you, but only if you keep asking, and that route allows one request per second.

A webhook removes the asking. Register a URL and we POST every status change to it as it happens.

Set one up

Go to Settings → Developer:

  1. Generate an API key if you don't have one. The key comes with a signing secret (whsec_...) shown on the same page.
  2. Put your endpoint in Webhook URL and save.

The URL must be https:// and must resolve to a public address. Anything else is rejected with INVALID_WEBHOOK_URL.

Clearing the field turns deliveries off. There is nothing else to switch on: once a URL is saved, trade events start arriving.

The signing secret is derived from your API key. Rerolling or revoking the key rotates the secret too, so update your verification code at the same time you update the key.

Keys issued before webhooks existed have no secret. If the Developer page shows none next to your webhook URL, reroll your key once — nothing is delivered until there is a secret to sign with.

What a delivery looks like

A POST with a JSON body:

{
  "id": "0f4dc9a1-1a2b-4c3d-9e8f-2b7c6d5e4f3a",
  "event": "trade.updated",
  "ts": "2026-08-13T11:04:21.000Z",
  "data": {
    "id": 91422,
    "type": "WITHDRAW",
    "status": "Accepted",
    "deposit_id": null,
    "withdraw_id": 5512,
    "steam_offer_id": "7654321098",
    "value": 42500,
    "error": null,
    "created_at": "2026-08-13T11:01:02.000Z",
    "updated_at": "2026-08-13T11:04:21.000Z",
    "items": [
      {
        "app_id": 730,
        "market_hash_name": "AK-47 | Redline (Field-Tested)",
        "steam_asset_id": "38472910384",
        "amount": 1,
        "value": 42500
      }
    ]
  }
}

data is exactly a row from GET /public/v1/trades — same fields, same snake_case, same integer cents. Code that already reads that route can read this without changing anything.

And the headers:

HeaderMeaning
X-CSDeals-EventEvent name, currently always trade.updated
X-CSDeals-DeliveryUnique id for this delivery, stable across retries
X-CSDeals-TimestampUnix seconds the signature was made at
X-CSDeals-Signaturesha256=<hex>, see below
X-CSDeals-Attempt1 on the first try, higher on retries

Verify the signature

Anyone can POST JSON at your endpoint. The signature is what tells you a delivery came from us.

We sign the string <timestamp>.<raw body> with HMAC-SHA256 and your signing secret. Verify against the raw request body, before any JSON parsing — a re-serialized body will not match.

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

function verify(rawBody, headers, secret) {
    const timestamp = Number(headers["x-csdeals-timestamp"]);
    const signature = headers["x-csdeals-signature"];

    if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
        return false;
    }

    const expected = `sha256=${createHmac("sha256", secret)
        .update(`${timestamp}.${rawBody}`)
        .digest("hex")}`;

    return (
        expected.length === signature.length &&
        timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
    );
}
import hashlib, hmac, time

TOLERANCE_SECONDS = 300

def verify(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = int(headers["X-CSDeals-Timestamp"])
    signature = headers["X-CSDeals-Signature"]

    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False

    expected = "sha256=" + hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Two details that matter:

  • Compare in constant time. hmac.compare_digest, timingSafeEqual, or your language's equivalent. A plain == leaks the expected signature one byte at a time.
  • Reject old timestamps. The timestamp is inside the signed string, so it cannot be edited without breaking the signature. Refusing anything older than a few minutes is what stops a captured delivery from being replayed at you later. Five minutes is a sensible window.

Responding

Return any 2xx within 10 seconds. Anything else — a non-2xx, a timeout, a connection failure, a redirect — counts as a failure.

Do the minimum before responding: verify, queue, return 200. Processing the trade inside the request is how endpoints end up timing out under load.

Redirects are not followed. The signed body is only ever sent to the URL you registered, so if your endpoint moves, register the new URL.

Retries, ordering and duplicates

Failed deliveries are retried up to 6 times with exponential backoff, starting at 5 seconds. After that the delivery is dropped; GET /public/v1/trades remains the source of truth and is worth a periodic reconciliation pass regardless.

Retries reuse the same X-CSDeals-Delivery id, so treat it as an idempotency key — a delivery your endpoint already handled can be acknowledged and ignored.

Deliveries are not ordered. A retry can land after a newer event for the same trade. Every payload is the full current state of the trade, so the rule is the same one the WebSocket feed uses: apply an event only if its updated_at is newer than what you have for that trade id.

If you clear or change your webhook URL, queued retries follow the new setting: deliveries in flight go to the new URL, and clearing the URL drops them.

Endpoints that stop answering

A delivery that exhausts all 6 attempts counts as one failure. After 20 of those in a row your webhook URL is removed and you get a notification on the site. Any successful delivery resets the count to zero, so an endpoint that recovers on its own is never removed. Set the URL again once your endpoint is reachable.

Source addresses

Deliveries leave from a fixed set of addresses, so you can allowlist them:

45.38.124.18
92.113.180.106
167.17.48.70

Which one a delivery comes from is stable for your account in normal operation and may change on a retry. Allowlist all three.

An allowlist is a useful first filter, but it is not authentication — the signature is. Verify it even on traffic from these addresses.

Events

EventFires whenPayload
trade.updatedA Steam trade of yours changes statusA /public/v1/trades row

trade.updated covers both directions: SELL trades where we send you an offer for items you're listing, and WITHDRAW trades sending bought items to your Steam inventory. The status values are the same set the /trades route returns — Active, Accepted, InEscrow, Declined, Expired, Canceled, Reversed, and the rest.

The event set will grow. Ignore event names you don't recognise rather than erroring on them.

Webhooks and the WebSocket feed

They cover different things and do not overlap:

  • Webhooks push your account's trade activity to your server. Nothing to keep connected, and they survive your process restarting.
  • The WebSocket feed streams marketplace-wide listing activity for building a local book. It is a live connection with sequence numbers, and it carries no account events.

Most bots want both.