Reconcile with the event feed
Store a cursor
You need the three variables from the Quickstart. The code uses PostgreSQL; any database with transactions works. Reconcile your ledger explains the feed this builds on.
The only state you keep is the last
sequenceyou processed and the ids of the events you applied.sequenceis a decimal string: store it as text, or as an integer wide enough for 64 bits. Start at0, which reads the feed from its first row.schema.sql CREATE TABLE avvio_cursor (id int PRIMARY KEY, since text NOT NULL);INSERT INTO avvio_cursor VALUES (1, '0');CREATE TABLE avvio_applied_events (event_id text PRIMARY KEY);Read a page
Ask for everything after your cursor, then carry each page's
nextSince.sinceis a sequence, not a timestamp.limitgoes up to 500, andtype=keeps the page to payout events.A row's
datais the webhook body'sdata, verbatim, so you can book from the feed alone (what a row carries).GET /payments/organizations/{orgId}/events curl -s "$AVVIO_BASE_URL/payments/organizations/$AVVIO_ORG_ID/events?since=40&limit=500&type=payout.pending,payout.processing,payout.completed,payout.failed,payout.returned,payout.canceled" \-H "x-api-key: $AVVIO_API_KEY"GET /payments/organizations/{orgId}/events import { PayoutsClient } from '@avvio/payments';const avvio = new PayoutsClient(); // reads AVVIO_API_KEY, AVVIO_ORG_ID and AVVIO_BASE_URLconst page = await avvio.listEvents({since: '40',limit: 500,type: ['payout.pending', 'payout.processing', 'payout.completed','payout.failed', 'payout.returned', 'payout.canceled'],});GET /payments/organizations/{orgId}/events import os, requestsres = requests.get(f"{os.environ['AVVIO_BASE_URL']}/payments/organizations/{os.environ['AVVIO_ORG_ID']}/events",headers={"x-api-key": os.environ["AVVIO_API_KEY"]},params={"since": "40","limit": 500,"type": "payout.pending,payout.processing,payout.completed,""payout.failed,payout.returned,payout.canceled",},)res.raise_for_status()page = res.json()Response {"data": [{"id": "cmf3k2x9a0001q8b7h4d2e6zt","sequence": "41","type": "payout.processing","payoutId": "sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11","batchId": null,"status": "processing","createdAt": "2026-08-17T09:12:00.000Z","apiVersion": 1,"data": {"payoutId": "sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11","status": "processing","sourceCurrency": "USD","sourceAmount": "200.00","destinationCurrency": "MXN","destinationAmount": "3384.65","destinationAccountId": "sbx_acct_MXN_4471_ae66cbc5","fee": { "currency": "USD", "amount": "1.02" },"rate": "16.923250","reference": "ZZ-2026-0042","endUser": { "id": "customer_42" },"endUserId": "customer_42","createdAt": "2026-08-17T09:11:58.000Z","completedAt": null}}],"hasMore": false,"nextSince": "41"}Apply each event once
Write the event id, your ledger change and the new cursor in one database transaction. If the process dies halfway, the next run re-reads the page and skips what it already applied.
Cursor on
sequence, dedupe onid. A timestamp cursor drops events and delivery is at least once (why). Theidis the webhook'ssvix-id, so one dedupe table serves both.Sync // sync.jsconst BASE = `${process.env.AVVIO_BASE_URL}/payments/organizations/${process.env.AVVIO_ORG_ID}`;const TYPES = 'payout.pending,payout.processing,payout.completed,payout.failed,payout.returned,payout.canceled';export async function syncOnce(db) {let { since } = await db.one('SELECT since FROM avvio_cursor WHERE id = 1');for (;;) {const res = await fetch(`${BASE}/events?since=${since}&limit=500&type=${TYPES}`, {headers: { 'x-api-key': process.env.AVVIO_API_KEY },});if (!res.ok) throw new Error(`events: ${res.status}`);const page = await res.json();await db.tx(async (tx) => {for (const event of page.data) {const fresh = await tx.result('INSERT INTO avvio_applied_events VALUES ($1) ON CONFLICT DO NOTHING', [event.id]);if (fresh.rowCount === 1) await applyToLedger(tx, event); // your booking logic}await tx.none('UPDATE avvio_cursor SET since = $1 WHERE id = 1', [page.nextSince]);});since = page.nextSince;if (!page.hasMore) return;}}Sync # sync.py. `conn` is a psycopg 3 connection opened with autocommit=True,# so each `conn.transaction()` block commits on its own.import os, requestsBASE = f"{os.environ['AVVIO_BASE_URL']}/payments/organizations/{os.environ['AVVIO_ORG_ID']}"TYPES = "payout.pending,payout.processing,payout.completed,payout.failed,payout.returned,payout.canceled"def sync_once(conn):(since,) = conn.execute("SELECT since FROM avvio_cursor WHERE id = 1").fetchone()while True:res = requests.get(f"{BASE}/events",headers={"x-api-key": os.environ["AVVIO_API_KEY"]},params={"since": since, "limit": 500, "type": TYPES},timeout=30,)res.raise_for_status()page = res.json()with conn.transaction():for event in page["data"]:fresh = conn.execute("INSERT INTO avvio_applied_events VALUES (%s) ON CONFLICT DO NOTHING", (event["id"],))if fresh.rowcount == 1:apply_to_ledger(conn, event) # your booking logicconn.execute("UPDATE avvio_cursor SET since = %s WHERE id = 1", (page["nextSince"],))since = page["nextSince"]if not page["hasMore"]:returnHandle a return after completed
A receiving bank can return a payout days after it reported
completed. The feed appends apayout.returnedevent withstatus: failedandfailureCode: returned_by_bank. Route it straight to a reversal of the booking you made oncompleted.Check
fundsReturnedbefore you credit anything back.truemeans the money is on your balance again. A missing field means we do not know yet, which is not the same asfalse.In the sandbox, pay an account number ending in
0003to get this event about 30 seconds afterpayout.completed.Response {"id": "cmf3k2xd70003q8b7n6v0p2ky","sequence": "43","type": "payout.returned","payoutId": "sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11","batchId": null,"status": "failed","failureCode": "returned_by_bank","fundsReturned": true,"createdAt": "2026-08-19T14:02:11.000Z","apiVersion": 1,"data": { "payoutId": "sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11", "status": "failed", "failureCode": "returned_by_bank", "fundsReturned": true }}Resume after downtime
Nothing is lost while your service is down. The feed replays from any cursor, so on restart
syncOncepages forward untilhasMoreisfalse.Run
syncOncewhen a webhook arrives and on a schedule, because a webhook you never received looks the same as one that never fired.GET /eventsallows 600 requests a minute. Receive and verify webhooks sets up the trigger, and Reconcile your ledger checks the money against the balance transactions.
Was this page helpful?