Stripe Subscription Billing: Implementation Guide for Developers
Stripe's subscription APIs are powerful but have real complexity traps. Here's an implementation guide covering the edge cases that matter in production SaaS billing.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Stripe Is Not Plug-and-Play
Stripe's documentation is excellent, the API is well-designed, and the developer experience is genuinely good. But implementing subscription billing for a SaaS product is not a weekend project. The edge cases — trial upgrades, prorations, failed payments, seat changes mid-billing-cycle, coupon stacking — accumulate into a system that requires careful design from the start.
This guide covers what I've learned building Stripe billing integrations for multiple SaaS products: how to model the data, which webhooks matter, and the specific edge cases that will bite you in production if you don't plan for them.
Data Model: Mirror Stripe in Your Database
The most common mistake I see in SaaS billing implementations is treating Stripe as the source of truth for subscription state and querying the Stripe API at runtime to check whether a customer is subscribed. This is slow, creates a Stripe API dependency in your request path, and fails when Stripe has an outage.
Your database should mirror the subscription state, updated via webhooks. Here's the minimum data model:
-- customers
id, user_id, stripe_customer_id, created_at
-- subscriptions
id, customer_id, stripe_subscription_id,
status, plan_id, current_period_start, current_period_end,
cancel_at_period_end, canceled_at, trial_end, created_at, updated_at
-- subscription_items
id, subscription_id, stripe_subscription_item_id,
stripe_price_id, quantity, created_at
-- invoices
id, customer_id, subscription_id, stripe_invoice_id,
status, amount_due, amount_paid, currency, period_start, period_end,
paid_at, created_at
With this model, checking whether a user is subscribed is a local database query: WHERE subscriptions.status = 'active' AND subscriptions.customer_id = ?. No Stripe API call required.
Creating the Subscription Flow
The standard flow for a new subscriber:
- Create a Stripe customer on user signup (or at first payment intent). Store the
stripe_customer_idon your user/customer record. - When the user selects a plan, create a Checkout Session or PaymentIntent. For most SaaS products, Stripe Checkout is the right choice — it handles tax calculation, 3DS authentication, and currency display out of the box.
- On successful payment, Stripe fires
checkout.session.completedandinvoice.paidwebhooks. Your webhook handler creates the subscription record in your database. - For feature gating, check your local subscription record — not Stripe's API.
Webhooks: The Events That Matter
Stripe communicates subscription state changes via webhooks. You need to handle these events reliably:
customer.subscription.created — New subscription created. Mirror to your database.
customer.subscription.updated — Subscription changed (plan upgrade/downgrade, trial extended, quantity changed). Update your local mirror.
customer.subscription.deleted — Subscription cancelled (either immediately or after period end has passed). Update status to canceled.
invoice.paid — Payment succeeded. Mark the invoice as paid, update current_period_end.
invoice.payment_failed — Payment failed. This begins the dunning process. Your product should show the user a payment update prompt. Stripe will retry based on your dunning configuration.
invoice.payment_action_required — 3DS authentication required. The user needs to complete a step before payment processes.
customer.subscription.trial_will_end — Fires 3 days before trial end. Good trigger for an in-app and email reminder.
Webhook reliability is critical. Stripe can fire the same event multiple times, and events can arrive out of order. Your webhook handler must be idempotent — process the same event twice and the database state should be the same as processing it once. Use Stripe's event ID to detect duplicates.
Plan Changes and Prorations
When a customer upgrades or downgrades mid-billing cycle, Stripe calculates a prorated credit or charge. This is the right behavior, but you need to handle it correctly.
For upgrades, the default is to immediately apply the new plan and charge the prorated difference. This is usually what you want — the customer gets the new features immediately and pays for the remaining days at the new rate.
For downgrades, I recommend scheduling the change to take effect at the end of the current billing period rather than immediately. Stripe supports this via proration_behavior: 'none' combined with a scheduled subscription update. This avoids the awkward UX of issuing a credit and then charging less next month — instead, the customer just pays the new amount on their next renewal.
// Schedule downgrade for end of period
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'none',
billing_cycle_anchor: 'unchanged',
})
Trial Management
Trials have several edge cases worth planning for:
Card capture at trial start. For most SaaS products, collecting a payment method at trial start (even though you won't charge immediately) significantly reduces trial-to-paid churn. Users who didn't provide a card are far less likely to convert.
Trial extension. If a user had technical issues during their trial, you may want to extend it. This is a one-line Stripe API call (trial_end: newTimestamp) but needs to fire the right email and update your local record.
Trial-to-paid conversion. When a trial ends with a valid payment method, Stripe automatically creates the first invoice and attempts payment. The invoice.paid webhook is your trigger to fully activate the account and send a "welcome to paid" email.
Free tier vs. trial. Decide which model you're using. A free tier (permanent, limited feature set) is not the same as a trial (temporary full access). In Stripe, a free tier is typically handled with a $0 plan or simply no subscription; a trial is a subscription with a trial_end date.
The Payment Failure Flow
Failed payments are where SaaS billing gets complex and where most implementations are weakest.
When invoice.payment_failed fires:
- Update the invoice status in your database.
- Show the user an in-app banner: "Your payment failed. Update your payment method to avoid service interruption."
- Send an email immediately with a link to update payment information.
- Continue to remind them as Stripe retries (typically day 1, 3, 5, 7 by default — configurable in Stripe settings).
If payment still hasn't succeeded when the subscription moves to past_due status (usually after the first failed retry), your product behavior should change. I recommend:
- Restricting access to premium features (not logging the user out)
- Showing a persistent, prominent banner
- Emailing every 2-3 days
When the subscription moves to canceled status (after your configured retry period), restrict access to the subscription's features entirely. Store the canceled_at timestamp — you may want to offer a grace period for data export.
Customer Portal
Build a self-service customer portal using Stripe's hosted portal product rather than building your own billing management UI. It handles plan upgrades, cancellations, payment method updates, and invoice history. Stripe maintains and updates it.
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.APP_URL}/settings/billing`,
})
// Redirect the user to session.url
Stripe sends webhooks for every action the user takes in the portal, so your database stays in sync.
Stripe billing is one of the most consequential technical systems in your SaaS product. Getting the webhook handling, data model, and edge case coverage right from the start prevents a category of production incidents that are painful and embarrassing. If you're implementing Stripe billing and want a review of your approach, book a call at calendly.com/jamesrossjr.