Subscription billing looks simple until you actually build it. Stripe's API is excellent, but the documentation shows you the happy path. Production billing systems spend 80% of their complexity on edge cases: failed payments, mid cycle upgrades, trial expirations on weekends, users who cancel and resubscribe within the same billing period, and proration calculations that do not match what customers expect.
We have built subscription billing for SaaS products handling thousands of subscribers. Here is the architecture that handles all of it without surprising your customers or your finance team.
The Core Architecture
Your billing system has three components that must stay synchronized: Stripe (source of truth for payment state), your database (source of truth for feature access), and your application logic (enforces what users can and cannot do based on their plan).
The critical principle: Stripe webhooks drive state changes, not API responses. When a user upgrades their plan, you call the Stripe API to create the subscription change, but you do not update your database based on the API response. You wait for the webhook event (`customer.subscription.updated`) and update your database in the webhook handler. This ensures consistency even when API calls timeout, webhooks are delayed, or Stripe retries events.
Your webhook handler needs to be idempotent. Stripe will retry webhooks up to 3 times over 72 hours, and you may receive the same event multiple times. Use the event ID to deduplicate, and design your state transitions so that processing the same event twice produces the same result.
Subscription Lifecycle States
Most implementations model subscriptions with 3 or 4 states. Production systems need at least 7:
- Trialing. User has access to paid features, no payment method required yet (or optionally required).
- Active. Paying customer with a current, valid subscription.
- Past Due. Payment failed but we are still retrying. User keeps access during the retry window (typically 7 to 14 days).
- Unpaid. All retry attempts exhausted. User loses access to paid features but account is not deleted.
- Canceled. User initiated cancellation. Access continues until the end of the current billing period.
- Paused. User requested a temporary pause (if you offer this). No billing, no access, account preserved.
- Expired. Trial ended without conversion. Similar to Unpaid in terms of access.
Each state transition triggers specific actions: feature access changes, email notifications, analytics events, and potentially webhook calls to your own downstream systems.
Trials That Convert
Trial implementation seems straightforward but has several architectural decisions that impact conversion:
Trial without payment method maximizes signups but produces lower quality leads. Conversion rates are typically 2 to 5%. Use this when your activation metric is strong and your product sells itself once users experience value.
Trial with payment method reduces signup volume by 40 to 60% but conversion rates jump to 40 to 65%. Use this when your product requires meaningful setup effort and you want committed users.
The hybrid approach (which we recommend for most SaaS products): start with a no card trial for the first 7 days, then prompt for a payment method to extend the trial by another 7 days. This captures both casual browsers and serious evaluators. In Stripe, you implement this by creating the subscription with `trial_end` set to 7 days out and no payment method, then updating it with a payment method and extending `trial_end` when the user adds their card.
Trial expiration handling is where most implementations break. Do not expire trials at midnight UTC. Expire them at the end of the user's local day, or better yet, extend by 24 hours if the trial ends on a weekend. A user whose trial expires at 2am Saturday morning while they were planning to evaluate over the weekend is a lost customer. Stripe lets you set `trial_end` to any timestamp, so use it.
Upgrades, Downgrades, and Proration
Upgrades should take effect immediately. The user pays the prorated difference for the remainder of the current billing period, and the next invoice reflects the new price. In Stripe, use `proration_behavior: 'create_prorations'` on the subscription update.
Downgrades should take effect at the end of the current billing period. The user already paid for the higher tier, so let them keep it. In Stripe, set `proration_behavior: 'none'` and schedule the plan change with `billing_cycle_anchor: 'unchanged'`. Store the pending downgrade in your database so you can show the user "Your plan will change to Basic on March 15."
The edge case most teams miss: a user upgrades and then downgrades within the same billing period. If you prorate the upgrade and then also prorate the downgrade, the math gets confusing for customers. Our approach: apply upgrade proration immediately, but if a downgrade is requested in the same period, refund the upgrade proration and schedule the downgrade from the original plan. This keeps invoices clean and comprehensible.
Failed Payment Recovery
Payment failures happen to 5 to 10% of subscription charges. Not because customers want to cancel, but because cards expire, banks flag unusual charges, or accounts temporarily lack funds. Your recovery system is worth building well because it directly impacts revenue.
Stripe's Smart Retries handle the basics. Enable them. They use machine learning to retry at optimal times and recover roughly 15 to 20% of failed payments automatically.
Dunning emails recover another 10 to 25%. Send them on a schedule:
- Immediately after failure: "Your payment did not go through. Please update your card to keep your access." Direct, clear, no guilt.
- Day 3: "We are still having trouble charging your card. Update it here to avoid any interruption."
- Day 7: "Your account will be downgraded in 7 days if we cannot process payment." Include what they will lose.
- Day 12: "Last chance. Your access to [specific features they use] will be removed in 2 days."
In app banners during the past due period are more effective than emails alone. A persistent but dismissable banner that says "Payment failed, update your card" with a direct link to the billing page converts 2 to 3x better than email alone.
Grace period architecture: during the past due period, keep full access to the product. Locking users out immediately after a payment failure (which is often not their fault) destroys goodwill and increases churn. The typical grace period is 7 to 14 days.
Webhooks You Must Handle
These are the Stripe webhook events that must trigger actions in your system. Missing any of them creates billing bugs:
- `customer.subscription.created` , provision access
- `customer.subscription.updated` , handle plan changes, status changes
- `customer.subscription.deleted` , revoke access
- `customer.subscription.trial_will_end` , send trial ending notification (fires 3 days before)
- `invoice.payment_succeeded` , update payment status, send receipt
- `invoice.payment_failed` , trigger dunning flow
- `customer.updated` , sync customer data (email changes, etc.)
- `payment_method.attached` / `detached` , update stored payment method status
For each webhook, log the raw event, process it, and store the result. If processing fails, your retry logic should pick it up. We store unprocessed events in a dead letter queue and alert on any event that fails processing 3 times.
Testing Billing Edge Cases
Stripe provides test mode and test card numbers for simulating failures, but you need a structured test plan:
- Trial expiration with and without payment method
- Upgrade mid cycle (verify proration amount)
- Downgrade mid cycle (verify access continues)
- Payment failure followed by card update
- Payment failure followed by all retries exhausted
- Cancel and resubscribe within the same billing period
- Multiple rapid plan changes (upgrade, downgrade, upgrade again)
- Webhook delivery failure and retry
For a broader look at payment processing architecture, our payment processing guide covers the fundamentals. And if you are evaluating payment providers, our Stripe vs. Square comparison breaks down the tradeoffs for different business models.
Get It Right the First Time
Billing bugs are the most expensive kind. They either cost you revenue (undercharging, failing to revoke access) or cost you customers (overcharging, unexpected charges, confusing invoices). The architecture we have described here is what we implement in every SaaS product we build because getting billing right from the start is dramatically cheaper than fixing it after launch.
If you are building a SaaS product and need billing architecture that handles the edge cases from day one, reach out and tell us about your project.