/KEY TAKEAWAYS
The short version
- 01Idempotency keys are not optional. A retried webhook that creates a second invoice is not a rare edge case. It is what happens the first time your payment processor has a network hiccup.
- 02Stripe bundles billing logic with payment processing. Lago separates the two, handling subscriptions, metering and invoicing on its own, then delegating collection to whichever gateway you choose.
- 03Lago's event-based architecture is built to ingest tens of thousands of billing events per second, which starts to matter the moment your pricing is usage-based instead of seat-based.
- 04Your core product database and your billing state should never be the same tables. Billing drift, where the two disagree about what a customer owes, is a data isolation failure before it is a support ticket.
- 05Proration is the feature every billing engine gets wrong first. Test it explicitly, because an upgrade mid-cycle is the code path nobody writes a test for until a customer complains.
Billing is the one part of a SaaS product where a bug does not just look bad. It moves money incorrectly, and somebody notices within a billing cycle, in writing, usually copying your CEO.
How do you design database schemas to support seat-based and usage-based billing?
Start with three separate tables and resist merging them for convenience. A subscriptions table holding plan, seat count and the effective date range each seat count was valid for. An append-only usage_events table, one row per billable action, each carrying an idempotency key so a retried request cannot double count. And an invoices table that is generated from the other two, never edited by hand, and never trusted as a source of truth for what a customer currently owes.
Seat-based pricing looks simple until someone downgrades mid-cycle. Model seats as a history, not a single column, or your proration math will be reconstructing a story your schema never actually recorded.
FIG/02Why should billing state live outside your core application database?
Because the two systems change at different speeds and answer to different authorities. Your core database reflects what your product believes is true right now. Your billing state has to reflect what a payment processor believes happened, which arrives asynchronously, out of order, and occasionally twice.
When those two live in the same tables, a schema migration on your product can silently corrupt a financial record, and a billing retry can silently corrupt a feature flag. Neither team wants to own that blast radius. Isolating them means a bug in your onboarding flow cannot double-charge a customer, and a billing webhook that arrives twice cannot accidentally grant someone a feature they have not paid for.
The two do need to agree eventually. Reconcile them on a schedule, not on faith.
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
// Stripe will retry a webhook that times out or 5xxs.
// The event id is the idempotency key, not a UUID we invented.
const alreadyProcessed = await db.webhookEvent.findUnique({
where: { stripeEventId: event.id },
});
if (alreadyProcessed) {
return new Response(null, { status: 200 });
}
await db.$transaction(async (tx) => {
await tx.webhookEvent.create({ data: { stripeEventId: event.id } });
if (event.type === "invoice.paid") {
const invoice = event.data.object;
await tx.subscription.update({
where: { stripeCustomerId: invoice.customer as string },
data: { status: "active" },
});
}
});
return new Response(null, { status: 200 });
}The webhookEvent check and the transaction wrapping it are the entire point. Without them, a single Stripe retry during a slow deploy can flip a subscription active twice and desync your ledger.
How do you handle prorated subscriptions without corrupting billing state?
Proration is arithmetic on time, and time is the field engineers get wrong most often. An upgrade on day twelve of a thirty-day cycle owes a credit for the unused portion of the old plan and a charge for the remaining portion of the new one. Compute both from the same effective-dated seat history mentioned earlier, never from whatever the subscription table currently says, because currently is a moving target while the calculation runs.
Write the credit and the charge as two separate line items on the next invoice, not a single netted number. Support needs to explain the charge to a confused customer, and a netted figure is much harder to defend than two lines that each make sense on their own.
Test the downgrade path with the same seriousness as the upgrade path. It is the one nobody writes a test for, and it is the one that generates the angriest support ticket when it is wrong.
FIG/03/TRADE-OFFS
Stripe Billing vs Lago
Not a question of which is better. A question of whether payment processing and billing logic should be the same decision.
| Criterion | Stripe Billing | Lago |
|---|---|---|
| Architecture | Billing logic bundled tightly with payment processing. | A standalone billing engine that delegates collection to Stripe, Adyen, GoCardless or others. |
| Usage-based metering | Supported, but the implementation gets complex fast for granular per-unit pricing. | Purpose-built for it, ingesting tens of thousands of events per second. |
| Cost structure | A percentage of the revenue that flows through it. | You pay for the infrastructure you run, not a cut of revenue. |
| Data residency and control | Billing data lives in Stripe's systems. | Self-hosted, so billing data stays inside infrastructure you control. |
| Payment gateway flexibility | Stripe, by design. | Any supported gateway, chosen independently of the billing logic. |
| Setup effort | Fastest path to a working checkout for most early-stage teams. | More setup, including running and operating the service yourself. |
| Vendor lock-in | Billing logic and payment processing are difficult to separate later. | Payment gateway is swappable without touching billing logic. |
| Best fit | Simple, seat-based or tiered pricing without heavy engineering investment. | Granular usage-based pricing, strict data residency needs, or wanting full control of pricing workflows. |
Should you build custom billing middleware or rely on off-the-shelf Stripe integrations?
For most teams under a few hundred paying accounts with simple, seat-based pricing, the off-the-shelf path wins. Stripe's tooling is mature, well documented, and gets you a working checkout in days rather than weeks. Building your own middleware to compete with that is usually a bad use of engineering time early on.
The calculation changes once pricing becomes genuinely usage-based, with per-unit metering that has to reconcile against a large volume of events, or once contractual customers demand invoicing terms Stripe's model was not built for. At that point a thin middleware layer, or a dedicated billing engine like Lago sitting in front of a payment gateway, starts paying for itself.
The decision that actually matters is not which vendor. It is whether your database schema keeps billing logic separable from payment processing from day one, so the choice can change later without a rewrite.
What does a resilient billing pipeline actually require?
- Every webhook handler checks an idempotency key before it changes state, not after.
- Subscriptions, usage events, and invoices live in separate, append-friendly tables, never edited by hand.
- Seats and plan changes are tracked with effective date ranges, not overwritten in place.
- Proration produces two visible line items, a credit and a charge, never a single netted number.
- Billing state and core product state reconcile on a schedule, not on the assumption that both sides agree.
/FAQ
Questions tech leads actually ask about billing architecture
- How do you design database schemas to support seat-based and usage-based billing?
Keep subscriptions, usage events, and invoices in three separate tables. Track seat counts and plan changes as an effective-dated history rather than a single overwritable column, and make usage events append-only with an idempotency key on each row so a retried request cannot be double counted.
- Should you build custom billing middleware or rely on off-the-shelf Stripe integrations?
Off-the-shelf Stripe integrations are the right default for simple, seat-based pricing at smaller scale. Custom middleware, or a dedicated billing engine like Lago, earns its cost once pricing becomes genuinely usage-based or contractual customers need invoicing terms the default tooling was not built for.
- Why do idempotency keys matter for payment webhooks?
Payment processors retry webhooks that time out or fail, which is normal and expected behavior, not a rare edge case. Without an idempotency check, a single retry during a slow deploy can create a duplicate invoice or flip a subscription state twice.
- Should billing data live in the same database as the core application?
No. The two systems change at different speeds and answer to different sources of truth. Isolating them means a product migration cannot corrupt a financial record, and a duplicate billing event cannot silently grant a feature nobody paid for.
- How should proration credits and charges appear on an invoice?
As two separate line items, a credit for the unused portion of the old plan and a charge for the new one, rather than a single netted figure. Two lines are easier for support to explain and easier for a customer to trust.
- What is the main architectural difference between Stripe Billing and Lago?
Stripe bundles billing logic with payment processing. Lago separates them, running subscriptions, metering and invoicing as its own layer and delegating actual payment collection to whichever gateway you choose, which keeps the payment provider swappable later.

