Your app has a ledger that says "we owe affiliates $4,210 this month." Stripe has its own record of what was actually charged, refunded, and settled. Everyone assumes those two numbers agree. They drift, quietly, and you find out when an affiliate emails asking why their payout is short.
The drift comes from ordinary things: a webhook that never arrived, a refund that updated Stripe but not your ledger, a currency rounding, a duplicate credit from a retried event. None of these are exotic. Any one of them means your ledger is now telling a story Stripe cannot back up.
The fix is a reconciliation job: once a period, diff your ledger against Stripe as the source of truth and flag every mismatch before you pay anyone.
The core idea
Stripe is the source of truth for money. Your ledger is a derived view. A derived view has to be provable against its source, so you check both directions.
Direction 1: every ledger credit maps to a real paid invoice
Pull the paid invoices for the period, then confirm each commission you recorded points at one that actually settled.
// paid invoices in the window, expanded to see discounts/amounts
const invoices = [];
for await (const inv of stripe.invoices.list({
status: 'paid',
created: { gte: periodStart, lt: periodEnd },
limit: 100,
})) {
invoices.push(inv);
}
const paidById = new Map(invoices.map(i => [i.id, i]));
for (const credit of ledger.creditsForPeriod(periodStart, periodEnd)) {
const inv = paidById.get(credit.invoice_id);
if (!inv) {
flag('credit_without_paid_invoice', credit); // paid on something Stripe has no record of
} else if (credit.base_amount !== inv.amount_paid) {
flag('base_mismatch', { credit, expected: inv.amount_paid });
}
}
credit_without_paid_invoice is the dangerous one. It means you are about to pay real money against a charge that never truly settled.
Direction 2: every affiliate-tagged paid invoice has a credit
This catches the missed webhooks, the silent under-payments that make affiliates quit.
for (const inv of invoices) {
const affiliateId = inv.customer_metadata?.affiliate_id;
if (!affiliateId) continue;
if (!ledger.hasCreditForInvoice(inv.id)) {
flag('missing_credit', { invoice: inv.id, affiliate_id: affiliateId });
}
}
A pile of missing_credit flags usually means your webhook handler was down for a stretch, or an event type you do not listen to (like an out-of-band paid invoice) slipped through.
Direction 3: refunds became clawbacks
A refund lives in Stripe. If it never propagated to your ledger, you are paying commission on money you gave back.
for await (const refund of stripe.refunds.list({ created: { gte: periodStart, lt: periodEnd }, limit: 100 })) {
const invoiceId = await invoiceForCharge(refund.charge);
if (invoiceId && ledger.hasCreditForInvoice(invoiceId) && !ledger.hasClawbackForRefund(refund.id)) {
flag('refund_without_clawback', { refund: refund.id, invoice: invoiceId });
}
}
What to do with the flags
Do not auto-correct silently. Reconciliation should produce a short report a human signs off on before payout runs:
-
credit_without_paid_invoiceandrefund_without_clawback-> hold those amounts, they overpay. -
missing_credit-> backfill, then figure out which webhook you dropped so it stops recurring. -
base_mismatch-> almost always a coupon or proration you computed off the wrong number.
The goal is not zero mismatches forever. It is that on payout day you can say, with a report to back it, that every dollar leaving matches a dollar Stripe actually collected and kept. That is the difference between an affiliate program you can scale and one that becomes a monthly argument.
We run this reconciliation inside Referralful (affiliate software on Stripe, which I help build) so payouts are provable against Stripe rather than trusted. If you build your own, the one habit worth stealing: never pay from your ledger alone, pay from your ledger after it has been proven against Stripe.
Top comments (0)