# Reconciling payouts with your database

URL: https://www.paydude.io/resources/reconciling-payouts-with-your-database
Type: Guide
Published: 2026-09-28 · Updated: 2026-09-28
Summary: Rebuild a payout from its balance transactions and match it to your orders. Separate fee rows, fractional-cent amounts, and a reconciliation job that is safe to re-run.

## Summary

Reconciling a payout means proving that the amount in your bank equals the sum of the balance transactions underneath it, and tying each of those back to an order in your own database. Fees are not deducted on the charge record — they arrive as separate balance transactions of type `fee`, so the charge's own `fee` field reads zero and its `net` equals the gross amount. Balance transaction amounts are in minor units but are not integers: a 2.9% fee on a $12.34 charge arrives as −35.786, and a rounding adjustment later settles the remainder. Fetch the transactions with the `payout_id` filter, sum them in scaled integers rather than floats, and store every line keyed by its own id so a repeated webhook changes nothing.

Your bank shows a deposit of $11.68. Your orders table says you sold $12.34. Reconciliation is the work of proving those two numbers describe the same money — and of noticing, automatically, on the day they stop doing so.

Every figure in this article comes from a real payout in the Paydude sandbox, pulled through the API on 28 September 2026. The traps it describes are ones that data actually contains, not ones inferred from the reference.

## The four objects, and how they link

**The reconciliation chain, from bank deposit to order**

| Object | What it is | How you reach the next one |
| --- | --- | --- |
| Payout `po_…` | One transfer to the bank account | List balance transactions with `?payout_id=po_…` |
| Balance transaction `txn_…` | One movement in the balance: a charge, a fee, a refund, an adjustment | `source_id` — for a charge and its fees, the charge id |
| Charge `ch_…` | One attempt to move money for a payment | `payment_intent_id` |
| Payment intent `pi_…` | The payment your code created | `metadata` — where your own order reference lives |

The last link is the one you own. If you did not put a reference in the payment intent's `metadata` when you created it, there is nothing exact to join on — which is the same reason [idempotency keys explained](https://www.paydude.io/resources/idempotency-keys-explained) recommends it for safe retries.

## A real payout, taken apart

Payout `po_nU7M0EaxRxGJzCR217mDD` paid out $11.68 from a single $12.34 card payment. Asking for its balance transactions returns four rows:

**Balance transactions for one sandbox payout, 28 September 2026**

| type | fee_type | amount (cents) | source_id |
| --- | --- | --- | --- |
| charge | — | 1234 | ch_CoIc1Jt5… |
| fee | discount | −35.786 | ch_CoIc1Jt5… |
| fee | transaction | −30 | ch_CoIc1Jt5… |
| adjustment | — ("Rounding adjustment") | −0.214 | acct_LAvgz7PQ… |
| **Total** |  | **1168** |  |

The percentage fee is 2.9% of 1234 cents, which is exactly 35.786 — the API records it unrounded. The 30-cent fixed fee is its own row. The rounding adjustment of −0.214 brings the total to a whole 1168 cents, and a matching **+0.214** adjustment appeared on the *next* payout. The fraction is carried forward, not lost, which means a payout can only be reconciled with its neighbours in view.

## Four things that break a naive reconciliation

### 1. The fee is not on the charge

The charge's balance transaction reports `fee: 0` and `net: 1234`. The API's own field description says fees "are now portrayed as individual balance transactions of `type = fee`" and that "`net` will no longer differ from `amount`". Any report that computes revenue from `net` is reporting gross revenue and calling it net.

### 2. Amounts are not integers

"Smallest currency unit" suggests whole cents, and every example in the reference is an integer. The data is not: −35.786, −0.214, and in another payout a pair of ±0.786 rounding entries. An `integer` column will reject or truncate these, and summing them as JavaScript numbers drifts. Store them as `NUMERIC` and add them up in scaled integers.

### 3. The payout's own transaction is not part of the payout

Each payout also has a balance transaction of `type: "payout"` for the full amount, negative (−1168 here), with `payout_id: null`. It records money leaving the balance. It is not one of the lines the payout is made of, and including it zeroes every total.

### 4. The transaction count is not a checksum

The payout object accepts `include=transaction_count`. On the payout above it returned **2**, against four balance transactions carrying that payout's id; on another payout it returned 1 against four. Whatever it counts, it is not the number of rows you need to have fetched, so do not use it to decide your fetch was complete — use `has_more`.

> **Payouts can be negative:** One sandbox payout carried an amount of −66: money taken from the bank account rather than sent to it, with a positive balance transaction of +66 alongside. Code that assumes a payout is a deposit will file this one backwards.

## The reconciliation job

