---
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.

# Receive and verify webhooks

## Register a sandbox endpoint

You need the three variables from the [Quickstart](/quickstart/) and a public HTTPS tunnel (for example cloudflared or ngrok) in front of a local server. [Webhooks](/webhooks/) is the reference for signing, retries and endpoint management.

A test key can register a sandbox endpoint; live endpoints are created in the dashboard. Leave out `events` to receive every payout event type.

**The signing `secret` is returned once and never shown again.** Without it you cannot verify a single delivery, so store it before you close the response.

```bash title="POST /payments/organizations/{orgId}/sandbox/webhook-endpoints" {5-7}
curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/sandbox/webhook-endpoints" \
  -H "x-api-key: $AVVIO_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "url": "https://example.ngrok-free.app/hooks/avvio",
    "events": ["payout.pending", "payout.processing", "payout.completed",
               "payout.failed", "payout.returned", "payout.canceled"]
  }'
```

```js title="POST /payments/organizations/{orgId}/sandbox/webhook-endpoints" {5-7,9}
import { PayoutsClient } from '@avvio/payments';

const avvio = new PayoutsClient(); // reads AVVIO_API_KEY, AVVIO_ORG_ID and AVVIO_BASE_URL
const endpoint = await avvio.createWebhookEndpoint({
  url: 'https://example.ngrok-free.app/hooks/avvio',
  events: ['payout.pending', 'payout.processing', 'payout.completed',
           'payout.failed', 'payout.returned', 'payout.canceled'],
});
console.log(endpoint.secret); // store it now: it is never shown again
```

```python title="POST /payments/organizations/{orgId}/sandbox/webhook-endpoints" {7-9,14}
import os, requests

res = requests.post(
    f"{os.environ['AVVIO_BASE_URL']}/payments/organizations/{os.environ['AVVIO_ORG_ID']}/sandbox/webhook-endpoints",
    headers={"x-api-key": os.environ["AVVIO_API_KEY"]},
    json={
        "url": "https://example.ngrok-free.app/hooks/avvio",
        "events": ["payout.pending", "payout.processing", "payout.completed",
                   "payout.failed", "payout.returned", "payout.canceled"],
    },
)
res.raise_for_status()
endpoint_id = res.json()["id"]
print(res.json()["secret"])  # store it now: it is never shown again
```

```json title="Response"
{
  "id": "cmf3k2xe80006q8b7d2f4g6hj",
  "url": "https://example.ngrok-free.app/hooks/avvio",
  "events": ["payout.pending", "payout.processing", "payout.completed", "payout.failed", "payout.returned", "payout.canceled"],
  "secret": "whsec_…",
  "warning": "Store this secret now — it is not retrievable.",
  "createdAt": "2026-09-03T09:58:00.000Z"
}
```

## Verify the signature

