Tech Stack31 May 2026 · 9 min read

Stripe Subscription Billing for SaaS: Full Production Setup in 2026

A production-grade Stripe subscription billing setup for SaaS: webhook idempotency, proration edge cases, trial logic, dunning, and annual invoicing patterns from two live products.

Stripe Subscription Billing for SaaS: Full Production Setup in 2026

I have wired up stripe subscription billing for SaaS on two production products — BookBed launched with a working Stripe subscription flow before its first paying customer — and the honest summary is that Stripe is wide. Most tutorials cover the happy path. The edges are where your billing incidents live.

This post covers a complete production setup: Checkout vs Elements, webhook architecture, proration edge cases, trial logic, dunning, Stripe Tax, and annual invoicing. Grounded in what I actually built, not what I would build with unlimited time.

Checkout vs Elements: Pick the Entry Point for Your Stage

Abstract recurring cycle diagram with neon cyan subscription nodes on dark background

Stripe Checkout is a hosted payment page. You create a session server-side, redirect the user to Stripe's domain, and Stripe handles card validation, 3DS2, Apple Pay, international cards, and tax collection. Elements puts the payment form fields directly inside your UI. You own the layout; Stripe owns the mechanics.

For most SaaS sign-up flows at the early stage, Checkout wins. You get 3DS2 compliance, localization, and a form Stripe iterates on continuously. The trade-off is visual control: you can theme it, but the user is on stripe.com during checkout. If your conversion funnel is already proven and you want full ownership of the sign-up page, move to Elements. Before that, it is a rebuild that does not move the real needle.

Let me back up on one thing. The Checkout-to-Elements migration is not particularly hard. Launching on Checkout and moving later is a rational path. What you do not want is building a full Elements integration in week two of a twelve-week MVP because it feels more polished, before you have a single paying customer to validate the assumption.

The Webhook Handler Is Your Billing Integration

The webhook handler is the most important file in a Stripe setup. Every billing state change — subscription created, payment failed, invoice paid, subscription cancelled — flows through it. If it processes events twice, customers get double-provisioned. If it drops events, subscriptions end up in stale states nobody notices until a customer calls.

A Stripe webhook handler needs three things to be production-grade.

Signature verification on the raw request body. Not the parsed JSON: the raw bytes, exactly as Stripe signed them. In Next.js App Router that means calling req.text() before passing the body to stripe.webhooks.constructEvent. If your framework parses the body upstream, the signature check fails and every webhook returns 400.

Idempotency by event ID. Stripe retries deliveries when your endpoint returns non-2xx or times out. If your handler is not idempotent, retries process the same event twice. Fifteen events, all duplicated — that is what an unidempotent handler in production looks like on a Monday morning. The fix is a webhook_events table: check whether the event ID already exists before doing any work, return 200 if it does.

A dead-letter queue for failed processing. Some events will fail despite a 200 response: a downstream service is unreachable, a race condition in your state machine, a Postgres constraint violation mid-transaction. Log those event IDs for manual replay. Without this, you discover missed events when customers call to say they are still on the free tier after paying.

The Stripe integration reference covers all three patterns. Most SaaS billing implementations I have reviewed in production cover one of them.

Proration in Stripe Subscription Billing: What Actually Happens

When a user upgrades mid-billing-cycle, Stripe prorates by default. That sentence is accurate. The behavior depends entirely on how you configure proration_behavior on the subscription update, and the options diverge in ways that surprise founders.

create_prorations generates a proration invoice item immediately — the user gets a credit for unused time and is charged for the upgrade in the same billing event. always_invoice creates and immediately attempts to collect the proration. none skips the credit entirely and charges the full new amount at the next renewal.

For monthly plans, create_prorations with billing_cycle_anchor unchanged is usually right: the credit and the new charge both land on the next invoice, no mid-cycle surprise. For annual plans, none is often correct — a user on a 12-month contract does not expect a charge the day they change their plan tier.

Know which behavior your pricing page implies before you touch configuration. The support ticket you do not want: "I upgraded my plan and was charged again immediately."

The Annual Plan Promo Code Trap

Here is how that went on Callidus.

Callidus has a price ID allowlist for annual plans. Why: a customer used a 100%-off promo code on the annual plan in test mode and I almost let that ship. Now any annual price IDs are explicitly whitelisted against promo code application.

The dashboard fix is a Promotion Code restriction — you can scope promo codes to specific products or price IDs. But test mode and live mode maintain separate promo code configurations, and it is easy to verify the restriction in one environment without replicating it in the other. The allowlist in application code is the belt-and-braces layer that does not depend on dashboard configuration being identical across environments.

Trial Logic and the Credit Card Question

Two Stripe trial patterns: trial_period_days on the subscription, and a free-tier price that converts to a paid price when the trial ends.

Trial period days is simpler. Stripe creates the subscription in trialing status, then attempts the first charge at the end of the trial. If you want credit-card-optional trials, you need a free price or a setup intent flow to collect payment details without an immediate charge.

Have you landed on a product you wanted to try and walked away because it asked for a card upfront? The opt-in trial reduces that sign-up friction but complicates conversion: users who never entered a card are harder to recover after trial expiry. Opt-out trials (card required at sign-up) convert better for B2B SaaS where users are making a deliberate purchasing decision. For consumer SaaS, requiring a card at sign-up reduces trial starts enough that the math often inverts.

Neither is universally correct. That decision belongs to your conversion data, not your Stripe configuration.

Customer Portal for Self-Serve Billing

Stripe's Customer Portal handles plan changes, payment method updates, cancellations, and invoice history. Configure it in the Stripe dashboard, create a portal session server-side using the customer's Stripe ID, and redirect the user. The implementation is three lines. The configuration is where attention is required.