Run it when a payout is paid. `payout.paid` is a webhook event you can subscribe to; verify the signature first, as covered in [webhook signature verification](https://www.paydude.io/resources/webhook-signature-verification).

```javascript
// Called from your webhook handler for payout.paid, after verification.
async function reconcilePayout(payoutId) {
  // Read the object rather than trusting the event name — see below.
  const payout = await api.get(`/v1/payouts/${payoutId}`);
  if (payout.status !== "paid") return;

  // Every balance transaction in this payout. Offset-paginated; has_more,
  // not transaction_count, tells you when you have them all.
  const txns = [];
  for (let offset = 0; ; offset += 100) {
    const page = await api.get(
      `/v1/balance-transactions?payout_id=${payoutId}&limit=100&offset=${offset}`
    );
    txns.push(...page.items);
    if (!page.has_more) break;
  }

  // Amounts carry fractions of a cent. Sum as scaled integers, never floats.
  const micro = (n) => Math.round(n * 1_000_000);
  const total = txns.reduce((sum, t) => sum + micro(t.amount), 0);
  const balanced = total === micro(payout.amount);

  // One row per transaction, keyed by its own id: a re-delivered webhook
  // overwrites rather than duplicates.
  for (const t of txns) {
    await db.ledger.upsert({ id: t.id }, {
      payout_id: payoutId,
      type: t.type,
      fee_type: t.fee_type,
      amount: String(t.amount),          // NUMERIC column keeps the fraction
      source_type: t.source_type,
      source_id: t.source_id,
    });
  }

  // Tie each charge — and the fees that share its source_id — to your order.
  const chargeIds = new Set(
    txns.filter((t) => t.source_type === "charge").map((t) => t.source_id)
  );
  for (const id of chargeIds) {
    const charge = await api.get(`/v1/charges/${id}`);
    const intent = await api.get(`/v1/payment-intents/${charge.payment_intent_id}`);
    await db.ledger.update(
      { source_id: id },
      { order_ref: intent.metadata?.reference ?? null }
    );
  }

  await db.payouts.upsert({ id: payoutId }, { amount: String(payout.amount), balanced });
  if (!balanced) await alertFinance({ payoutId, expected: payout.amount, found: total / 1e6 });
}
```

```sql
create table ledger_entries (
  id           text primary key,         -- txn_…, so re-running is an upsert
  payout_id    text not null,
  type         text not null,            -- charge, fee, adjustment, refund, …
  fee_type     text,                     -- discount, transaction, …
  amount       numeric(18, 6) not null,  -- minor units, and fractional
  source_type  text not null,
  source_id    text not null,            -- ch_… for a charge and its fees
  order_ref    text                      -- your reference, via the payment intent
);

create index on ledger_entries (payout_id);
create index on ledger_entries (source_id);
```

> **Why the job re-reads the payout:** In the sandbox event log, one `payout.created` event already carried `status: "paid"`, and its `payout.paid` followed four milliseconds later. An event's name tells you what happened first; its data tells you the state when it was sent. Trigger on the event, then decide on the object.

## What "reconciled" should mean

1. **The payout balances.** The sum of its balance transactions equals its amount, exactly, in scaled integers.
2. **Every charge maps to an order.** A charge with no `order_ref` is money you received and cannot explain — usually a payment intent created without metadata.
3. **Every paid order appears in some payout.** Run this one from your side: orders marked paid with no ledger row after a few days are the gap the first two checks cannot see.
4. **Anything unmatched goes to a person.** The job's value is the exception list, not the green tick.

The balance-transaction `type` list also includes `refund`, `dispute`, `dispute_won` and several failure types. They were not present in the payouts examined here, so this article does not describe their shape — but they sit in the same list, carry a `source_id`, and belong in the same sum. Treat any type you have not seen before as an exception until you have.

## Daily totals and the timezone default

`GET /v1/balance-transactions/summary` groups totals by day or month, which is useful for a finance dashboard. Its day buckets began at **05:00 UTC** when no `time_zone` was passed — midnight in US Central daylight time. If your books close on UTC days, or on your own local day, pass `time_zone` explicitly or the summary and your ledger will disagree at the edges of every day.

> **Sandbox timing is not production timing:** Sandbox payouts run on the sandbox's own schedule. Use it to learn the shape of the data — the objects, the links, the fractions — not how many days settlement takes.

**Build against the sandbox first** An open sandbox, SDKs and an MCP server, on every tier. — [View pricing](https://www.paydude.io/pricing)

## Frequently asked questions

### How do I reconcile a payout with my database?

List the balance transactions for the payout with the payout_id filter, sum their amounts, and confirm the total equals the payout amount. Then follow each charge's source_id to the charge, the charge's payment_intent_id to the payment intent, and the payment intent's metadata to your own order reference. Store each balance transaction keyed by its id so the job can be re-run safely.

### Why doesn't the fee show on the charge?

Fees are recorded as separate balance transactions of type fee, sharing the charge's source_id. The charge's own balance transaction reports a fee of zero and a net equal to its gross amount, so revenue calculated from net is gross revenue.

### Why are balance transaction amounts fractional?

Percentage fees are recorded unrounded — 2.9% of 1234 cents is 35.786 — and rounding adjustments later settle the remainder, sometimes on the following payout. Store amounts in a NUMERIC column and sum them as scaled integers rather than floating-point numbers.

### Should I include the payout's own balance transaction in the sum?

No. The balance transaction of type payout records the money leaving your balance for the bank. It has no payout_id and is not one of the transactions the payout is made of; including it cancels the total out.

### Which webhook should trigger reconciliation?

payout.paid. Verify its signature, then fetch the payout and check its status yourself rather than relying on the event name — an event's data reflects the object when the event was sent, which can already be later than the event's name suggests.

### Can a payout be negative?

Yes. A payout can carry a negative amount, meaning money was taken from the bank account rather than deposited. Reconciliation code should handle the sign rather than assume every payout is a deposit.
