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

# Idempotency & retries

> Handle duplicate deliveries and out-of-order events correctly.

Ionic delivers events **at least once** and does **not** guarantee order. A correct handler is therefore idempotent (safe to run twice on the same event) and never infers state from the order events arrive in. This page shows how.

## Deduplicate on the event `id`

The envelope `id` (the `evt_` value) identifies the event. It is stable across every redelivery of that event, so it's your deduplication key.

```ts theme={null}
async function handleEvent(event: WebhookEvent) {
  // INSERT ... ON CONFLICT DO NOTHING — returns false if we've seen this id.
  const isNew = await markProcessed(event.id); // event.id = "evt_..."
  if (!isNew) return; // already handled; acknowledge and stop.

  await doWork(event);
}
```

Record `event.id` in the same transaction as the work it triggers. If the work commits but recording the id doesn't (or vice versa), a redelivery either double-processes or is wrongly skipped. One transaction keeps "did the work" and "saw the event" in lockstep.

<Note>
  Two different ids travel with a delivery — don't confuse them. The **envelope `id`** (`evt_…`) identifies the *event* and is what you dedupe on. The **`webhook-id` header** (`msg_…`) identifies the *delivery* and is used for [signature verification](/webhooks/signatures). Ionic also sets the event `id` as the delivery's idempotency key, so a republished event collapses to one delivery — but your handler must still dedupe, because retries are by design.
</Note>

## Don't trust arrival order

Because order isn't guaranteed, `payment_intent.succeeded` can arrive before the `payment_intent.created` that preceded it. Never drive a state machine off "which event came first." Instead:

* **Read the snapshot.** `data.object` carries the resource's state at fire time, including its `status`. Act on that, not on the event name's position in a sequence.
* **Compare, don't assume.** If you keep your own copy of the resource, use the snapshot's `updated_at` (or `status`) to ignore an event that's older than what you've already recorded.
* **Re-fetch when it matters.** For a decision that must reflect the *current* truth (e.g. "is this subscription active right now"), call `GET /v1/<resource>/{id}` rather than relying on any single event.

## Retries

If your endpoint doesn't return a `2xx` — an error status, a timeout, or an unreachable host — Ionic retries the delivery on an exponential backoff over an extended window. Each retry carries the **same** `webhook-id` and the same envelope `id`, so your dedup logic makes a retry after a partial success a no-op.

<Warning>
  A `2xx` is an acknowledgment: Ionic stops retrying once it sees one. Return `2xx` only after you've durably stored the event. If you return `2xx` and then crash before doing the work, the event is gone. The safe pattern is **store-then-ack**: persist the raw event, return `2xx`, process asynchronously.
</Warning>

## Endpoint disabling and recovery

An endpoint that fails for a sustained period is automatically disabled to stop wasting retries on a dead URL. You can re-enable it once it's healthy, and recover the events it missed:

* **Re-enable** with [`POST /v1/webhook_endpoints/{id}`](/webhooks/endpoints) (`{"disabled": false}`).
* **Fill the gap** by listing resources created during the outage and comparing
  them with the event IDs your application processed.

## A correct handler, end to end

```ts theme={null}
app.post("/webhooks/ionic", express.raw({ type: "application/json" }), async (req, res) => {
  // 1. Verify against the RAW body (see Verify signatures).
  try {
    verifyWebhook(process.env.IONIC_WEBHOOK_SECRET, req.headers, req.body.toString());
  } catch {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString());

  // 2. Store-then-ack: insert the raw event keyed by its id, in one write.
  //    The unique key on event.id is the dedup — storeEvent returns false
  //    for an id it already holds. If the insert throws, the 5xx below
  //    lets Ionic retry the delivery; nothing has been acknowledged yet.
  let isNew;
  try {
    isNew = await storeEvent(event);
  } catch {
    return res.sendStatus(500);
  }

  // 3. Acknowledge. The event is durably stored; everything after the 200
  //    happens out of band, and arrival order is not significant.
  res.sendStatus(200);
  if (isNew) processStoredEvent(event.id);
});
```
