How to Integrate Stripe Billing Into a SaaS Application
A practical guide to wiring Stripe Billing into a SaaS product — covering Checkout, subscriptions, webhooks, the customer portal, plan gating, and the billing edge cases that always catch teams off guard.
TL;DR
- Use Stripe Checkout for subscription sign-up — it handles PCI, 3DS, and card validation automatically
- Sync subscription state to your own database via webhooks — never read it live from Stripe on every request
- Handle webhooks idempotently — Stripe will send the same event multiple times
- Use the Stripe Customer Portal for self-serve plan changes and cancellations
- Test billing edge cases before launch: failed payments, plan upgrades, trial-to-paid, cancellations
The Stripe Billing Architecture
Stripe Billing has four core objects you need to understand before writing any code:
| Stripe Object | What It Represents | In Your Database |
|---|---|---|
| Customer | A paying entity (org or user) | organisations.stripe_customer_id |
| Product | Your plan (Starter, Growth, Pro) | Reference only — set up in Stripe Dashboard |
| Price | A specific billing interval + amount (£49/mo) | subscriptions.stripe_price_id |
| Subscription | The active recurring billing for a customer | subscriptions.stripe_subscription_id |
Step 1: Set Up Your Stripe Products and Prices
In the Stripe Dashboard, create your Products (plan tiers) and Prices (billing intervals). Copy the price IDs — you'll reference them throughout your application to identify which plan a customer is on.
# Store these as environment variables STRIPE_PRICE_STARTER_MONTHLY = "price_xxx" STRIPE_PRICE_GROWTH_MONTHLY = "price_yyy" STRIPE_PRICE_PRO_MONTHLY = "price_zzz" STRIPE_PRICE_GROWTH_ANNUAL = "price_aaa" STRIPE_PRICE_PRO_ANNUAL = "price_bbb"
Step 2: Create a Stripe Customer on Sign-Up
When a new organisation signs up, immediately create a Stripe Customer and store the ID:
import stripe
stripe.api_key = settings.STRIPE_SECRET_KEY
def create_stripe_customer(organisation):
customer = stripe.Customer.create(
email=organisation.billing_email,
name=organisation.name,
metadata={"organisation_id": str(organisation.id)}
)
organisation.stripe_customer_id = customer.id
organisation.save()
return customer
Step 3: Stripe Checkout for Subscription Sign-Up
When a user selects a plan, create a Checkout Session and redirect them to Stripe's hosted page:
def create_checkout_session(organisation, price_id, trial_days=14):
session = stripe.checkout.Session.create(
customer=organisation.stripe_customer_id,
mode="subscription",
payment_method_types=["card"],
line_items=[{"price": price_id, "quantity": 1}],
subscription_data={
"trial_period_days": trial_days,
"metadata": {"organisation_id": str(organisation.id)}
},
success_url=f"{settings.BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{settings.BASE_URL}/billing/plans",
)
return session.url # redirect user here
Stripe Checkout handles card validation, 3D Secure authentication, and PCI DSS compliance automatically. Never collect card numbers on your own server unless you have PCI Level 1 compliance.
Step 4: Webhook Handler — Syncing Billing State
Your application must stay in sync with Stripe's billing state via webhooks. This is the most important — and most commonly broken — part of SaaS billing:
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
# Verify the webhook signature
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400)
# Idempotency check — has this event already been processed?
if WebhookEvent.objects.filter(stripe_event_id=event["id"]).exists():
return {"status": "already_processed"}
WebhookEvent.objects.create(stripe_event_id=event["id"])
event_type = event["type"]
if event_type in [
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted"
]:
subscription = event["data"]["object"]
sync_subscription_to_db(subscription)
elif event_type == "invoice.payment_failed":
handle_payment_failure(event["data"]["object"])
return {"status": "ok"}
Critical: Always verify the webhook signature using your Stripe Webhook Secret. Never trust incoming webhooks without signature verification — anyone can POST to your webhook endpoint.
Step 5: Plan Gating — Feature Access by Subscription
Gate features based on the subscription synced to your database — never call the Stripe API on every request:
PLAN_FEATURES = {
"price_starter": ["feature_a", "feature_b"],
"price_growth": ["feature_a", "feature_b", "feature_c", "feature_d"],
"price_pro": ["feature_a", "feature_b", "feature_c", "feature_d", "feature_e"],
}
def can_access_feature(organisation, feature_name: str) -> bool:
sub = organisation.active_subscription
if not sub or sub.status not in ("active", "trialing"):
return False
allowed = PLAN_FEATURES.get(sub.stripe_price_id, [])
return feature_name in allowed
Step 6: Stripe Customer Portal for Self-Serve Billing
Enable the Stripe Customer Portal in your Dashboard, then generate a portal session when users click "Manage Billing":
def create_billing_portal_session(organisation):
session = stripe.billing_portal.Session.create(
customer=organisation.stripe_customer_id,
return_url=f"{settings.BASE_URL}/settings/billing"
)
return session.url # redirect user here
The Customer Portal handles plan upgrades, downgrades, cancellations, payment method updates, and invoice history — all without any additional development on your side. Use it; don't build a custom billing UI from scratch.
Billing Edge Cases to Test Before Launch
| Scenario | What Must Happen |
|---|---|
| Trial expires, card charged | Status changes from trialing → active; no feature interruption |
| Trial expires, no card | Status → incomplete; redirect to payment on next login |
| Payment fails | Status → past_due; Stripe retries; dunning emails sent |
| User upgrades plan | Proration calculated; new features unlocked immediately |
| User downgrades plan | Takes effect at period end; credit issued; restrict excess usage |
| User cancels subscription | Access until period end; status → canceled at period end |
| Duplicate webhook received | Idempotency check skips re-processing; returns 200 |
Need Stripe Billing Built Into Your SaaS?
We implement production-ready Stripe billing as part of every SaaS product we build — including subscriptions, webhooks, plan gating, and the customer portal. Fixed-price engagement.
Get a Fixed-Price Quote