For the Supabase and Stripe stack, the common pattern is a stripe_customer_id column on your users or tenants table, looked up when creating the portal session. Gate the portal link behind a billing-role check — not every seat-holder should be able to cancel the subscription, only the account owner.

The configuration trap: the portal uses your live mode settings. Test mode configuration is separate, and if you configure the portal in test mode and forget to replicate in live, production users will see different options than what you tested. Always verify in live which plan changes are allowed, whether cancellation is immediate or end-of-period, and whether users can pause.

Dunning: Recovering Failed Subscription Renewals

Failed renewals are not an edge case. At meaningful subscription volume, a few percent of renewal attempts fail every month — expired cards, bank declines, temporary holds. Stripe's Smart Retries will attempt payment again at optimal intervals, but your application needs to handle the state transitions and drive the dunning sequence.

Saturday at 11pm: Stripe fires customer.subscription.updated with status past_due. Your webhook handler marks the subscription, and from that point the application should show a payment-update banner on every authenticated page. The dunning email sequence starts at day 0, continues at day 3, and sends a final notice before automatic cancellation.

B2B products with enterprise customers typically extend the grace period to 14 days or more — payment teams at larger companies sometimes need time to process a new corporate card. Configure the cancellation window in your Stripe billing settings to match what your customer agreement implies.

One event most handlers miss: customer.subscription.deleted fires for hard-deleted subscriptions, which is separate from the status moving to canceled via customer.subscription.updated. Handle both events and route them to the same mark-subscription-inactive branch. If you only handle status transitions, the immediate-deletion case goes unprocessed until a customer calls to say they cancelled and still have access.

Annual Invoicing for Enterprise Contracts

Annual contracts often need invoices sent to finance teams, not automatic card charges. The pattern: create the subscription with collection_method set to send_invoice and days_until_due set to 30. Stripe creates the invoice; you trigger the send. The customer's finance team gets an email with a pay-now link.

The webhook handling gets more complex here. You need to process invoice.payment_action_required when additional authentication is needed, invoice.overdue when the due date passes, and invoice.paid when payment clears — all separate from the automatic-collection event flow. Missing one of these means a customer's annual invoice sits unpaid in a spam folder for three weeks while your system assumes it has been collected.

BookBed sidesteps this entirely: all plans use monthly billing with an annual prepay discount applied as a coupon rather than a separate price ID. One billing model, one webhook handler, no finance-team invoicing complexity. Whether that trade-off fits your product depends on whether your enterprise customers' finance teams actually need formal invoices. Many do not, and the ones that do will tell you clearly.


The stripe subscription billing for SaaS setup that holds under production load is disciplined, not complicated. Signature verification, idempotency by event ID, a dead-letter queue, correct proration behavior per plan type, and a dunning sequence that catches failed payments before they become churned customers.

Pick the one component your current implementation is missing and add it this week. Probably the idempotency check.

DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Senior full-stack developer shipping SaaS MVPs, web apps, and mobile apps 2× faster than agencies using AI-augmented workflows. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do I set up Stripe webhooks in Next.js?

In Next.js App Router, create a route handler at app/api/webhooks/stripe/route.ts. Read the request body with req.text() before parsing — Stripe signs the raw bytes, so parsing first breaks signature verification. Call stripe.webhooks.constructEvent with the raw body, the stripe-signature header, and your webhook secret. Then add idempotency: check a webhook_events table for the incoming event ID before processing. If you have already processed that event, return 200 immediately. This protects you from Stripe's automatic retries duplicating state changes in your application.

How do I set up the Stripe Customer Portal for a SaaS application?

Enable the Customer Portal in your Stripe dashboard and configure which actions users are allowed to take — plan changes, cancellations, payment method updates. Server-side, create a portal session using stripe.billingPortal.sessions.create with the customer's Stripe ID and a return URL, then redirect the user to the session URL. The key thing to know: portal configuration is separate for test mode and live mode. Configure both, and test against a test-mode configuration that mirrors your live settings so production users see exactly what you expect.

What is the best approach for SaaS billing with Stripe?

Start with Stripe Checkout for sign-up — you get 3DS2, international cards, and tax collection without building the form yourself. Use a webhook handler with signature verification and idempotency for all billing state changes. Choose proration behavior that matches what your pricing page implies: create_prorations for monthly plans, none for annual. Add a dunning email sequence that starts when a subscription moves to past_due, not after it is cancelled. Gate the Stripe Customer Portal behind a billing-role check so only account owners can manage the subscription.

How does Stripe subscription proration work?

When a user upgrades or downgrades mid-billing-cycle, Stripe calculates a prorated credit for the unused portion of the current plan and a prorated charge for the remaining time on the new plan. The behavior is controlled by proration_behavior on the subscription update. create_prorations applies the credit and charge immediately. none skips the proration entirely. always_invoice creates and immediately collects a proration invoice. For monthly SaaS plans, create_prorations with billing_cycle_anchor set to unchanged means the user sees both amounts on their next invoice with no surprise mid-cycle charge.

What Stripe webhook events should a SaaS application handle?

The minimum set for a subscription SaaS: customer.subscription.created and customer.subscription.updated to track status changes (trialing, active, past_due, canceled), customer.subscription.deleted for hard-deleted subscriptions, invoice.payment_succeeded and invoice.payment_failed for payment outcomes, and customer.subscription.trial_will_end to trigger trial-ending emails three days before expiry. If you support annual contracts with send_invoice billing, also handle invoice.payment_action_required, invoice.overdue, and invoice.paid. Handle both subscription.updated and subscription.deleted for cancellations — they fire in different scenarios and you need both to catch every cancellation path.