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

# Reconcile your ledger

Page the event feed from a stored cursor to keep your ledger in step with every
payout, then check the money against the balance transactions.
[Reconcile with the event feed](/recipes/reconcile-with-events/) builds the
sync step by step, in Node and Python.

The event feed is every change to your payouts, in `sequence` order, complete
and replayable. Webhooks, which arrive in batches within about 15 seconds of a
transition, tell you when to read it.

![Reconciliation flow: webhooks nudge, the event feed is the record you replay from your last sequence](/partner-assets/diagrams/reconciliation.svg)

## The event feed

`GET /payments/organizations/{orgId}/events` returns everything after a cursor.
`since` is a sequence from a previous response, not a timestamp. On the first
call omit it or send `since=0` (an empty `since=` is the same), then carry each
page's `nextSince`. `limit` is 1 to 500 (default 100), `payoutId` narrows the
feed to one payout, and `type` takes a comma-separated list. An unknown type is
a `400 VALIDATION_ERROR`, never an empty page.

A row's `data` is the [webhook body](/webhooks/)'s `data`, verbatim, and its
`id` is the delivery's `svix-id`. It carries the amounts, `fee`, `rate`,
`reference` and `endUserId`, so you can book from the feed alone.

Payout, batch, approval, endpoint and checkout-payment events share one
sequence and are told apart by `type` ([Webhook events](/coverage/webhook-events/)).
A `payout_batch.*` row has `batchId` set and `payoutId: null`; a
`checkout_payment.*` row has no `payoutId` and a decimal-string `fee`. Pass
`type=` to book payouts only, or skip types you don't recognize.

Events become readable about two seconds after they happen. A row can get its
`sequence` before its transaction commits, and the delay keeps a reader at the
edge of a page from skipping it. No status change makes a network call inside
its transaction, so two seconds is enough.

> [!WARNING]
> **Cursor on `sequence`, dedupe on `id`**
> Timestamps tie across concurrent transactions, so a timestamp cursor drops
> events. Delivery is at least once, so an event applied without a dedupe on
> its `id` is booked twice. Webhooks carry the same value as `svix-id`, so one
> dedupe table serves both.

The feed is in `sequence` order, so events for one payout arrive in the order
they happened; webhooks can arrive out of order. An event is never updated in
place, and the feed replays from any cursor, so nothing is lost while your
service is down. Read it when a webhook arrives and on a schedule, because a
webhook you never received looks the same as one that never fired.

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`; reverse the booking you made on `completed`.
Check `fundsReturned` before you credit anything back: a missing field means we
do not know yet, which is not the same as `false`.

## Reconcile your balance

The event feed records state; the balance transactions feed records money. It
has one row per change to what you can spend, newest first, each with the
balance after it.

```http
GET /payments/organizations/{orgId}/balance_transactions?type=payout,payout_return&limit=100
x-api-key: avvio_live_…
```

```json
{
  "data": [
    {
      "id": "48213",
      "type": "payout",
      "amount": "-100.00",
      "fee": "0.50",
      "net": "-99.50",
      "currency": "USD",
      "balanceAfter": "9800.00",
      "orderId": "sbx_pay_7c1c0f2e-6d9b-4a3e-9d1a-2f4b8c9e0a11",
      "snapshotId": "sbx_quote_…",
      "batchId": null,
      "reference": "payroll-2026-09",
      "endUserId": "emp_412",
      "reason": null,
      "description": null,
      "createdAt": "2026-09-01T10:00:00.000Z"
    }
  ],
  "hasMore": true,
  "nextCursor": "48213"
}
```

| Field | Meaning |
| --- | --- |
| `amount` | Signed change to what you can spend, fee included. Positive for `funding`, `payout_return` and `hold_release`; negative for `payout` and `hold`; either for `adjustment` |
| `fee` | The part of `amount` that was the fee. `null` means the network published none: unknown, not zero |
| `net` | `\|amount\| − fee` with `amount`'s sign, the part converted for the recipient. Present only when `fee` is known |
| `balanceAfter` | Your balance after this row |

A `50.00` USD payout with a `0.26` fee is one row (`amount: -50.00`,
`fee: 0.26`, `net: -49.74`), with no separate fee row. A `payout_return` row's
`amount` is what came back: the full `50.00` in the sandbox. On a live rail,
whether the fee is kept on a return depends on the rail, and this row shows it.

To reconcile:

1. Pull newest first until you reach a row you have. Rows are append-only and
   `id` is monotonic, so `cursor=<the last id you saw>` returns strictly older
   rows. Stop when `hasMore` is false or a page holds only known rows.
2. Upsert by `id`, never `orderId`: one payout produces several rows (a hold,
   its release, the debit, a later return).
3. Check `balanceAfter`, not the total. The first row where your running
   balance disagrees is where the difference started.
4. Join to payouts on `orderId`, and to the event feed for when a state
   changed.

Compare against the network-held part of `GET /balance` (`provider[]`, or
`ledger[].available`), not the `amount` headline, which also counts your
wallet. A shortfall there is a hold: a `hold` row until it becomes a payout
(`hold_release` plus `payout`, net zero) or is released (`hold_release` alone).

A hold is placed before the network answers, so its `orderId` is `null`. Every
row sized by a quote snapshot carries its `snapshotId`: the `hold`, its
`hold_release` and the `payout`. Join the hold to its payout on `snapshotId`,
then read `orderId` off the `payout` row. On the two-step flow, `snapshotId` is
the `id` from `POST /quotes/offramp`. `funding` and `adjustment` rows have
`snapshotId: null`. The feed is served on every environment, and an empty page
is not an error.

## Cursors and lists

| | `GET /events` | `GET /orders` |
|---|---|---|
| Ordering | Monotonic `sequence` | `createdAt` descending; `updatedSince` switches to oldest-changed-first |
| A change to an old payout | Appended as a new event | Not reordered by default |
| Pagination | `since=<sequence>`, then `nextSince` | `cursor=<nextCursor>`, passed back verbatim |
| Use it for | Automated ledger sync | Dashboard history and search |

Both allow 600 requests a minute. The `/orders` cursor is opaque: pass the
`nextCursor` you were given back unchanged. A value we didn't issue, such as a
bare payout id, is a `400 VALIDATION_ERROR` (`cursor: unrecognized`). The other
lists (`/balance_transactions`, `/audit-events`) page by `cursor=<the last id
you saw>`, then `nextCursor`.

Unknown query parameters are ignored, not refused. `cursor` sent to `/events`
reads from the start of the feed, and `since` sent to a `cursor` list is
dropped, so check the parameter name if a page looks wrong.
