# Retries and delivery

URL: /docs/webhooks/retries

How CRM webhooks are delivered at least once, retried with backoff, switched off when failing, and sent again from the delivery log.

CRM webhooks are queued the moment a change is saved and delivered in the background. A failed delivery is retried for several hours, and every attempt is written to a delivery log you can read in the admin. This page explains what your receiver can expect.

## At least once [#at-least-once]

Delivery is **at least once**. You may receive the same event more than once, for example when your `2xx` answer was lost on the way back. Every retry of an event carries the same `X-ElaraOne-Delivery` header (the body's `id`), so use it to skip repeats:

```ts title="Skip events you have already handled"
const deliveryId = req.headers.get('X-ElaraOne-Delivery')!

// A unique constraint on delivery_id makes this safe under concurrency.
const firstTime = await db.webhookDeliveries.insertIfAbsent({ delivery_id: deliveryId })
if (!firstTime) return Response.json({ received: true }) // already done: still answer 2xx
```

Events about one record usually arrive in order, but a retry can overtake a newer event. Compare `created_at`, or read the record's current state through the merchant CRM API.

## What counts as success [#what-counts-as-success]

| Your answer | Result |
|---|---|
| Any `2xx` | Delivered. |
| `410 Gone` | Delivery stops at once for this event. No more retries. |
| Any other status, including `3xx` | The attempt failed and will be retried. Redirects are never followed. |
| No answer within 10 seconds, or a connection error | The attempt failed and will be retried. |

Answer `2xx` as soon as you have verified and stored the event, and do the real work afterwards. Do not answer `410` for a bad signature: use a `4xx` such as `400`, so a configuration mistake on your side is retried once fixed.

## The retry schedule [#the-retry-schedule]

A failed delivery is retried with exponential backoff and a little jitter: about 30 seconds, then 1 minute, 2 minutes, 4 minutes and so on, each wait capped at 6 hours. If your endpoint answers with a longer `Retry-After`, that wins.

After **8** failed attempts the delivery gives up and is marked dead in the log. Each attempt is signed afresh, so the `t` in `X-ElaraOne-Signature` is always current.

## Automatic switch-off [#automatic-switch-off]

Each endpoint keeps a count of failed attempts in a row, reset by any success. When an endpoint has failed **20** times in a row **and** has not succeeded for **24 hours** (or never has), it is switched off and its waiting deliveries give up.

The staff member who added the endpoint (or else the store's admins) gets a notification in the admin and an email. Neither names any contact. Once your receiver is fixed:

1. Switch the endpoint back on in **Settings → CRM → Webhooks**. This clears its failure count.
2. Send the missed deliveries again from the delivery log.

While an endpoint is switched off, changes send nothing to it.

## The delivery log [#the-delivery-log]

**Settings → CRM → Webhooks → Delivery log** lists every delivery, newest first, with its event, record, state, attempts, last answer, error and the exact payload sent. You can filter by endpoint, state and event.

| State | Meaning |
|---|---|
| `pending` | Queued, not yet attempted. |
| `delivering` | An attempt is in progress. |
| `delivered` | Your endpoint answered `2xx`. |
| `failed` | The last attempt failed and a retry is due. |
| `dead` | It gave up: 8 failed attempts, a `410`, or the endpoint was switched off. |

**Send again** puts a dead delivery back in the queue with its attempt count reset. It keeps the same event id, so your deduplication still works.

Delivered entries are kept for 30 days and dead ones for 90 days. Entries hold ids and field names only.

## Test events [#test-events]

**Send a test** on an endpoint sends one signed `webhook.test` event straight away and writes it to the log. It does not count towards the endpoint's failure streak, so you can test a receiver that is not ready yet without risking a switch-off.

```json title="webhook.test"
{
  "id": "01jb3m2c5d7f9h1k3m5p7r9t1v",
  "type": "webhook.test",
  "created_at": "2026-10-24T10:02:00+00:00",
  "store": "hearth-and-oak",
  "api_version": "2026-10-01",
  "data": {
    "object": "endpoint",
    "id": 4,
    "message": "A test from Settings → CRM → Webhooks."
  }
}
```

Handle `webhook.test` by verifying the signature and answering `2xx`. There is nothing else to do with it.

## Staging stores [#staging-stores]

A staging store sends webhooks too, signed with its own endpoint secrets and marked with `X-ElaraOne-Environment: staging`. A receiver that serves your live store can ignore events with that header.
