Stripe's docs are excellent until your first enterprise buyer asks for annual invoicing, seat changes mid-cycle, and audit logs. Up to that point, most teams integrate Checkout, wire up a webhook or two, and billing feels solved. Then a procurement team sends back a contract with payment terms that don't map cleanly onto a subscription object, and the gap between "Stripe is integrated" and "billing actually works for this customer" becomes obvious fast. These patterns keep billing logic maintainable as that gap gets closed.
One source of truth in your database
Stripe holds payment state; your app holds product state. Those are two different systems of record, and the moment a team starts treating Stripe's API as the live source of truth for what a customer is entitled to, things get fragile — every page that needs to know a user's plan or seat count now depends on a live API call succeeding, with no clear story for what happens when it doesn't.
We mirror subscription IDs, plan tiers, and seat counts locally and reconcile via webhooks — never by trusting client-side checkout success alone. The client-side "success" redirect after Checkout tells you the browser reached the success URL. It does not tell you the payment cleared, that the subscription is active, or that nothing failed downstream. Relying on it to unlock a feature is a race condition waiting to happen: a user can land on the success page and refresh a dashboard that still shows them as unpaid, or — in the more damaging direction — get access granted client-side for a payment that later fails or gets disputed.
The webhook is the actual source of truth for "did this billing event really happen." The local mirror exists so the rest of the application never has to reach out to Stripe just to answer "what plan is this account on" — that question gets answered from your own database, fast and consistently, while Stripe stays the system that owns the payment lifecycle itself.
Webhooks are idempotent
The same invoice.paid event may arrive twice. This isn't a hypothetical edge case — Stripe's delivery guarantee is at-least-once, not exactly-once, and retries happen for reasons that have nothing to do with your application: a timeout on Stripe's side, a slow response, a deploy that briefly drops a request. Handlers that don't account for this will occasionally grant a seat twice, extend a trial twice, or fire a "subscription renewed" notification email twice — small bugs individually, but the kind that erode trust with a customer who notices.
Handlers check event IDs or use idempotency keys before mutating seats or unlocking features, so a duplicate delivery is a no-op rather than a double-charge in effect. We also log unhandled event types instead of silently ignoring them. Stripe adds event types over time, and a webhook handler with an unlogged default case means a new event type — one that might matter, like a disputed charge or a canceled subscription — passes through unnoticed until a customer reports something that doesn't match what your system shows.
Proration rules are product decisions
Whether upgrades bill immediately or at period end is not a Stripe setting — it's a product rule, and it needs to be decided before the integration is built, not discovered by whichever engineer happens to be looking at the Stripe dashboard when a customer asks about their invoice. Stripe supports several proration behaviors, and each one has real implications for cash flow, customer expectations, and support load. Bill immediately on upgrade and customers occasionally push back on being charged mid-cycle for something they expected to take effect at renewal. Defer to period end and a customer who upgrades expecting instant access to a higher tier's limits can be confused when the invoice doesn't reflect it yet.
Neither choice is universally correct — it depends on the product and the customer base — but the choice has to be made deliberately and applied consistently. We document it in the PRD and encode it once in a billing service, not scattered across Route Handlers where three different endpoints might implement three subtly different proration behaviors because three different engineers made three independent judgment calls under deadline pressure. A single billing service as the only path to Stripe's subscription-mutation endpoints means the rule only needs to be right once.
Failed payments need UX, not just emails
Dunning emails help — Stripe's built-in retry and reminder sequence catches a meaningful share of failed payments caused by expired cards or temporary insufficient funds. But an email-only strategy leaves the customer with no way to see what's actually happening to their account short of digging through their inbox, and it leaves your product with no graceful way to respond while the retries are in flight.
The portal should show payment status plainly — not just "active" or "inactive" but "payment failed, retrying," so a customer who logs in isn't surprised. It should offer a direct path to update the card on file without needing to find the right email and click through it. And access should degrade according to a documented policy rather than an abrupt cutoff: read access preserved while write access is paused, for example, or a grace period before hard suspension, matching what was actually promised in the contract or terms of service rather than whatever the code happened to do by default.
Getting this right before support tickets pile up matters because a failed payment is often not a customer's fault — cards expire, banks flag unusual charges, corporate cards get reissued during a fraud sweep — and a harsh experience around a routine card update is a disproportionately bad way to lose a paying customer over something that had nothing to do with dissatisfaction with the product.
Test clocks in staging
Stripe test clocks let us simulate renewals and failures without waiting thirty days for a real billing cycle to elapse. Before test clocks existed, testing a renewal, a proration edge case, or a multi-cycle dunning sequence meant either waiting out real time in a test environment or writing ad hoc backdating hacks against Stripe's API that behaved differently from the real event flow.
We run test clocks before every major billing change — a new plan tier, a change to proration behavior, a modification to the dunning policy — because billing logic that looks correct in code review can still fail in ways that only show up across a multi-event sequence: a webhook that assumes events arrive in a particular order, a seat count that drifts after several consecutive proration events, a dunning email that fires on the wrong day relative to the actual retry schedule. Compressing thirty days of billing lifecycle into a test run that finishes in minutes is the difference between catching that class of bug before launch and finding it from a confused customer's support ticket weeks later.
What we deliberately don't build
Not every billing feature Stripe exposes needs a custom implementation on top of it, and knowing what to leave alone is as much a part of a maintainable integration as knowing what to wrap. We generally don't build custom invoice PDF rendering when Stripe's hosted invoices cover the requirement, don't build a parallel payment method vault when Stripe's is already PCI-compliant and battle-tested, and don't build custom tax calculation logic before checking whether Stripe Tax already covers the jurisdictions in question. Every one of those is a place where a thin wrapper is enough, and building more is just surface area to maintain without a corresponding benefit.
The discipline is knowing where the line sits: wrap and own the pieces that encode product-specific decisions — proration rules, entitlement logic, degradation policy — and leave alone the pieces that are genuinely commodity infrastructure Stripe already handles well.
Billing integrations are integration projects, not checkbox tasks. Treating the first successful test charge as "done" is how teams end up rebuilding this layer eighteen months later under pressure from an enterprise deal that billing can't support. A thin, well-tested layer around Stripe — one source of truth, idempotent webhooks, documented proration rules, real failed-payment UX, and test clocks run before every change — saves years of spreadsheet reconciliation later.
