Receive and verify webhooks
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
eventsto receive every payout event type.The signing
secretis 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"]}'POST /payments/organizations/{orgId}/sandbox/webhook-endpoints import { PayoutsClient } from '@avvio/payments';const avvio = new PayoutsClient(); // reads AVVIO_API_KEY, AVVIO_ORG_ID and AVVIO_BASE_URLconst 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 againPOST /payments/organizations/{orgId}/sandbox/webhook-endpoints import os, requestsres = 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 againResponse {"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, so any Standard Webhooks library works, or use the dependency-free code below.
The signed content is
svix-id,svix-timestampand the raw body, joined by dots. Verify the bytes you received before parsing them, because re-serialized JSON will not match.svix-signatureis a space-separated list ofv1,<base64>values; during a secret rotation it carries two, so accept a match on any.Verify // verify.jsimport crypto from 'node:crypto';// whsec_… → the raw HMAC keyconst 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);});}Verify # verify.pyimport base64, hashlib, hmac, os, time# whsec_… → the raw HMAC keyKEY = 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 Falsesigned = f"{msg_id}.{timestamp}.".encode() + raw_body # the bytes you receivedexpected = 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 Truereturn FalseAnswer 2xx fast, and dedupe
Any
2xxcounts 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 eventidand is stable across retries. Answer2xxto event types you do not handle, since new types can be added at any time.Receive // server.jsimport http from 'node:http';import { verify } from './verify.js';import { processEvent } from './handle.js';const seen = new Set(); // use your database in productionhttp.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 firstconst eventId = req.headers['svix-id'];if (seen.has(eventId)) return;seen.add(eventId);queueMicrotask(() => processEvent(JSON.parse(raw)));});}).listen(3000);Receive # server.pyimport jsonfrom http.server import BaseHTTPRequestHandler, HTTPServerfrom verify import verifyfrom handle import process_eventseen = set() # use your database in productionclass 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()returnself.send_response(204) # acknowledge firstself.end_headers()event_id = self.headers["svix-id"]if event_id in seen:returnseen.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.processingcan land afterpayout.completed. Ignore anything older than the state you hold.The only move out of
completedispayout.returned, an event whose body carriesstatus: failedandfailureCode: returned_by_bank. There is noreturnedstatus. ReadfundsReturnedbefore 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 isfee, which is an object,{ "amount": "1.02", "currency": "USD" }(ornullwhen the network has not disclosed one).Handle // handle.jsconst rank = { pending: 0, processing: 1, completed: 2, failed: 3, canceled: 3 };const payouts = new Map(); // use your database in productionexport function processEvent(event) {if (!event.type.startsWith('payout.')) return; // 2xx already sentconst payout = event.data;const current = payouts.get(payout.payoutId);if (current && rank[payout.status] <= rank[current.status]) return; // stalepayouts.set(payout.payoutId, payout);if (payout.status === 'failed' && payout.fundsReturned === true) {// the money is back on your balance}}Handle # handle.pyRANK = {"pending": 0, "processing": 1, "completed": 2, "failed": 3, "canceled": 3}payouts = {} # use your database in productiondef process_event(event):if not event["type"].startswith("payout."):return # 2xx already sentpayout = event["data"]current = payouts.get(payout["payoutId"])if current and RANK[payout["status"]] <= RANK[current["status"]]:return # stalepayouts[payout["payoutId"]] = payoutif payout["status"] == "failed" and payout.get("fundsReturned") is True:pass # the money is back on your balanceTrigger 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
idand 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 with409 DUPLICATE_REQUEST_DETECTEDand nothing is sent, so change thereferenceor amount if you run the same tab twice.Pay an account ending in
0003to see the return path:payout.pending,payout.completed, thenpayout.returned.payout.processingmay not appear, because not every payout passes through every state.Each delivery body looks like this (abridged; Webhook events has every field). Its
idis thesvix-idheader and the event'sidin the feed.POST /payments/organizations/{orgId}/payouts IDEMPOTENCY_KEY=$(uuidgen) # new key per call; reuse it only to retry this exact requestcurl -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" }'POST /payments/organizations/{orgId}/payouts const payout = await avvio.payout({amount: '25.00',destinationAccountId: 'sbx_acct_…', // a recipient whose account ends in 0003reference: 'HOOKS-TEST-NODE',});POST /payments/organizations/{orgId}/payouts import uuidres = 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()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 bypayoutIdortype) and match itsid; the feed has no lookup by id.eventIdequals thesvix-idyou received.nullon bothdeliveredAtandnextAttemptAtmeans the delivery is dead and will not be retried.Reconcile against
GET /eventson 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"GET /payments/organizations/{orgId}/sandbox/webhook-endpoints/{endpointId}/deliveries const deliveries = await avvio.webhookDeliveries(endpoint.id);GET /payments/organizations/{orgId}/sandbox/webhook-endpoints/{endpointId}/deliveries 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()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?