Webhooks
Get trade status changes pushed to your server instead of polling for them.
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:
- Generate an API key if you don't have one. The key comes with a signing
secret (
whsec_...) shown on the same page. - 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:
| Header | Meaning |
|---|---|
X-CSDeals-Event | Event name, currently always trade.updated |
X-CSDeals-Delivery | Unique id for this delivery, stable across retries |
X-CSDeals-Timestamp | Unix seconds the signature was made at |
X-CSDeals-Signature | sha256=<hex>, see below |
X-CSDeals-Attempt | 1 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.70Which 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
| Event | Fires when | Payload |
|---|---|---|
trade.updated | A Steam trade of yours changes status | A /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.