---
updatedAt: 2026-09-30T15:54:20.000Z
---

Fetch the complete documentation index at: https://docs.avvio.xyz/llms.txt. Use this file to discover all available pages before exploring further. Append .md to any documentation page URL to get its markdown version.

# Webhooks

Avvio sends your server a signed event each time a payout changes state. Each
delivery carries the same `id` as its row in
`GET /payments/organizations/{orgId}/events`, the feed you reconcile from. A
delivery can go missing; the feed can't.
[Receive and verify webhooks](/recipes/receive-and-verify-webhooks/) builds a receiver step by
step, and [Webhook events](/coverage/webhook-events/) shows every event type
with a full body.

## Signing

Deliveries are signed with [Standard Webhooks](https://www.standardwebhooks.com/),
the scheme Svix popularized, so an off-the-shelf library can verify them.

| Header                  | Meaning                                                                                              |
| ----------------------- | ---------------------------------------------------------------------------------------------------- |
| `svix-id`               | The event id. Stable across retries and equal to the body's `id`. Dedupe on it                        |
| `svix-timestamp`        | Unix seconds. Reject a delivery more than five minutes from your clock                               |
| `svix-signature`        | Space-separated `v1,<base64>` values. A rotation sends two, so accept a match on any                  |
| `Avvio-Webhook-Version` | The shape of `data`, equal to the body's `apiVersion` (`1` today). Branch on it before you parse      |

```
signedContent = `${svix-id}.${svix-timestamp}.${rawBody}`
signature     = base64(HMAC_SHA256(base64decode(secret minus "whsec_"), signedContent))
```

The secret has the `whsec_` prefix and is shown once at creation and once at
each rotation. Compare signatures in constant time.

> [!WARNING]
> Verify the bytes you received, before parsing JSON. Re-serialized JSON
> changes the body, so a valid delivery fails the check.

## The body

```jsonc
{
  "id": "cmf3k2xb20002q8b7c1s9m4rw",  // opaque; equals svix-id and the feed row's id
  "sequence": "48213",            // the feed cursor for this event
  "type": "payout.completed",
  "createdAt": "2026-09-03T10:00:00.000Z",
  "apiVersion": 1,                // equals Avvio-Webhook-Version
  "livemode": true,               // false from a sandbox organization
  "data": { … }                   // the event payload, verbatim
}
```

`data` is built once, when the event is recorded. The webhook and the feed
read that stored object, so both give the same numbers. For `payout.*` it is
the flat payout (schema `WebhookPayout`); checkout events use
`WebhookCheckoutPayment`. Money is decimal strings beside a currency field,
except `fee`, which is a `{ amount, currency }` object.

## Events

Payout, batch, approval and endpoint events go to every endpoint by default.
Checkout-payment events are opt-in: list them in `events` when you register,
or filter the feed with `type=`. They are opt-in because a payout receiver that
answers `4xx` to an unknown type climbs toward auto-disable. Answer `2xx` to
any type you don't handle, and never throw on a new one: new types are a
[backwards-compatible change](/environments/#versioning).

A newly registered endpoint can also receive events recorded up to about 10
minutes before it was created. Dedupe on `id` and ignore a status older than
the one you hold, and these are harmless.

Every state change produces one event, written in the same database
transaction as the change. A dispatcher runs every 15 seconds, so a delivery
lands within about 15 seconds and close transitions arrive as one burst.

One event per transition is not one event per state. A payout can skip
`payout.processing`, in the sandbox too (scenario `0002`), so handle any
forward edge from the state you hold ([State transitions](/status/#state-transitions)).
Batches have no in-progress event; each created line emits its own
`payout.*` events. `payout_approval.executed` carries the `payoutId` that
joins a `202` to its payout.

Two things are not announced: a bank deposit held short of the total (it stays
`pending` until accepted in the dashboard), and a dispute before it resolves.
No payload names the payment network or uses its status words, so a re-route
doesn't change your ledger.

## Delivery and retries

Any `2xx` is a success. Anything else, including a timeout, is retried, each
step jittered by ±20%:

| Retry | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| After the previous attempt | 1 min | 5 min | 15 min | 1 h | 3 h | 6 h | 12 h | 24 h | 24 h |

Nine retries cover about 70 hours. After that the delivery is dead: it stays in
the delivery log with its `lastError` and is never retried automatically or
deleted. The event stays in the feed.

> [!WARNING]
> Return `2xx` first and do the work afterwards. A receiver that processes
> before responding times out under load, and the retry becomes a duplicate.

Deliveries are at least once and offered in `sequence` order per endpoint, but
retries can reorder them. Dedupe on `svix-id`, and never move a payout
backwards: a late `payout.processing` can land after `payout.completed`. The
one forward move from `completed` is `payout.returned`, whose body carries
`status: failed` and `failureCode: returned_by_bank`
([the bank return](/status/#the-bank-return-completed--failed)).

### Auto-disable

After three dead deliveries and five days with no accepted delivery, the
endpoint is disabled with `disabledReason: auto_disabled_after_failures`. Your
organization's owners and admins are emailed and `webhook_endpoint.disabled` goes to your
other endpoints. Events keep being recorded but nothing is sent there until
someone re-enables it. The endpoint list shows `consecutiveFailures`,
`lastSuccessAt` and `lastFailureAt`, so you can see it coming.

## Managing endpoints

Paths below start at `/organizations/{orgId}/webhook-endpoints`.

| Endpoint | What it does |
| --- | --- |
| `GET …` | List, with health |
| `POST …` | Register; returns the signing secret once |
| `DELETE …/{id}` | Remove |
| `POST …/{id}/enabled` | Pause or resume without losing the secret; resuming clears health |
| `POST …/{id}/rotate-secret` | New secret; the old one keeps signing for 24 hours |
| `GET …/{id}/deliveries` | The 50 newest attempts, a bare array sorted by `createdAt` then `id`. No payload |
| `POST …/{id}/deliveries/{deliveryId}/replay` | Re-fire one, with a fresh retry ladder |
| `POST …/{id}/deliveries/replay` | Re-fire deliveries in a `from`/`to` window, up to 1,000 |

Listing endpoints and reading deliveries accept an API key. Everything else is
dashboard-only, for `owner`, `admin` and `operator` members, so a leaked key
can see where deliveries go but can't repoint them. A test key can register an
endpoint and read its deliveries under
`/payments/organizations/{orgId}/sandbox/webhook-endpoints`, to rehearse
verification without the dashboard.

Rotation returns the new `whsec_` once, with `previousValidUntil`. Until then
every delivery carries two signatures, so deploy the new secret at your own
pace and accept either.

A replay keeps the event `id`, so your dedupe still recognizes it. Bulk replay
takes ISO-8601 `from` and `to`, and `status`: `failed` (the default, dead
deliveries only) or `all`. It runs oldest first and answers `{ requeued }`.
Use it to catch up after re-enabling a disabled endpoint.
