A practical guide to implementing Stripe billing in a SaaS product — covering subscription models, metered usage, free trials, dunning, proration, and the specific edge cases that break billing implementations and cause revenue leakage.
Billing is the one part of your product that directly touches revenue. Get it wrong and you lose money silently — failed payments that never retry, plan changes that prorate incorrectly, trials that don't convert because the confirmation email wasn't sent. Unlike a broken feature that users complain about, broken billing often fails without visible symptoms until you audit your numbers months later.
This guide covers the implementation patterns that work and the specific edge cases that break billing in production. Not a tour of the Stripe documentation — Stripe's docs are excellent. This is what you need to know that the docs don't emphasize.
One price, recurring monthly or annually. The simplest model and still the most common in B2B SaaS.
In Stripe: a Product represents what you're selling, a Price represents the billing interval and amount, and a Subscription ties a Customer to a Price. The implementation is straightforward: create the subscription when the customer completes signup, handle the customer.subscription.created webhook to provision access, and listen for invoice.payment_failed to handle payment failures.
The edge cases still exist even here. Trials, plan changes, and failed payments all need explicit handling — which we'll cover below.
A recurring subscription where the quantity scales with the number of users. The price is per seat per month; the subscription quantity is the current seat count.
In Stripe: use Subscription with a quantity parameter. When a customer adds a user, call stripe.subscriptions.update to increment the quantity. When they remove a user, decrement it. Stripe prorates the difference automatically (if you configure it to).
The subtlety: decide whether seat additions bill immediately (prorated charge for the remainder of the current billing period) or at the start of the next period. Most SaaS products charge immediately for additions (you're unlocking access now) and bill removals at the end of the period (you've already paid for this month). This needs to be explicit in both your Stripe configuration and your customer-facing terms.
The customer pays for what they consume — API calls, active users, compute time, storage. The price is unknown until the end of the billing period.
In Stripe: use Stripe Meters. Define the meter (what unit you're counting), report usage events via stripe.billing.meters.createEvent() as they happen in your application, and attach the meter to a price in your subscription. Stripe aggregates usage per billing period and generates the invoice automatically.
This is the most complex model to implement correctly. The reporting pipeline needs to be reliable — usage events that fail to reach Stripe mean you don't bill for that usage. A queue-based approach (write usage to your database, a background job sends it to Stripe, with retries on failure) is more reliable than reporting usage inline in the request path.
Webhooks are the foundation of correct billing. Not an integration option — the core mechanism.
Stripe webhooks are HTTP POST requests that Stripe sends to your application when billing events happen. Payment succeeds, subscription cancels, invoice generates, trial ends — these all arrive as webhooks. If your application doesn't handle them, billing state in your database falls out of sync with billing state in Stripe.
The architecture that works in production:
/api/webhooks/stripe. Verify the signature on every request. Return 200 immediately.200 response (or receive a 5xx). Your handlers will receive the same event multiple times. Use the Stripe event ID as an idempotency key — check whether you've already processed this event before doing work.The events you must handle without exception: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed, and customer.subscription.trial_will_end.
A payment fails. What happens next?
Stripe has built-in retry logic (Smart Retries — it uses ML to pick retry times with higher success rates). Configure this in your Stripe dashboard. But retries alone aren't enough. You need a dunning flow: a sequence of emails over the retry window telling the customer their payment failed and asking them to update their card.
The hard part is access revocation. Do you immediately revoke access when payment fails, or after the retry window expires? Most SaaS products give a grace period (7–14 days) before revoking access, with escalating communication. This needs to be explicit in your code — the invoice.payment_failed webhook starts the grace period clock; the customer.subscription.deleted event (after Stripe finally gives up) ends it.
Without handling this explicitly, failed payments either silently accumulate (customer never hears from you, keeps using the product for free) or immediately revoke access on a transient payment failure (aggressive, causes churn).
A customer on your $49/month plan upgrades to the $99/month plan on day 15 of their billing cycle.
Stripe can handle proration automatically. But you need to decide: charge the prorated difference immediately, or apply it to the next invoice? The Stripe default is immediate proration with an immediate charge. Some SaaS products prefer to credit the unused portion of the current plan and apply it to the next invoice instead.
Set proration_behavior explicitly when updating subscriptions. The options are create_prorations (immediate charge), none (no proration, full new price next cycle), or always_invoice (creates a proration invoice immediately).
A customer downgrades from $99 to $49. Unlike upgrades, immediate access reduction is usually wrong — they paid for the full month at the higher tier.
Set proration_behavior: 'none' on downgrades and schedule the price change to take effect at the end of the current billing period using billing_cycle_anchor or subscription schedule items. The customer keeps their current plan until renewal, then drops to the lower tier.
If you implement this incorrectly, you either credit the customer nothing on the downgrade (charging them for features they no longer have access to) or issue an immediate credit and immediately revoke access (which feels punishing).
Free trials are standard SaaS practice. The webhook you must not miss: customer.subscription.trial_will_end — Stripe sends this 3 days before the trial ends. Use it to send a conversion email.
When the trial ends, Stripe automatically attempts to charge the payment method on file. If none exists, the subscription goes into incomplete status. You need to handle this: prompt for payment before trial end, and handle the state where trial expired without a payment method.
The invoice.payment_succeeded event after trial expiry is your signal to move the customer from trial to active status in your database. Do not rely on the subscription status field alone for this — check the invoice.
A customer on annual billing wants to switch to monthly. This is operationally messy.
They've paid for the full year. Do you refund the unused months and switch immediately? Or run out the year and switch at renewal? Most SaaS products do the latter (simpler, less accounting). Be explicit in your UI about which they're getting.
If you do immediate switches, you're looking at partial refunds via stripe.refunds.create and a new subscription. The Stripe subscription objects don't cleanly handle mid-year plan interval changes — you often end up canceling and recreating the subscription. Test this flow thoroughly.
Per-seat products have this constantly. A team admin adds 3 users and removes 2 in the same week.
Use Stripe's quantity update. But track the subscription quantity in your database independently so you can reconcile. If a webhook fails to deliver and your database says 10 seats but Stripe says 8, the next time you try to update quantity you'll send an incorrect value.
Keep your source of truth in Stripe for billing, your database for access control, and a reconciliation job that runs daily to detect divergence.
Stripe refunds are straightforward via stripe.refunds.create. The complication is how you represent them in your product and your accounting.
For partial refunds (discounts, goodwill credits), prefer Stripe Customer Balance or Stripe Coupons over manual refunds. They apply cleanly to future invoices and don't create complex reconciliation problems.
For full refunds on an annual plan where the customer wants out early, decide your policy before you need it. "No refunds after 14 days" or "prorated refunds" — either is fine, but it needs to be in the code before a customer asks.
Every Stripe invoice has line items. Those line items need to be reflected in your database — not just "customer paid $99" but what they paid for, when, and what period it covers.
This matters for: support ("did this charge cover their January billing period?"), accounting (revenue recognition), and analytics (MRR calculations). Store the Stripe invoice ID, subscription ID, period start, period end, and line items. The invoice.payment_succeeded webhook gives you everything you need.
| Tool | Use when | Avoid when |
|---|---|---|
| Stripe Billing | Your primary billing for almost all SaaS | You need complex usage models that Stripe Meters can't represent |
| Paddle | You need a merchant of record (Paddle handles tax, compliance, payouts for 200+ countries) | You're US-only and comfortable handling your own tax logic |
| Lago | Open-source billing, complex usage pricing is your core model, you want to self-host billing infrastructure | You don't want to maintain billing infrastructure |
| Orb | Usage-based is your primary model and you need flexible aggregation logic (sum, max, unique count, custom) | Flat-rate subscriptions — overkill and more expensive |
For most SaaS products: Stripe Billing. Paddle if you're selling globally and want tax handled for you. Lago or Orb only if usage-based pricing is genuinely complex (multiple meters, tiers, custom aggregation) and Stripe Meters can't model it.
The most common mistake: testing billing manually through the Stripe dashboard. This doesn't scale and doesn't catch the edge cases.
Stripe test mode gives you test card numbers, test webhooks, and test customers. All your CI tests should run in test mode with a test Stripe account. Use the Stripe CLI (stripe listen --forward-to localhost:3000/api/webhooks/stripe) to forward webhooks to your local development environment.
Test Clock is the feature most billing implementations don't use but should. A Test Clock is a simulated time object you attach to a test customer. You can advance it forward — "simulate that 30 days have passed" — and watch subscription renewals, trial expirations, and invoice generation happen in your test environment. This is the only reliable way to test time-dependent billing flows without waiting for them to occur naturally.
Cover these scenarios in your test suite with Test Clocks:
For the full picture of what goes into a B2B SaaS MVP beyond billing — including architecture, multi-tenancy, and what to build in which order — see building a B2B SaaS MVP. For multi-tenant architecture specifically, multi-tenant SaaS architecture covers the row-level security vs. schema-per-tenant decision.
Billing is the part most development teams underestimate. Hunchbite builds SaaS products with Stripe Billing implemented correctly — subscription management, usage tracking, dunning flows, and webhook architecture that doesn't leak revenue.
Call +91 90358 61690 · Book a free call · Contact form
If this guide resonated with your situation, let's talk. We offer a free 30-minute discovery call — no pitch, just honest advice on your specific project.
How to set up Drizzle ORM with PostgreSQL from scratch — schema definition, migrations, query patterns, connection pooling, and the configuration decisions that matter in production Next.js applications.
11 min readguideA technical guide to database indexes: B-tree internals, composite index column ordering, covering indexes, partial indexes, the write cost of over-indexing, EXPLAIN ANALYZE interpretation, and the common indexing mistakes that degrade production performance.
14 min read