Skip to content

6 steps, all in the sandbox

  1. Register a sandbox endpoint

    You need the three variables from the Quickstart and a public HTTPS tunnel (for example cloudflared or ngrok) in front of a local server. 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.

    POST /payments/organizations/{orgId}/sandbox/webhook-endpoints
    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"]
    }'
    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"
    }
  2. Verify the signature

    Deliveries are signed with Standard Webhooks, 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.

    Verify
    // 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);
    });
    }
  3. 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.

    Receive
    // 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);
  4. 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).

    Handle
    // 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
    }
    }
  5. Trigger some events

    Send a sandbox payout, as in the Mexico recipe. 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.

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

    POST /payments/organizations/{orgId}/payouts
    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" }'
    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" }
    }
    }
  6. 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 builds that sync.

    GET /payments/organizations/{orgId}/sandbox/webhook-endpoints/{endpointId}/deliveries
    curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/sandbox/webhook-endpoints/$ENDPOINT_ID/deliveries" \
    -H "x-api-key: $AVVIO_API_KEY"
    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"
    }
    ]

Was this page helpful?