If you are building a SaaS product, mobile app, or e-commerce platform, you will eventually need to process recurring subscription payments.
Hand-rolling your own billing system (managing card expiration dates, retry logic, dunning emails, and global tax rules) is a fatal mistake that drains engineering resources for months.
Stripe Billing is the industry standard for a reason: it handles the complexity of global payments so your team can focus on building the product.
Here is the definitive architecture guide for integrating Stripe Billing into a modern web stack.
Start With the Money Model, Not the Code
Before writing a single line of integration code, be able to answer these five questions in writing:
- What are you selling? A flat monthly subscription, seats, usage-based metering, or a mix?
- What is the billing interval? Monthly, annual, or both with a discount for annual?
- Are there tiers? Free, Pro, Enterprise — and what changes when someone moves between them?
- Is tax included or added? This changes the price the customer sees and whether you must register for VAT or GST.
- What happens when a payment fails? How long does access continue, and what does the customer see?
Integrations that skip these questions end up rebuilding their price structure three months later, after real customers are on it — which is far more expensive than an extra week of planning.
Core Principles of Stripe Billing Architecture
┌──────────────────────────────────────────────────────────────────────┐
│ 1. Stripe is the Source of Truth for money & entitlements │
│ 2. Your DB stores a mirror of subscription state (fast local reads) │
│ 3. Never trust the browser — reconcile via webhooks │
│ 4. Design Idempotent webhook handlers (events arrive more than once) │
└──────────────────────────────────────────────────────────────────────┘
1. The Golden Rule: Never Trust the Client
When a user clicks "Upgrade to Pro", do not send pricing data from the frontend.
Anti-Pattern (Insecure):
// NEVER DO THIS
fetch("/api/create-checkout", {
body: JSON.stringify({ price: userInputPrice })
});
Correct Pattern: Look up Price IDs on the backend
// Frontend just requests a plan tier
fetch("/api/billing/checkout-session", {
method: "POST",
body: JSON.stringify({ plan: "pro_monthly" })
});
Your Django/Node backend maps pro_monthly to a hardcoded Stripe Price ID stored in environment variables.
PRICE_MAP = {
"pro_monthly": settings.STRIPE_PRICE_PRO_MONTHLY,
"pro_annual": settings.STRIPE_PRICE_PRO_ANNUAL,
}
@login_required
@require_POST
def create_checkout_session(request):
plan = json.loads(request.body).get("plan")
price_id = PRICE_MAP.get(plan)
if not price_id:
return JsonResponse({"error": "Unknown plan"}, status=400)
session = stripe.checkout.Session.create(
mode="subscription",
line_items=[{"price": price_id, "quantity": 1}],
customer=customer_id_for(request.user),
success_url=f"{settings.SITE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{settings.SITE_URL}/billing/cancelled",
client_reference_id=str(request.user.id),
)
return JsonResponse({"url": session.url})
Note the client_reference_id: it lets you tie the checkout session back to your own user when the webhook arrives, which is invaluable when someone checks out on a different device than they signed up on.
2. Webhook Events You Must Handle
Stripe communicates state changes asynchronously through webhooks. Every serious integration uses these:
| Event | What it means | What you should do |
|---|---|---|
checkout.session.completed | Customer finished checkout | Provision access, link customer ID to user |
customer.subscription.created | Subscription started | Store subscription ID, tier, period end |
customer.subscription.updated | Plan or quantity changed | Update tier and entitlements |
customer.subscription.deleted | Subscription cancelled | Downgrade to free at period end, not instantly |
invoice.paid | Payment succeeded | Extend access, record the payment |
invoice.payment_failed | Charge failed | Begin dunning, notify the customer |
charge.refunded | Refund issued | Record the reversal, adjust entitlements |
3. Idempotency and Out-of-Order Events
Stripe retries webhooks. The same event can arrive twice, and events do not always arrive in the order they occurred. Both facts break naive handlers.
@csrf_exempt
@require_POST
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except (ValueError, stripe.error.SignatureVerificationError):
return HttpResponse(status=400)
if ProcessedEvent.objects.filter(event_id=event["id"]).exists():
return HttpResponse(status=200) # already handled
handler = WEBHOOK_HANDLERS.get(event["type"])
if handler:
handler(event)
ProcessedEvent.objects.create(event_id=event["id"], event_type=event["type"])
return HttpResponse(status=200)
Two habits matter as much as the code:
Return 200 quickly, then process. If your handler does slow work — sending emails, generating invoices — queue it. Stripe will time out a slow endpoint and retry, which produces the duplicate handling you were trying to avoid.
Never overwrite newer state with older data. A subscription.updated event generated at 10:00 can arrive after one generated at 10:05. Store the Stripe created timestamp and ignore events older than what you already have:
def apply_subscription_update(subscription):
if subscription["created"] < local_record.stripe_event_created:
return # stale event, ignore
# ...apply
Handling this properly is the difference between a billing system that is correct and one that silently resurrects cancelled subscriptions.
4. Proration, Upgrades and Downgrades
When a customer on a $29/month plan upgrades mid-cycle to a $99/month plan, Stripe calculates the unused time on the old plan and applies it as a credit.
Configuration choices that matter:
- Proration on upgrade: Almost always charge immediately, with a credit for unused time.
- Proration on downgrade: Default to applying the change at the end of the current period, not immediately. Customers who downgrade and immediately lose access to features they already paid for will complain, and they are right.
- Billing anchors: Align all subscriptions to a common renewal date (e.g. the 1st of the month) so invoices, revenue reporting and support all become simpler.
stripe.Subscription.modify(
subscription_id,
items=[{"id": item_id, "price": new_price_id}],
proration_behavior="create_prorations",
billing_cycle_anchor="unchanged",
)
The most common mistake here is changing a price object in Stripe instead of creating a new one. Prices are immutable by design. To change what you charge, create a new Price and point new subscriptions at it. Existing subscriptions stay on the old price until you explicitly migrate them — which is exactly the behaviour you want when you raise prices, and exactly what you must remember to plan for.
5. Tax, VAT and GST
This is the part most tutorials omit, and it is where founders get unpleasant surprises.
- US: Sales tax on SaaS varies by state, and nexus rules depend on where your customers are. Stripe Tax automates the calculation and the filing reports.
- EU: B2C digital services are taxed at the customer's country rate from the first sale. B2B customers are reverse-charged, but only if you collect and validate their VAT number.
- UK: VAT registration is required once turnover crosses the threshold, and digital services to UK consumers are taxed at the point of sale.
- Australia: GST applies to digital services sold to Australian consumers, with the same consumer-versus-business distinction.
- Bangladesh and other markets: Local rules differ and often include withholding requirements on exported services; check before assuming a foreign entity structure removes the obligation.
Practical setup:
- Enable Stripe Tax and let it calculate at checkout.
- Collect VAT/tax IDs for business customers at checkout and validate them via the tax ID validation API.
- Show tax-inclusive or tax-exclusive pricing consistently — the customer-facing price must match what they are charged.
- Keep invoices with the tax breakdown for at least six years in most jurisdictions.
Get this wrong in either direction and you either under-collect, which you pay for later, or over-collect, which is worse because you owe refunds.
6. Dunning: Recovering Failed Payments
A failed payment is not always a dead customer. Between 20% and 40% of involuntary churn is recoverable with a decent retry schedule.
Day 0: Payment fails → customer receives "we couldn't process your card" email
Day 3: Automatic retry → second email with a direct link to update the card
Day 7: Automatic retry → in-app banner appears for the account owner
Day 14: Final retry → email states that access will be limited on a specific date
Day 21: Access limited to read-only; data preserved
Day 45: Subscription cancelled; data retained per your policy
Stripe Billing's Smart Retries uses payment-network signals to choose retry timing, which outperforms a fixed schedule. Two rules matter regardless of configuration:
- Preserve the data. Downgrade to read-only rather than deleting. A customer who returns in month three because their card was replaced should find everything intact.
- Make card updating effortless. A direct link to the Stripe customer portal in every dunning email recovers more revenue than any change to the retry logic.
7. Refunds and Chargebacks
Refunds are a support decision with a billing consequence. Refund the invoice in Stripe, and make sure your system revokes access if the refund was tied to a subscription being cancelled — otherwise you keep serving a customer you have refunded.
Chargebacks are different: the customer's bank reverses the charge, Stripe applies a dispute fee (typically $15), and you have a limited window to submit evidence. Practical rules:
- Respond to every dispute with evidence, even small ones. Unanswered disputes count against you.
- Keep logs of what the customer accessed, when they agreed to terms, and any prior support contact. This is usually what decides a dispute.
- If a customer disputes instead of asking for a refund, that is a signal they could not reach support. Fix the support path.
8. Reconciliation: Making Sure the Numbers Match
At the end of every month, your finance records and your Stripe balance must agree. Build for this from the start:
| Check | Frequency | What it catches |
|---|---|---|
| Stripe payouts vs bank deposits | Monthly | Missing or duplicated payouts |
Sum of invoice.paid vs recognised revenue | Monthly | Missing events, duplicate handling |
| Active subscriptions in your DB vs Stripe | Monthly | Webhook failures, drift |
| Failed events in the webhook log | Weekly | Silent integration breakage |
| Pending disputes and refunds | Weekly | Cash flow and access exceptions |
A short reconciliation script that compares active subscription counts between Stripe and your database, and emails you the difference, will catch the vast majority of real-world bugs. Run it on the first of the month, every month.
9. Testing With the Stripe CLI
Do not test billing by clicking through a real checkout and hoping. Use the Stripe CLI to forward events to your local environment:
stripe listen --forward-to localhost:8000/api/billing/webhook/
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
stripe trigger charge.refunded
Then test specific cards, which Stripe provides for exactly this purpose:
| Card number | Behaviour |
|---|---|
| 4242 4242 4242 4242 | Payment succeeds |
| 4000 0025 0000 3155 | Requires 3D Secure authentication |
| 4000 0000 0000 9995 | Declined for insufficient funds |
| 4000 0000 0000 0341 | Attaches, then fails on charge |
Also use test clocks to simulate subscription lifecycles over months without waiting: create a clock, attach a customer to it, and advance time to watch renewals, dunning and cancellation behave as they will in production. Teams that use test clocks find an order of magnitude more edge cases before launch than teams that do not.
10. When to Use Checkout vs Embedded Elements
Two viable approaches, with a real trade-off:
- Stripe Checkout (hosted page): Fastest to ship, handles 3D Secure, Apple Pay, Google Pay, and local payment methods automatically. You lose control of the visual experience.
- Stripe Elements (embedded): Full design control, more integration work, and you own every edge case — including 3D Secure redirects and wallet setup.
For a first launch, use Checkout. Shipping in a week with a battle-tested payment page beats spending three weeks on a custom form that handles fewer payment methods.
Summary Checklist
- [ ] Prices are looked up on the server, never sent from the client.
- [ ] Webhook signature is verified on every request.
- [ ] Handlers are idempotent and tolerate out-of-order events.
- [ ] Slow work is queued, and the endpoint returns 200 quickly.
- [ ] Upgrades prorate immediately; downgrades apply at period end.
- [ ] Tax calculation and tax ID collection are configured before launch.
- [ ] Dunning preserves data and includes a direct card-update link.
- [ ] Refunds revoke access when they should.
- [ ] Monthly reconciliation compares Stripe against your database.
- [ ] Test clocks and the Stripe CLI cover the edge cases before launch.
- [ ] The Stripe customer portal is enabled so customers can self-serve.
Related reading: API integration covers the general patterns this article applies to Stripe; RBAC is how plan entitlements map to permissions; and reducing Day-1 churn covers the trial-to-paid moment. Building a subscription SaaS product? KEHEM IT engineers secure, scalable Stripe billing architectures for B2B platforms worldwide.
Getting billing edge cases wrong is expensive — walk us through your pricing model and we will pressure-test the architecture.
Have a project in mind?
KEHEM designs and builds thoughtful websites, SaaS products, and business systems.