> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cromos.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get told when catalog and price data changes, with the changed records attached.

A webhook endpoint is how you hear about a change **when it happens**, carrying the records that
changed. There is no daily batch and nothing to refetch to find out what moved — the delivery
already has it.

## When deliveries fire

**Catalog changes** — a card, sealed product, expansion or series edited by hand or by the nightly
crawl — are collapsed into one delivery about **ten seconds** after the first change in a burst.
Editing continuously does not push that back: the window is anchored to the first change, not the
most recent one, so a long editing session still delivers within ten seconds and later edits in
that window ride along with it.

**Price changes** fire the moment the sync that produces them finishes, independently for each
currency arm. There is no fixed time of day this is promised for — only that it happens as soon as
the source data lands, not on a schedule you have to guess at or wait out.

Both paths guarantee the delivery is **sent promptly relative to the change**. Neither path
guarantees anything about the order deliveries arrive in relative to each other — see
[Ordering](#ordering-is-not-guaranteed) below.

## The delivery body

Every delivery `POST`s the same four-key body. It is not wrapped in the `{ data, error }`
envelope the REST API answers with — that belongs to `/v1` reads, and a delivery is not one.

```
POST <your endpoint URL>
X-Signature: t=1753862412,v1=3f8a4c9d…
X-Webhook-Id: whd_9c4f2ab7d1e4…
X-Webhook-Event: pokemon.card_prices.updated
X-Webhook-Attempt: 1
X-Webhook-Sequence: 4412
X-Webhook-Endpoint: whe_2b7e9c41…
X-Webhook-Test: false
Content-Type: application/json
User-Agent: …

{
  "id": "evt_7b3e9d1a2c4f4b8e9a6d3c1f5b2e8a4d",
  "name": "pokemon.card_prices.updated",
  "created_at": "2026-07-31T04:02:11.184Z",
  "data": {
    "chunk": { "index": 12, "of": 27 },
    "currency": "USD",
    "cards": [
      {
        "card_id": "sv08-125",
        "prices": [
          { "variant": "holofoil", "condition": "NM", "currency": "USD", "market": 8999, "low": 7450, "high": 14999 }
        ]
      }
    ]
  }
}
```

| Field        | Meaning                                                                                      |
| ------------ | -------------------------------------------------------------------------------------------- |
| `id`         | The **event** id. The same value on every endpoint that receives this event.                 |
| `name`       | One of the names in [Events](/webhooks/events).                                              |
| `created_at` | When the event was recorded, RFC 3339 with **milliseconds**, always UTC.                     |
| `data`       | The changed records, under a key that depends on the event — see [Events](/webhooks/events). |

`created_at` is inside the bytes we sign, at millisecond precision — reconstructing it with
whole-second precision produces a body that will never verify. Don't reconstruct the body at
all; see [Verifying deliveries](/webhooks/security).

`chunk` is present only when a run produced more records than fit in one delivery (500 per
delivery); a group small enough for one delivery carries no `chunk` key at all. `currency` is
present only on the two price events.

**`sequence` is never a body field.** It rides only as the `X-Webhook-Sequence` header, because one
underlying event is shared by every endpoint subscribed to it while the sequence is counted
per endpoint — it cannot live in a value every endpoint reads the same copy of. See
[Sequence and replay](#sequence-and-replay).

## Headers

| Header               | Example                       | What it is                                                                          |
| -------------------- | ----------------------------- | ----------------------------------------------------------------------------------- |
| `X-Signature`        | `t=1753862412,v1=3f8a4c9d…`   | Proof the payload came from us. See [Verifying deliveries](/webhooks/security).     |
| `X-Webhook-Id`       | `whd_9c4f2ab7d1e4…`           | The **delivery** id. Unique per endpoint, stable across retries.                    |
| `X-Webhook-Event`    | `pokemon.card_prices.updated` | Same value as `name` in the body, so you can route before parsing.                  |
| `X-Webhook-Attempt`  | `1`                           | 1 on the first try, incrementing on each retry.                                     |
| `X-Webhook-Sequence` | `4412`                        | This endpoint's monotonic, gapless delivery counter. See below.                     |
| `X-Webhook-Endpoint` | `whe_2b7e9c41…`               | The id of the endpoint this delivery was sent to.                                   |
| `X-Webhook-Test`     | `false`                       | `true` only for a delivery sent from **Send test event**.                           |
| `Content-Type`       | `application/json`            |                                                                                     |
| `User-Agent`         | —                             | Constant across every delivery. Read it off the request if you want to match on it. |

### Deduplicate on `X-Webhook-Id`

Delivery is at-least-once. A response that times out on our side after your server already
committed the change looks exactly like a failure, and we will try again.

`X-Webhook-Id` is the value to deduplicate on. It stays the same across every retry of one
delivery and differs between two endpoints receiving the same event — which is why it, and not
the event `id` in the body, is the right key. Record it, and treat a repeat as already handled.

## Responding

**Any 2xx is success.** Anything else — including a 3xx — fails the attempt and schedules a
retry.

Return quickly. We wait **10 seconds** and no longer; a slow 200 is a failed attempt. Acknowledge
first, then apply the records on your own time.

Redirects are **not followed**. A 302 from your endpoint fails the attempt rather than forwarding
a signed payload to wherever it points.

## Retries

Failed deliveries are retried on an exponential schedule: **11 retries over roughly 15.8 hours**,
enough to ride out a bad deploy without losing the change.

| Retry | Wait before it | Elapsed |
| ----- | -------------- | ------- |
| 1     | 2.7s           | 2.7s    |
| 2     | 7.4s           | 10s     |
| 3     | 20s            | 30s     |
| 4     | 55s            | 1.4m    |
| 5     | 2.5m           | 3.9m    |
| 6     | 6.7m           | 11m     |
| 7     | 18m            | 29m     |
| 8     | 50m            | 1.3h    |
| 9     | 2.3h           | 3.6h    |
| 10    | 6.1h           | 9.7h    |
| 11    | 6.1h           | 15.8h   |

After the eleventh retry the delivery is marked failed and we stop. Your endpoint is **never
disabled automatically** — a working integration switched off by a bad afternoon is a worse
failure than a dead endpoint receiving traffic. Every attempt, with its status code and the first
512 bytes of your response, is in the delivery log under **Settings → Webhooks**. A failed
delivery can be resent from there — not one that already succeeded, since a resend mints a new
`X-Webhook-Id` and a correctly deduplicating receiver would process it again.

## Ordering is not guaranteed

A delivery that keeps failing retries for up to 15.8 hours. That means `pokemon.cards.updated`
for a card that was only just created can arrive **after** a `pokemon.card_prices.updated` that
already references it, even though we produced the two events in the right order — the price
event's delivery simply succeeded first.

Within one dispatch, chunks go out in `chunk.index` order and `X-Webhook-Sequence` is allocated
in that same order, so in-order arrival is the overwhelmingly common case. It is not a guarantee
you can build correctness on.

Design around it: treat a price record naming a card id you don't recognize yet as
**buffer it, or fetch the card directly — never as an error**. Apply every record idempotently,
keyed on the record's own id (`card_id`, `product_id`, and so on), not on delivery order. A
record for a card that arrives twice, or a price that arrives before its card, both need to leave
your mirror in the same correct state.

## Sequence and replay

Every delivery to a given endpoint carries a monotonic, gapless `X-Webhook-Sequence`, counted **per
endpoint** — not globally, and not per event. Track the highest value you have processed for each
endpoint; a hole in that sequence means a delivery you never received, something 15.8 hours of
exhausted retries cannot rule out on its own.

Worked example: your log shows sequence `127`, then jumps straight to `129`. Fetch the missing
delivery by endpoint and sequence:

```
GET /v1/webhooks/endpoints/whe_4a8f1c9e/deliveries/128
Authorization: Bearer cs_sk_…
```

which returns the exact body that delivery carried, inside the `{ data, error }` envelope every
`/v1` read answers with:

```json theme={null}
{
  "data": {
    "id": "evt_3f8b6d2a91c74e5f8a2d6b3c9e1f7a4d",
    "name": "pokemon.card_prices.updated",
    "created_at": "2026-08-02T16:47:31.502Z",
    "data": {
      "currency": "USD",
      "cards": [
        {
          "card_id": "base1-4",
          "prices": [
            {
              "variant": "holo",
              "condition": "NM",
              "currency": "USD",
              "market": 80042,
              "low": 42650,
              "high": 149965
            }
          ]
        }
      ]
    }
  },
  "error": null
}
```

`data.data` is not a typo: the outer key is that envelope, the inner one is the event's own
records. Unwrap exactly one level and you have the delivery body above, byte for byte — a
delivery we `POST` to you is never enveloped, so that one unwrap is all a replayed delivery
needs before it goes through the same handler.

The delivery's sequence comes back on the response's own `X-Webhook-Sequence` header — never in
the body, so a replayed delivery reaches your handler in exactly the shape a live one did. Your own
endpoint id travels on every delivery as `X-Webhook-Endpoint`, so you always have it on hand
without hardcoding it. See [Get a delivery](/webhooks/delivery) for the endpoint's full reference.

**Bounded by 30 days.** Replay reads from the same storage a delivery is retried from, and that
storage is pruned after 30 days. A gap older than that cannot be recovered this way — past that
window, a full resync (below) is the only option.

## Cold start

Replay repairs a gap in a mirror that is already running; it cannot seed one that is empty, since
an empty mirror has no sequence to start from. For that, pull the whole catalog once with prices
attached:

```
GET /v1/cards?include=prices
```

adds each card's current prices onto the normal, paginated card listing, so a full sync is one
pass over the whole catalog instead of one request per card. Add `expansion_id` to do it an
expansion at a time.

The cold-start procedure: page that call to the end, note the sequence of the next delivery you
receive after the walk finishes, and stay incremental from there — processing deliveries as they
arrive and using replay to fill any gap you notice.

## Setting one up

Endpoints are managed in the console under **Settings → Webhooks**. Add one, choose the events you
want, and copy the signing secret — it is shown once when you create it, and revealable afterwards
from the endpoint's page (see [Verifying deliveries](/webhooks/security)).

**Send test event** on the endpoint's page sends a real, signed delivery for whichever event you
pick, built from real catalog rows, through the same path a live delivery takes — so you can
confirm your verification and your handler both work without waiting for a real change. It carries
`"test": true` in the body and `X-Webhook-Test: true` on the request, and it is tried once rather
than retried.

There is no API for managing endpoints. The console is the only way, by design.
