How to reconcile Stripe payments in a double-entry ledger
A customer pays $2,400 by card, Stripe says the payment succeeded, and it is tempting to write one line in your books: Stripe balance, plus $2,400. Almost every part of that line is wrong. The amount is wrong, because Stripe keeps a fee. The balance is wrong, because the money is not spendable yet. And the timing is wrong, because at the moment Stripe says “succeeded”, the number you need to record it may not exist yet.
A Stripe payment is not one event. It is a sequence of events spread over seconds for the fee and days for the cash, and a ledger that reconciles with Stripe records each of them as Stripe does. To reconcile Stripe payments, you record that sequence step by step. This guide shows which events to listen for, the accounts that mirror Stripe's balances, and the exact ledger transaction for each step, for a card payment and a bank debit.
A card payment is four events, not one
Here is a real $2,400.00 card payment from a Stripe test account, as Stripe's own event log shows it:
| Time | Event | What it tells you |
|---|---|---|
| 18:30:12 | payment_intent.created | A payment for $2,400.00 has been started |
| 18:30:13 | charge.succeeded | The card was charged $2,400.00 |
| 18:30:13 | payment_intent.succeeded | The payment is complete |
| 18:30:16 | charge.updated | The charge now carries its balance transaction |
The first three arrive within a second, and the third is the one every integration tutorial tells you to listen for. It is the wrong moment to record the payment, because on both charge.succeeded and payment_intent.succeeded the charge's balance_transaction field is still null.
The balance transaction is Stripe's record of what the payment did to your Stripe balance: the gross amount, the fee, the net, and the date the money becomes spendable (available_on). It is the only place the real fee comes from, and in the log above it appears three seconds after the payment succeeded.
If you post to the ledger at succeeded anyway, you post every payment without its fee. You then either guess the fee from Stripe's rate, which will disagree with Stripe by a cent often enough to matter, or correct it later with a second transaction. Both make the books disagree with Stripe for reasons that are not in Stripe.
Record the payment once, when the balance transaction exists
The gap comes from a setting, not from cards. Since API version 2024-04-10, Stripe's default capture_method is automatic_async: the confirm call returns as soon as the card is authorized, and Stripe creates the balance transaction in the background (Stripe's asynchronous capture docs). Integrations on older API versions never see the gap, which is why it surprises people.
What we recommend: listen for charge.updated, and post the payment once, from the balance transaction it carries. It works on every API version and every capture setting, it is the event Stripe itself points to, and it is the one path that does not depend on your checkout request surviving. The webhook carries the balance transaction's id, so fetch the fee, net and available_on with GET /v1/balance_transactions/txn_....
How your checkout gets there is your call, and none of it changes the ledger:
- Keep the default and let the customer see “succeeded” straight away. The ledger catches up a few seconds later.
- Pass
capture_method: 'automatic'withconfirm: true. The confirm call then waits for the balance transaction (about half a second longer in our tests), and you can post before you answer the customer. Set it explicitly, because an API or SDK upgrade otherwise changes the default underneath you. - Poll the PaymentIntent for a few seconds after confirming, if you want the same effect without changing capture.
const paymentIntent = await stripe.paymentIntents.create({
amount: 240000,
currency: 'usd',
customer: customerId,
payment_method: paymentMethodId,
confirm: true,
capture_method: 'automatic', // not the default since 2024-04-10
expand: ['latest_charge.balance_transaction'],
});
const balanceTransaction = paymentIntent.latest_charge?.balance_transaction;
// fee, net and available_on are all here, in the same responseWhichever you choose, the posting is identical: one transaction, carrying the real fee and the balance transaction id, written under one idempotency key. A checkout that posts first and a webhook that arrives later write the same transaction, so the second does nothing, and reconciliation sees exactly one posting per movement either way.
What we advise against is the shape the gap tempts you into: a “sale” transaction at succeeded, at gross, into a Stripe clearing account, followed seconds later by a second transaction moving it to pending once the fee is known. It records nothing the second transaction does not, and it doubles every payment's rows.
Dated at the charge. The posting may be written seconds or minutes after the payment. It still takes effect at the moment the customer paid, because that is when the income and the obligation came into existence. Recording an event after it happened, dated when it happened, is ordinary accounting, and it keeps a charge at 23:59:58 on 31 December in the year it belongs to.
Stripe has two balances, and only one of them is cash
The net of $2,330.10 does not land somewhere you can spend it. Stripe keeps two balances:
- Pending - money Stripe has collected for you and has not released yet. Every new balance transaction lands here first.
- Available - money you can pay out to your bank, or use for refunds and transfers.
Money moves from pending to available on the balance transaction's available_on date. In the US that is usually two business days after the charge. It is longer in many other countries, and a new Stripe account's first payout typically waits 7 to 14 days. In our own Stripe test account it was six to seven days.
This matters for anything that pays money out of Stripe. A platform that pays sellers, hosts or freelancers from its Stripe balance can only pay from available. If the books show one “Stripe” balance of $2,330.10 on day 0, a payout run that trusts the books will try to spend money Stripe will refuse to release.
balance.available is not a per-payment event
It is natural to expect Stripe to announce when this payment's money became available. It does not. balance.available fires when your available balance changes, and its payload is the whole balance object. It does not say which payments were released, and one event can cover hundreds of them.
You do not need it to. available_on is set the moment the balance transaction exists, so you know the release date seconds after the charge. Store it, and run a scheduled job that posts every release whose date has arrived. Treat balance.available as a hint to run that job early, never as the source of what to post.
Date the release on available_on, not on the charge. This one is easy to get wrong because both dates are right there. Posting the release with the charge's date tells the ledger the money was available from day 0. A balance sheet for day 1 then reports cash that Stripe was still holding, and, worse, it reported the correct figure when you ran it on day 1 and a different one when you ran it again on day 3. A report that changes its mind about the past with no new event to explain it is the kind of thing an auditor stops trusting the whole ledger over. We shipped exactly that in our own demo application, and it was caught by comparing the same report run on two different days.
Bank debits take days, and can still fail
A card gives you an answer in a second. A US bank debit (ACH Direct Debit) gives you a promise. The PaymentIntent moves to processing, and stays there for up to four business days while the customer's bank decides. Then it either succeeds or fails, and a “succeeded” debit can still come back as a return for up to 60 days on a personal account.
Whether your ledger records anything during those four days depends on one business question: do you deliver before the debit succeeds?
- If you wait for success before confirming the order, a bank debit is just a slow card. Post once, on success, exactly as below. You do not need anything new.
- If you confirm at
processing(the booking is held, the subscription is active, the goods ship), you now owe something to someone for money you do not have. That has to be in the books from day 0, and it needs somewhere to sit that is honest about what it is.
That somewhere is a clearing account: an asset that means “money a payment method has promised us and has not delivered”. It is debited when the debit starts processing, and credited when it resolves, either into Stripe's pending balance on success, or back out on failure. A clearing account with a balance is a list of open promises. One that has been non-zero for a debit started two weeks ago is a debit nobody followed up.
A card does not need a clearing account. Accounting tools often recommend one for Stripe (some call it Undeposited Funds), and the reason is real but different: it groups many charges into the single payout that lands in the bank. The pending and available accounts already model that. A card payment's money is in Stripe's pending balance within seconds, so there is no open promise for clearing to hold. A later refund or dispute is a new movement with its own balance transaction, not the original payment failing.
Mapping Stripe onto your chart of accounts
Every place money sits at Stripe gets its own account, and each account means one thing.
| Account | Type | Mirrors | Is it cash? |
|---|---|---|---|
assets:stripe-pending | Asset | Stripe's pending balance | No. It cannot be spent yet. |
assets:stripe-available | Asset | Stripe's available balance | Yes |
assets:stripe-clearing | Asset | Bank debits started and not yet resolved | No. It may never arrive. |
expenses:stripe-fees | Expense | Stripe's processing fees | Not applicable |
The rule that makes this reconcile: at any moment, stripe-pending should equal Stripe's pending balance, and stripe-available should equal Stripe's available balance. Those are two numbers you can fetch from GET /v1/balance and compare. Any difference is either a movement Stripe made that you have not posted, or a posting you made that Stripe never confirmed. Clearing has no Stripe counterpart to compare to, which is exactly why it should stay small and short-lived.
The other side of each payment depends on your business, and getting it right matters more than anything Stripe-specific here. In the examples below, a marketplace takes a 12% commission and owes the rest to a host:
income:commission- the platform's share. Only money you keep is income.liabilities:escrow/host-9001- the host's share, owed from the moment the customer pays. It is not income that you later spend. See escrow accounting for a marketplace.
The ledger transactions
All three examples use the same $2,400.00 payment. The card fee is Stripe's standard US rate at the time of writing (2.9% + $0.30 = $69.90). The bank debit fee is 0.8%, capped at $5.00.
A card payment: one transaction, written when the fee is known
Posted once the balance transaction exists, from charge.updated or from your checkout, dated at the charge.
| Account | Debit | Credit |
|---|---|---|
assets:stripe-pending | 2,330.10 | |
expenses:stripe-fees | 69.90 | |
liabilities:escrow/host-9001 | 2,112.00 | |
income:commission | 288.00 | |
| Total | 2,400.00 | 2,400.00 |
Three things about its shape are deliberate:
- Gross on the business side, net on the Stripe side. The customer paid $2,400.00, so commission and the host's share are split from $2,400.00. Stripe's pending balance only ever received $2,330.10, so that is what the Stripe account gets. Recording the net as revenue would understate revenue by every fee you have ever paid.
- The Stripe account appears on exactly one line. You could debit pending $2,400.00 and credit it $69.90 for the fee. The account would net to the same figure, and the posting would no longer match the balance transaction, which is a single net movement of $2,330.10. Match the shape of the thing you reconcile against.
- The fee comes from the balance transaction, never from a rate. A computed fee makes reconciliation compare your arithmetic with itself.
A bank debit: two transactions, through clearing
Only if you deliver at processing. Otherwise, post the card-shaped transaction on success.
1. On payment_intent.processing, dated then:
| Account | Debit | Credit |
|---|---|---|
assets:stripe-clearing | 2,400.00 | |
liabilities:escrow/host-9001 | 2,112.00 | |
income:commission | 288.00 |
2a. On success, when the balance transaction exists:
| Account | Debit | Credit |
|---|---|---|
assets:stripe-pending | 2,395.00 | |
expenses:stripe-fees | 5.00 | |
assets:stripe-clearing | 2,400.00 |
2b. Or on failure, the first transaction is undone:
| Account | Debit | Credit |
|---|---|---|
liabilities:escrow/host-9001 | 2,112.00 | |
income:commission | 288.00 | |
assets:stripe-clearing | 2,400.00 |
Either way clearing returns to zero for this payment. Post the failure as its own transaction rather than editing or deleting the first one: the record that you believed a debit was coming, and that it did not, is history you will want when the customer asks why their booking was cancelled.
A return after success is not this case. By then the money reached Stripe, so it comes back out as a dispute with its own balance transaction, and it is recorded like any other dispute.
Funds becoming available
Posted by the scheduled job, dated at available_on. The same for a card and a bank debit.
| Account | Debit | Credit |
|---|---|---|
assets:stripe-available | 2,330.10 | |
assets:stripe-pending | 2,330.10 |
Nothing about the business changed, so nothing outside Stripe's accounts moves. Only now can this money pay the host.
Making it reconcile, not just balance
Every transaction above balances. That is the ledger's arithmetic check, and it says nothing about whether the ledger agrees with Stripe. A payment that neither the checkout nor the webhook recorded leaves the books balanced and $2,330.10 short. These are the habits that close that gap:
- Put the balance transaction id (
txn_...) on the Stripe line. It is the key that ties one posting to one Stripe movement. The charge id is not enough, because one charge produces several balance transactions over its life: the payment, a refund, a dispute. - Classify in the description, identify in metadata. “Booking payment” is a description. The order id, charge id, PaymentIntent id and balance transaction id belong in metadata, where they can be searched and joined.
- One idempotency key per step, derived from the business object.
order_123:paymentandorder_123:available, never a random id. Webhooks are delivered at least once, and a retry must replay the posting rather than add a second one. - Recover missed webhooks with a sweep that looks for evidence. Run a scheduled job that asks “which charges have a balance transaction and no posting?” Do not key it on an order status field that the webhook also writes. Ours did, and it skipped every booking it existed to catch, because the webhook had moved the status past the value the sweep looked for. The sweep reported running every hour, and the gap was only found when the balances were compared with Stripe's.
- Compare balances, then compare movements. Account balance against
GET /v1/balancetells you whether you are wrong. Posting against balance transaction, one by one, tells you where.
What this guide does not cover
Each of these follows the same principle, one posting per balance transaction, and each has a detail worth its own treatment:
- Refunds and disputes. Each creates a negative balance transaction, and a dispute adds a fee that is not refunded even if you win.
- Payouts to your bank. One payout moves many payments' worth of available balance at once, so it reconciles against the payout, not against individual charges.
- Multi-currency charges. When the charge currency differs from your settlement currency, the balance transaction carries the exchange rate Stripe applied, and the net lands in the settlement currency.
- Stripe Connect. Destination charges and transfers to connected accounts add a second balance per connected account.
Everything on this page works in any double-entry ledger, including one you build yourself. The accounts, the one posting per balance transaction and the checks against GET /v1/balance need no vendor.
Ledfra is a ledger with this structure in its demo workspace. It reads your Stripe balances with a read-only key and matches each movement to the posting that carries its txn_ reference, listing whatever is missing on either side. It does not move money, pay out, or convert between currencies, and it does not reconcile against your bank account. How it works: connecting Stripe.
Where this goes next
The Stripe accounts are the easy half. The harder half is deciding what the other side of each payment is, because that decision makes revenue real or fictional. Escrow accounting for a marketplace covers money you hold for somebody else, and why marketplace accounting becomes so hard follows refunds and fee policies further. How to design your ledger has the naming and structure rules the accounts above follow.