Skip to content

5 steps, all in the sandbox

  1. 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 sequence you processed and the ids of the events you applied. sequence is a decimal string: store it as text, or as an integer wide enough for 64 bits. Start at 0, 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);
  2. Read a page

    Ask for everything after your cursor, then carry each page's nextSince. since is a sequence, not a timestamp. limit goes up to 500, and type= keeps the page to payout events.

    A row's data is the webhook body's data, 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"
    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"
    }
  3. 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 on id. A timestamp cursor drops events and delivery is at least once (why). The id is the webhook's svix-id, so one dedupe table serves both.

    Sync
    // sync.js
    const 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;
    }
    }
  4. Handle a return after completed

    A receiving bank can return a payout days after it reported completed. The feed appends a payout.returned event with status: failed and failureCode: returned_by_bank. Route it straight to a reversal of the booking you made on completed.

    Check fundsReturned before you credit anything back. true means the money is on your balance again. A missing field means we do not know yet, which is not the same as false.

    In the sandbox, pay an account number ending in 0003 to get this event about 30 seconds after payout.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 }
    }
  5. Resume after downtime

    Nothing is lost while your service is down. The feed replays from any cursor, so on restart syncOnce pages forward until hasMore is false.

    Run syncOnce when a webhook arrives and on a schedule, because a webhook you never received looks the same as one that never fired. GET /events allows 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?