You've processed $50,000 in transactions this month, your Stripe dashboard says everything looks good, and yet your accounting team is asking why the numbers don't match the bank deposit. Payment reconciliation is the unglamorous but critical practice of proving that every cent is accounted for—from the moment a customer swipes their card to the day the money lands in your account. In this guide, we'll walk through how to build a reconciliation system that catches discrepancies before they become problems.
Why reconciliation matters in production
Most teams get away without formal reconciliation early on. You process a charge, the customer's card goes through, Stripe shows the funds, and weeks later money appears in your bank account. It all feels connected. But as you scale, gaps emerge: a webhook fails silently, a manual refund happens outside your system, Stripe applies fees you didn't account for, or a payout splits across multiple bank transfers. Without reconciliation, you're flying blind.
Payment reconciliation is a practical guide for developers because it bridges the gap between business expectations and technical reality. Your CFO needs to know the money is accounted for. Your team needs tooling to find and fix problems fast. Let's build that together.
Sources: Understanding the three data layers
Reconciliation starts with understanding where your data lives and what each source tells you.
Your orders database
This is your system of truth for customer intent. An order contains:
- Order ID and customer ID
- Amount charged (in cents)
- Timestamp of charge attempt
- Status (pending, succeeded, failed, refunded)
- Stripe charge ID or payment intent ID
You own this data. It reflects what your app told Stripe to charge. If a customer places an order for $29.99, your database records that exact amount. The challenge: this is intention, not confirmation.
Stripe balance transactions
Stripe's ledger view. Every balance transaction represents a real movement of money on Stripe's side: charges, refunds, adjustments, and fees. Balance transactions are Stripe's ground truth. You query them via the Stripe API, and they come with:
- Transaction ID
- Type (charge, refund, adjustment, payout, fee)
- Amount (in cents, already net of fees for some types)
- Timestamp
- Description or metadata linking to your order
- Status (available, pending)
Key insight: balance transactions include fees. A $100 charge might generate a balance transaction of $97.70 after Stripe's 2.9% + $0.30 cut. You need to account for that fee separately.
Bank payouts
The money you can actually spend. Stripe batches balance transactions into payouts and deposits them to your bank account. A payout includes:
- Payout ID
- Net amount (what actually hits your bank)
- Gross amount (before fees)
- List of balance transaction IDs included
- Payout date and arrival date
- Status (pending, in_transit, paid, failed)
A single payout typically includes hundreds of balance transactions batched together. Your bank statement shows only the payout total, not the individual transactions behind it.
Match: Building the reconciliation chain
Reconciliation is a three-stage matching process. Get this right and you've caught 95% of issues.
Stage 1: Payout to balance transactions
Start from the most reliable source—your bank. A deposit of $4,873.45 appeared in your account. Which Stripe transactions make up that payout?
Query Stripe for the payout object and fetch its balance transactions:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
async function reconcilePayout(payoutId) {
const payout = await stripe.payouts.retrieve(payoutId);
const balanceTransactions = await stripe.balanceTransactions.list({
payout: payoutId,
limit: 100,
});
let total = 0;
const breakdown = {
charges: 0,
refunds: 0,
fees: 0,
adjustments: 0,
};
for (const txn of balanceTransactions.data) {
total += txn.net; // net is already fee-adjusted
breakdown[txn.type] += txn.net;
console.log(`[${txn.type}] ${txn.id}: $${(txn.net / 100).toFixed(2)}`);
}
console.log(`Payout total: $${(payout.amount / 100).toFixed(2)}`);
console.log(`Calculated total: $${(total / 100).toFixed(2)}`);
console.log(`Match: ${total === payout.amount ? 'YES' : 'NO'}`);
return { payout, balanceTransactions: balanceTransactions.data, breakdown };
}
If the sums don't match, there's a data freshness issue (payout includes pending transactions) or an API error. Flag it and retry in a few hours.
Stage 2: Balance transactions to charges and orders
Now match each balance transaction back to your order. For charge-type balance transactions, look up the charge ID in Stripe, then correlate it to your order:
async function matchBalanceTxnToOrder(balanceTxn) {
// Balance transaction describes a charge; find the charge object
if (balanceTxn.type !== 'charge') {
return null; // Skip refunds, fees, etc. for now
}
const charge = await stripe.charges.retrieve(balanceTxn.source);
// Metadata often holds your order ID
const orderId = charge.metadata?.order_id;
if (!orderId) {
console.warn(`Charge ${charge.id} has no order_id metadata`);
return null;
}
// Query your database
const order = await db.orders.findById(orderId);
if (!order) {
console.error(`Order ${orderId} not found in database`);
return { status: 'order_missing', chargeId: charge.id, orderId };
}
// Compare amounts (both in cents)
const match = charge.amount === order.amount_charged;
return {
status: match ? 'matched' : 'amount_mismatch',
orderId,
chargeId: charge.id,
stripeAmount: charge.amount,
orderAmount: order.amount_charged,
};
}
Store these results in a reconciliation log table. Every matched pair is a win. Mismatches and missing orders are gaps to investigate.
Stage 3: Refunds and reversals
Balance transactions of type refund point back to a charge via a refund object. Match refunds to your order refund records:
async function matchRefund(balanceTxn) {
// balanceTxn.source is the refund ID
const refund = await stripe.refunds.retrieve(balanceTxn.source);
// refund.charge is the charge ID
const charge = await stripe.charges.retrieve(refund.charge);
const orderId = charge.metadata?.order_id;
const order = await db.orders.findById(orderId);
const orderRefund = await db.refunds.findByOrderId(orderId);
const match = orderRefund &&
orderRefund.amount_refunded === Math.abs(balanceTxn.net);
return {
status: match ? 'matched' : 'refund_mismatch',
orderId,
refundId: balanceTxn.source,
stripeAmount: Math.abs(balanceTxn.net),
orderRefundAmount: orderRefund?.amount_refunded || 0,
};
}
Investigate gaps: Finding and understanding discrepancies
Even with perfect automation, gaps happen. The investigation process is systematic.
Missing webhooks
You issued a refund via the Stripe dashboard but your app never got the charge.refunded webhook. Result: Stripe shows the refund in balance transactions, but your order record still shows the full charge as settled.
Detection: Balance transaction refund with no matching order refund record.
Fix: Replay that webhook or manually update your order status. Log it so your monitoring catches pattern of missing webhooks (indicates a bug).
Manual refunds outside your system
Your support team refunds a customer directly in Stripe to resolve a dispute. Stripe records the refund immediately, but your app doesn't know about it until you reconcile.
Detection: Balance transaction refund where order refund record timestamp is much later (or doesn't exist).
Fix: Document in your reconciliation report. Implement Slack notifications to alert the team when manual refunds occur, so they can also update your system.
Fee discrepancies
Stripe applies a charge fee for every transaction. The fee appears as a separate balance transaction of type fee. You need to account for it in your revenue calculation:
async function calculateNetRevenue(payoutId) {
const balanceTxns = await stripe.balanceTransactions.list({
payout: payoutId,
});
let grossRevenue = 0;
let totalFees = 0;
for (const txn of balanceTxns.data) {
if (txn.type === 'charge') {
grossRevenue += txn.source; // Gross charge amount
} else if (txn.type === 'fee') {
totalFees += Math.abs(txn.net); // Fees are negative in net
}
}
const netRevenue = grossRevenue - totalFees;
console.log(`Gross: $${(grossRevenue / 100).toFixed(2)}`);
console.log(`Fees: -$${(totalFees / 100).toFixed(2)}`);
console.log(`Net: $${(netRevenue / 100).toFixed(2)}`);
return { grossRevenue, totalFees, netRevenue };
}
Fees can fluctuate based on payment method and dispute chargeback volume. Track them separately so your finance team understands where margin goes.
Partial refunds and disputes
A customer disputes a charge after paying. Stripe issues a chargeback, and the full amount (plus a chargeback fee) is reversed from your balance. Your order record shows the original charge succeeded, but the balance transaction shows a negative adjustment.
Detection: Balance transaction of type adjustment with negative amount and no corresponding order refund.
Fix: Query the dispute object from Stripe, understand the reason, and update your order status to disputed or chargebacked. Alert your team for manual review.
Automate daily: Building reconciliation reports
Manual spot-checking is fragile. Build a daily automated job that pulls all three data layers and flags mismatches. Here's the structure:
async function dailyReconciliation() {
const today = new Date();
today.setHours(0, 0, 0, 0);
// 1. Fetch all payouts for the past 2 days
const payouts = await stripe.payouts.list({
created: { gte: Math.floor(today.getTime() / 1000) - 86400 * 2 },
limit: 100,
});
const report = {
timestamp: new Date().toISOString(),
payouts: [],
summary: { matched: 0, mismatched: 0, missing: 0 },
errors: [],
};
for (const payout of payouts.data) {
const payoutReport = await reconcilePayout(payout.id);
// 2. For each balance transaction, match to order
for (const txn of payoutReport.balanceTransactions) {
let match;
if (txn.type === 'charge') {
match = await matchBalanceTxnToOrder(txn);
} else if (txn.type === 'refund') {
match = await matchRefund(txn);
} else {
match = { status: 'skipped', type: txn.type };
}
// 3. Store result in database
await db.reconciliationLog.insert({
payoutId: payout.id,
balanceTxnId: txn.id,
matchStatus: match.status,
details: match,
createdAt: new Date(),
});
// 4. Update summary
if (match.status === 'matched') {
report.summary.matched++;
} else if (match.status.includes('mismatch')) {
report.summary.mismatched++;
report.errors.push(match);
} else if (match.status.includes('missing')) {
report.summary.missing++;
report.errors.push(match);
}
}
report.payouts.push({
id: payout.id,
amount: payout.amount,
status: payout.status,
});
}
// 5. Send report to team
if (report.errors.length > 0) {
await sendSlackAlert(report);
}
return report;
}
// Run daily at 6 AM UTC
schedule.scheduleJob('0 6 * * *', dailyReconciliation);
Store reconciliation results in a database table so you can build dashboards. Track metrics: percentage matched, average time-to-match, common error types. Over time, patterns emerge that hint at systematic issues.
Common mistakes
- Comparing gross to net without accounting for fees: A $100 charge generates a $97.70 balance transaction. If you compare $100 to $97.70, you'll report a false mismatch. Always remember that balance transaction amounts are net of Stripe fees.
- Trusting webhooks alone: Webhooks can fail and retry unpredictably. A payout might include transactions your app never saw via webhook. Treat webhooks as a convenience, not as the source of truth. The source of truth is Stripe's balance transactions list.
- Running reconciliation too frequently: Balance transactions and payouts take time to settle. Running reconciliation every hour will generate noise. Stick to daily or even weekly. For near-real-time visibility, query balance transactions directly in your app; reserve reconciliation for audit.
- Ignoring pending transactions: A payout marked
pendingmight not include all its transactions yet. Wait forpaidstatus before reconciling. - Not handling currency conversion: If you operate in multiple currencies, ensure you're comparing like to like. Stripe balance transactions report in your platform currency; convert order amounts if needed.
Key takeaways
- Reconciliation is a three-layer match: payout → balance transactions → orders. Each layer catches different types of errors.
- Balance transactions are Stripe's ground truth. Always compare your order records against them, never the other way around.
- Automate reconciliation daily. Store results so you can track trends and spot systematic issues before they become audit findings.
- Account for fees explicitly. They reduce your net revenue and must be tracked separately for accurate P&L.
- Investigate gaps systematically. Missing webhooks, manual refunds, and chargebacks are common—build processes to handle them.
- Use reconciliation as an early warning system. Mismatches often signal bugs in your payment flow before customers complain.






Comments
Loading comments…