> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cromos.dev/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**. A catalog change, a card,
sealed product, expansion or series, carries the changed records directly in the delivery:
nothing to refetch. A price change carries a pointer to the current snapshot file instead, since
card and sealed product prices rewrite most of their rows every day. See
[Price snapshots](/webhooks/snapshots) for that file.

Webhook payloads are complete for the records they name. A consumer does not need to call back
into the REST API to process an event.

## When deliveries fire

**Catalog changes**, meaning a card, sealed product, expansion or series changed, are collapsed
into one delivery about **ten seconds** after the first change in a burst. Continuous editing
does not push that back: the window is anchored to the first change rather than 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 when a new snapshot file publishes: at most a few deliveries a day, never
one per changed price. See [Price snapshots](/webhooks/snapshots) for the file itself.

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

Receiving a delivery costs nothing against your rate limit: it is a request to your endpoint,
not one you make. Fetching the file a price snapshot points to costs nothing either, since the
file lives outside `/v1`, on `assets.cromos.so`. Only a call you make into `/v1` yourself counts
against your [rate limit](/rate-limits).

## 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, which belongs to `/v1` reads.

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

{
  "id": "evt_7b3e9d1a2c4f4b8e9a6d3c1f5b2e8a4d",
  "name": "pokemon.card_prices.snapshot",
  "created_at": "2026-08-05T03:30:11.184Z",
  "data": {
    "currency": "USD",
    "snapshot": [
      {
        "snapshot_date": "2026-08-05",
        "rows": 139577,
        "subjects": 19295,
        "url": "https://assets.cromos.so/prices/cards/2026-08-05.c3f8a1e9b2d4f7a1c8e3b6d9f2a4c7e1b8d3f6a9c2e5b8d1f4a7c3e6b9d2f5a8.ndjson.gz",
        "sha256": "c3f8a1e9b2d4f7a1c8e3b6d9f2a4c7e1b8d3f6a9c2e5b8d1f4a7c3e6b9d2f5a8",
        "bytes": 1583488
      }
    ]
  }
}
```

| 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 signed bytes at millisecond precision, so reconstructing it with
whole-second precision produces a body that will never verify. Do not 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 event is shared by every endpoint subscribed to it while the sequence is counted per
endpoint. See [Sequence and replay](#sequence-and-replay).

## Headers

| Header               | Example                        | What it is                                                                      |
| -------------------- | ------------------------------ | ------------------------------------------------------------------------------- |
| `X-Signature`        | `t=1753862412,v1=3f8a4c9d`     | Proof the payload is genuine. 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.snapshot` | 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`         | A constant                     | The same on 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 after your server already committed the
change is indistinguishable from a failure, and the delivery is retried.

`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. The wait is **10 seconds** and no longer, so 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 no further attempt is made. 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. One that already succeeded cannot, 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 newly created card can arrive **after** you have already fetched a card price snapshot
whose file carries a row for it, even though the underlying data was produced in the right
order.

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 snapshot row, or any catalog record, naming a card id you do not
recognise yet as **buffer it, or fetch the card directly, never as an error**. Apply every
record idempotently, keyed on its own id (`card_id`, `product_id`, `variant_id`, and so on)
rather than on delivery order. A card record that arrives twice, and a price row 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, which 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_yA7fQ2mR9vK4xN1pL6wT8zB3cJ5hD0gS2eV4nM7uX9k
```

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

```json theme={null}
{
  "data": {
    "id": "evt_9a3f6c1e8d2b4a7f9c1e6d3a8f2b5c7d",
    "name": "pokemon.card_variants.deleted",
    "created_at": "2026-07-31T03:58:41.006Z",
    "data": {
      "variants": [
        { "card_id": "bog-4", "variant_id": 48212 },
        { "card_id": "sv08-125", "variant_id": 48214 }
      ]
    }
  },
  "error": null
}
```

The outer `data` is the envelope and 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 sent to your endpoint
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.** A gap older than that cannot be recovered this way, and a full resync
(below) is the only option.

## Cold start

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

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

That attaches every printing, with its current prices, to each row of the normal paginated card
listing, so a full sync is one pass over the 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` inside `data` 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.