Deliveries are signed with [Standard Webhooks](https://www.standardwebhooks.com/), so any Standard Webhooks library works, or use the dependency-free code below.

The signed content is `svix-id`, `svix-timestamp` and the raw body, joined by dots. Verify the bytes you received before parsing them, because re-serialized JSON will not match. `svix-signature` is a space-separated list of `v1,<base64>` values; during a secret rotation it carries two, so accept a match on any.

```js title="Verify" {18-19,22-27}
// verify.js
import crypto from 'node:crypto';

// whsec_… → the raw HMAC key
const key = Buffer.from(process.env.AVVIO_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');

export function verify(headers, rawBody) {
  const id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatures = headers['svix-signature'];
  if (!id || !timestamp || !signatures) return false;

  // Reject deliveries more than 5 minutes from your clock (also rejects a non-numeric timestamp).
  if (!(Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300)) return false;

  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody) // the bytes you received, not re-serialized JSON
    .digest();

  return signatures.split(' ').some((entry) => {
    const [version, sig] = entry.split(',');
    if (version !== 'v1' || !sig) return false;
    const got = Buffer.from(sig, 'base64');
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}
```

```python title="Verify" {18,21-24}
# verify.py
import base64, hashlib, hmac, os, time

# whsec_… → the raw HMAC key
KEY = base64.b64decode(os.environ["AVVIO_WEBHOOK_SECRET"].removeprefix("whsec_"))

def verify(headers, raw_body: bytes) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signatures = headers.get("svix-signature")
    if not (msg_id and timestamp and signatures):
        return False

    # Reject deliveries more than 5 minutes from your clock.
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False

    signed = f"{msg_id}.{timestamp}.".encode() + raw_body  # the bytes you received
    expected = base64.b64encode(hmac.new(KEY, signed, hashlib.sha256).digest())

    for entry in signatures.split(" "):
        version, _, sig = entry.partition(",")
        if version == "v1" and hmac.compare_digest(sig.encode(), expected):
            return True
    return False
```

## Answer 2xx fast, and dedupe

Any `2xx` counts as delivered; anything else, including a timeout, is retried for roughly 70 hours. **Answer before you process:** a receiver that works first times out under load and gets the same event again.

Delivery is at least once. Dedupe on `svix-id`, which is the event `id` and is stable across retries. Answer `2xx` to event types you do not handle, since new types can be added at any time.

```js title="Receive" {17,19-21}
// server.js
import http from 'node:http';
import { verify } from './verify.js';
import { processEvent } from './handle.js';

const seen = new Set(); // use your database in production

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);
    if (!verify(req.headers, raw)) {
      res.writeHead(400).end();
      return;
    }
    res.writeHead(204).end(); // acknowledge first

    const eventId = req.headers['svix-id'];
    if (seen.has(eventId)) return;
    seen.add(eventId);
    queueMicrotask(() => processEvent(JSON.parse(raw)));
  });
}).listen(3000);
```

```python title="Receive" {16-17,19-22}
# server.py
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from verify import verify
from handle import process_event

seen = set()  # use your database in production

class Webhook(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("content-length", 0)))
        if not verify(self.headers, raw):
            self.send_response(400)
            self.end_headers()
            return
        self.send_response(204)  # acknowledge first
        self.end_headers()

        event_id = self.headers["svix-id"]
        if event_id in seen:
            return
        seen.add(event_id)
        process_event(json.loads(raw))

HTTPServer(("", 3000), Webhook).serve_forever()
```

## Never move a payout backwards

Retries can reorder deliveries, so a late `payout.processing` can land after `payout.completed`. Ignore anything older than the state you hold.

The only move out of `completed` is `payout.returned`, an event whose body carries `status: failed` and `failureCode: returned_by_bank`. There is no `returned` status. Read `fundsReturned` before changing your ledger on a failure.

Webhook amounts are flat strings beside separate currency fields (`sourceAmount`, `sourceCurrency`), unlike the `{currency, amount}` objects on the REST API. The exception is `fee`, which is an object, `{ "amount": "1.02", "currency": "USD" }` (or `null` when the network has not disclosed one).

```js title="Handle" {10,13}
// handle.js
const rank = { pending: 0, processing: 1, completed: 2, failed: 3, canceled: 3 };
const payouts = new Map(); // use your database in production

export function processEvent(event) {
  if (!event.type.startsWith('payout.')) return; // 2xx already sent
  const payout = event.data;
  const current = payouts.get(payout.payoutId);

  if (current && rank[payout.status] <= rank[current.status]) return; // stale

  payouts.set(payout.payoutId, payout);
  if (payout.status === 'failed' && payout.fundsReturned === true) {
    // the money is back on your balance
  }
}
```

```python title="Handle" {11-12,15}
# handle.py
RANK = {"pending": 0, "processing": 1, "completed": 2, "failed": 3, "canceled": 3}
payouts = {}  # use your database in production

def process_event(event):
    if not event["type"].startswith("payout."):
        return  # 2xx already sent
    payout = event["data"]
    current = payouts.get(payout["payoutId"])

    if current and RANK[payout["status"]] <= RANK[current["status"]]:
        return  # stale

    payouts[payout["payoutId"]] = payout
    if payout["status"] == "failed" and payout.get("fundsReturned") is True:
        pass  # the money is back on your balance
```

## Trigger some events

Send a sandbox payout, as in the [Mexico recipe](/recipes/send-your-first-payout-to-mexico/). Changes are dispatched every 15 seconds, so expect a delivery within about that.

A new endpoint can also receive events from up to about 10 minutes before you registered it, so expect deliveries for earlier sandbox payouts. Dedupe on `id` and never move a payout backwards, and they do no harm.

Each tab below sends its own `reference`. An identical body sent again under a new key within 15 minutes is refused with `409 DUPLICATE_REQUEST_DETECTED` and nothing is sent, so change the `reference` or amount if you run the same tab twice.

Pay an account ending in `0003` to see the return path: `payout.pending`, `payout.completed`, then `payout.returned`. `payout.processing` may not appear, because not every payout passes through every state.

```bash title="POST /payments/organizations/{orgId}/payouts" {6}
IDEMPOTENCY_KEY=$(uuidgen)  # new key per call; reuse it only to retry this exact request
curl -s -X POST "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/payouts" \
  -H "x-api-key: $AVVIO_API_KEY" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "content-type: application/json" \
  -d '{ "amount": "25.00", "destinationAccountId": "sbx_acct_…", "reference": "HOOKS-TEST-CURL" }'
```

```js title="POST /payments/organizations/{orgId}/payouts" {3}
const payout = await avvio.payout({
  amount: '25.00',
  destinationAccountId: 'sbx_acct_…', // a recipient whose account ends in 0003
  reference: 'HOOKS-TEST-NODE',
});
```

```python title="POST /payments/organizations/{orgId}/payouts" {6}
import uuid

res = requests.post(
    f"{os.environ['AVVIO_BASE_URL']}/payments/organizations/{os.environ['AVVIO_ORG_ID']}/payouts",
    headers={"x-api-key": os.environ["AVVIO_API_KEY"], "Idempotency-Key": str(uuid.uuid4())},
    json={"amount": "25.00", "destinationAccountId": "sbx_acct_…", "reference": "HOOKS-TEST-PY"},  # an account ending in 0003
)
res.raise_for_status()
```

Each delivery body looks like this (abridged; [Webhook events](/coverage/webhook-events/) has every field). Its `id` is the `svix-id` header and the event's `id` in the feed.

```json title="A delivery body"
{
  "id": "cmf3k2xb20002q8b7c1s9m4rw",
  "sequence": "48213",
  "type": "payout.completed",
  "createdAt": "2026-09-03T10:00:00.000Z",
  "apiVersion": 1,
  "livemode": false,
  "data": {
    "payoutId": "sbx_pay_…",
    "status": "completed",
    "reference": "PAYROLL-2026-09",
    "sourceCurrency": "USD",
    "sourceAmount": "200.00",
    "destinationCurrency": "MXN",
    "destinationAmount": "3384.65",
    "fee": { "amount": "1.02", "currency": "USD" }
  }
}
```

## Check the delivery log

The delivery log lists the 50 most recent deliveries, newest first, one per event with its attempt count: which event, what your server last answered and when the next retry is due. It does not include the payload. To read one, page `GET /events` (narrowed by `payoutId` or `type`) and match its `id`; the feed has no lookup by id. `eventId` equals the `svix-id` you received. `null` on both `deliveredAt` and `nextAttemptAt` means the delivery is dead and will not be retried.

Reconcile against `GET /events` on a schedule too: a webhook you never received looks like one that never fired. [Reconcile with the event feed](/recipes/reconcile-with-events/) builds that sync.

```bash title="GET /payments/organizations/{orgId}/sandbox/webhook-endpoints/{endpointId}/deliveries" {1}
curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/sandbox/webhook-endpoints/$ENDPOINT_ID/deliveries" \
  -H "x-api-key: $AVVIO_API_KEY"
```

```js title="GET /payments/organizations/{orgId}/sandbox/webhook-endpoints/{endpointId}/deliveries" {1}
const deliveries = await avvio.webhookDeliveries(endpoint.id);
```

```python title="GET /payments/organizations/{orgId}/sandbox/webhook-endpoints/{endpointId}/deliveries" {3}
res = requests.get(
    f"{os.environ['AVVIO_BASE_URL']}/payments/organizations/{os.environ['AVVIO_ORG_ID']}"
    f"/sandbox/webhook-endpoints/{endpoint_id}/deliveries",
    headers={"x-api-key": os.environ["AVVIO_API_KEY"]},
)
res.raise_for_status()
```

```json title="Response"
[
  {
    "id": "cmf3k2xe80007q8b7k8l0m2np",
    "eventId": "cmf3k2xb20002q8b7c1s9m4rw",
    "eventType": "payout.processing",
    "attempts": 1,
    "deliveredAt": "2026-08-20T14:03:13.100Z",
    "nextAttemptAt": null,
    "lastError": null,
    "createdAt": "2026-08-20T14:03:12.900Z"
  }
]
```
