# Webhooks

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`](/docs/reference/trading/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 [#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.

<Callout type="warn">
  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.
</Callout>

## What a delivery looks like [#what-a-delivery-looks-like]

A `POST` with a JSON body:

```json
{
  "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`](/docs/reference/trading/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 [#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.

```js
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))
    );
}
```

```python
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 [#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 [#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`](/docs/reference/trading/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 [#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 [#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 [#events]

| Event           | Fires when                            | Payload                                                     |
| --------------- | ------------------------------------- | ----------------------------------------------------------- |
| `trade.updated` | A Steam trade of yours changes status | A [`/public/v1/trades`](/docs/reference/trading/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 [#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](/docs/reference/public-api/overview)** 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.
