How to Integrate Stripe Into Your SaaS in 2026 (Subscriptions Done Right) — Cesar Ayala
← All posts

How to Integrate Stripe Into Your SaaS in 2026 (Subscriptions Done Right)

Integrate Stripe by treating webhooks—not the success redirect—as the source of truth. Use hosted Checkout in subscription mode to collect payment, Stripe Billing to run the subscription, and the Customer Portal for self-serve. Grant access only when a signature-verified, idempotent webhook confirms the subscription is active. Your database is just a synced read-replica of Stripe.

Where do you actually grant access in a Stripe subscription?

To integrate Stripe subscriptions correctly, use Checkout to acquire the customer, Stripe Billing to run the subscription, and the Customer Portal for self-serve management. Treat Stripe as the source of truth: grant access only from signature-verified, idempotent webhooks — never the success redirect — and gate every feature on subscription.status.

Here is the one rule that took me a production incident to internalize: Stripe is the source of truth, and your database is a read-replica synced by verified webhooks. You never compute subscription state yourself. Stripe owns it. Your app stores a thin mirror keyed to your user, and every change to that mirror arrives through the webhook stream.

So where do you grant access? Not on the success page. When Checkout finishes, Stripe bounces the browser to your success_url. That redirect is for UX only. The payment may not be captured yet (a 3DS challenge can still be pending, or an ACH debit settles days later), the user can close the tab before it fires, and anyone with the link can replay it. If you flip a “subscribed” flag there, you get two failure modes that quietly cost money: double-provisioning (the user refreshes and gets granted twice, with two welcome emails) and false grants (you provisioned, then the payment failed).

Payments are asynchronous. The API call and the redirect are only a request. The truth lands later, as a signed webhook event. You grant access only when a signature-verified, idempotent webhook confirms the subscription is active.

The 2026 stack that makes this clean is three Stripe products working together: Checkout (hosted page) to acquire the customer, Stripe Billing to run the subscription, and the Customer Portal to let people manage it themselves. You write the glue: the webhook handler, the user-to-customer mapping, and the access-grant logic.

I learned this the hard way on FinHOA, a fintech SaaS I built for HOA dues collection and accounting, and again on Mercanto, a B2B marketplace. Every silent-churn and double-provisioning bug I hit traced back to the same root cause: trusting the redirect or my own database instead of the webhook stream. Get this spine right and most of the bugs never happen. This is also a non-trivial line item when you budget a SaaS MVP — billing is rarely the afternoon job founders expect.

The webhook-as-source-of-truth lifecycle

User clicks SubscribeYour server creates a Checkout Session (mode: subscription)
Redirect to StripeHosted Checkout collects the card; success_url is UX only
Stripe fires a webhookcheckout.session.completed + customer.subscription.created
Verify + provisionSignature-checked, idempotent handler writes access to your DB
Renewalinvoice.paid each cycle extends the paid-through date
Failure / dunninginvoice.payment_failed -> status past_due, keep access during retries
Cancel / revokecustomer.subscription.deleted -> revoke access
Provisioning happens on the verified webhook, never on the browser redirect.

How do the core Stripe objects fit together?

Before the lifecycle makes sense, you need the object model. It nests cleanly:

  • Customer (cus_...) is your user’s billing identity. It holds payment methods.
  • Subscription (sub_...) links a Customer to one or more Prices and carries a status.
  • Price (price_...) is a recurring amount plus an interval (e.g. 5000 USD per month).
  • Product (prod_...) is the thing you sell; a Price belongs to a Product.

Each billing cycle, Stripe generates an Invoice, and each Invoice tracks its charge attempts. One 2026 caveat that bites people who learned the old model: under the Basil API the Invoice-to-PaymentIntent link moved. The Invoice no longer exposes a payment_intent field, so the old expand=["latest_invoice.payment_intent"] now errors. Instead, expand latest_invoice.confirmation_secret when you need the client secret for a Payment Element flow, or read the Invoice Payment object to inspect the underlying charge attempt’s state.

Model plans with Products and Prices, never hardcoded amounts

A Price is immutable. To change what you charge, you create a new Price and archive the old one; existing subscribers are grandfathered onto their original Price. This trips up founders who expect to “edit the price” and accidentally re-bill everyone.

Reference Prices by lookup_key, not by hardcoded IDs. Test-mode and live-mode Price IDs differ, so a hardcoded price_123 is a deploy waiting to break. With a lookup key your code is environment-portable.

Pricing models map directly to Price config: flat is a simple recurring Price; per-seat is usage_type=licensed with a quantity; usage/metered uses Stripe’s Billing Meters; tiered uses billing_scheme=tiered.

A 2026 gotcha worth flagging

Under flexible billing mode (API version 2025-06-30 and later), the period dates moved. current_period_start and current_period_end now live on the subscription items (subscription.items.data[].current_period_end), not as top-level fields on the subscription. The safest source for an access-expiration timestamp is the invoice line period end (invoice.lines.data[].period.end) on invoice.paid. Code that reads subscription.current_period_end can silently break on newer API versions.

Which Stripe webhook events do you actually need to handle?

You do not need to subscribe to all 200-plus event types. Here is the minimal correct set and the action each one drives.

Event What it means What you do
checkout.session.completed Checkout flow finished Link client_reference_id to your user; gate on payment_status == "paid"
checkout.session.async_payment_succeeded ACH/bank transfer settled Provision now (it didn’t settle at checkout time)
checkout.session.async_payment_failed Async payment failed Do not provision; notify the user
customer.subscription.created Subscription exists Set initial access from status
customer.subscription.updated Status/plan changed Re-sync access (active, past_due, cancel flag)
customer.subscription.deleted Subscription ended Revoke access
invoice.paid Money arrived (first + renewals) Extend the paid-through date
invoice.payment_failed Renewal charge failed Start dunning; status goes past_due
customer.subscription.trial_will_end ~3 days before trial ends Nudge for a payment method

A few non-obvious points. checkout.session.completed does not guarantee money moved: payment_status can be unpaid for async methods, which is exactly why ACH gets its own async_payment_succeeded event. And do not conflate invoice.paid with payment_intent.succeeded. For subscriptions, invoice.paid is the right, less noisy grant signal; it fires for the first charge and every renewal. payment_intent.succeeded is lower-level and far noisier.

Finally, never assume ordering or once-only delivery. Stripe delivers at-least-once and can reorder events. A subscription.created can land after an invoice event. If a referenced object isn’t in your DB yet, re-fetch it by ID from the API rather than trusting sequence.

What does the whole subscription lifecycle look like (the status state machine)?

This is the part tutorials skip, and it is the highest-value reference in the post. The subscription status is your access gate. Here is every status and the grant/revoke decision.

  • trialing — in trial, no charge yet. Grant access.
  • incomplete — the first PaymentIntent hasn’t confirmed (e.g. SCA pending). ~23-hour window. Do not provision.
  • incomplete_expired — first payment never completed; invoice voided. Terminal. Never provision.
  • active — good standing. Grant access.
  • past_due — a renewal failed but Smart Retries are still running. Keep access during this dunning grace window.
  • unpaid — retries exhausted. Revoke access.
  • canceled — terminal. Revoke access.
  • paused — trial ended with no payment method on file. No billing until resumed.

Derive a single boolean from this. In my apps has_access = status in {trialing, active, past_due}, and I store status, current_period_end, and cancel_at_period_end on the local customer row. Everything in the product gates on that one boolean.

The common silent bug is hardcoding active == has access. That single line denies access to paying trial users (trialing) and instantly churns recoverable card declines (past_due). Both cost you money in opposite directions.

Each phase maps to an event: signup is checkout.session.completed plus subscription.created; a renewal is invoice.paid; a failure is invoice.payment_failed; a scheduled cancellation is subscription.updated with cancel_at_period_end=true; an immediate cancellation is subscription.deleted.

Subscription status -> access decision

trialingGRANT (in trial, no charge)
activeGRANT (good standing)
past_dueGRANT (dunning grace — Smart Retries running)
incompleteREVOKE (first payment unconfirmed, ~23h)
incomplete_expiredREVOKE (terminal, invoice voided)
unpaidREVOKE (retries exhausted)
canceledREVOKE (terminal)
Derive one boolean: grant on trialing/active/past_due, revoke on the rest.

How do you write a webhook handler that won’t double-provision? (the code)

Here is the one correct pattern. Two things every tutorial skips: read the raw body before verifying, and claim the event ID for idempotency before you touch any state.

First, create the Checkout Session. Notice success_url is purely for the “thanks, setting up your account” page — it grants nothing.

import stripe
from fastapi import FastAPI, Request, HTTPException

stripe.api_key = STRIPE_SECRET_KEY  # sk_test_... locally, sk_live_... in prod
app = FastAPI()

@app.post("/create-checkout-session")
async def create_checkout_session(user, plan_id):
    # Reference the price by lookup_key so this works across environments.
    prices = stripe.Price.list(lookup_keys=["pro_monthly"], expand=["data.product"])
    session = stripe.checkout.Session.create(
        mode="subscription",
        line_items=[{"price": prices.data[0].id, "quantity": 1}],
        customer=user.stripe_customer_id,        # reuse; never create duplicates
        client_reference_id=str(user.id),        # map Stripe -> your user
        subscription_data={"trial_period_days": 14},
        success_url=f"{APP}/welcome?session_id={{CHECKOUT_SESSION_ID}}",
        cancel_url=f"{APP}/pricing",
        # Request option (NOT a Session field): dedupes the create REQUEST so a
        # retried call from your own server can't spawn two subscriptions.
        idempotency_key=f"checkout:{user.id}:{plan_id}",
    )
    return {"url": session.url}  # redirect the browser here; do NOT provision yet

Now the webhook. This is the source of truth.

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    payload = await request.body()                  # RAW bytes — never request.json()
    sig = request.headers.get("stripe-signature")
    try:
        event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
    except (ValueError, stripe.SignatureVerificationError):  # v8+ top-level alias
        raise HTTPException(status_code=400)

    # Inbound idempotency: claim the event id first. Stripe retries + reorders.
    if not await mark_event_seen(event["id"]):       # INSERT ... ON CONFLICT DO NOTHING
        return {"received": True, "duplicate": True}

    SYNC_EVENTS = {
        "checkout.session.completed",
        "customer.subscription.created",
        "customer.subscription.updated",
        "customer.subscription.deleted",
        "invoice.paid",
        "invoice.payment_failed",
    }
    if event["type"] in SYNC_EVENTS:
        obj = event["data"]["object"]
        customer_id = obj.get("customer")
        await sync_stripe_to_db(customer_id)         # ONE function, every path

    return {"received": True}                        # 2xx fast; heavy work async

The architecture that keeps me sane is one sync_stripe_to_db(customer_id) function called from every webhook, from the success redirect, and from a nightly reconciliation cron. It pulls the current subscription from Stripe’s API, derives has_access, and upserts the local row. No per-event provisioning logic that drifts apart over time.

async def sync_stripe_to_db(customer_id):
    subs = stripe.Subscription.list(customer=customer_id, status="all",
                                    limit=1, expand=["data.items"])
    if not subs.data:
        return await clear_access(customer_id)
    sub = subs.data[0]
    item = sub["items"]["data"][0]
    await db.upsert_subscription(
        customer_id=customer_id,
        subscription_id=sub.id,
        status=sub.status,
        price_id=item["price"]["id"],
        current_period_end=item["current_period_end"],   # 2026: read the item
        cancel_at_period_end=sub.cancel_at_period_end,
        has_access=sub.status in ("trialing", "active", "past_due"),
    )

Two idempotency mechanisms, both required. Inbound: dedupe on event.id so retries never double-grant or double-email. Outbound: pass an idempotency_key request option on create calls so a retried request from your own server doesn’t spawn two subscriptions.

The reconciliation cron is the safety net most teams forget. Webhooks get missed — your endpoint was down during a deploy, or Stripe gave up after its 3-day retry window. A nightly job that re-runs sync_stripe_to_db against Stripe’s API truth catches the drift before a customer notices.

The five steps of a correct webhook handler

  1. Read the RAW bodyawait request.body() — never request.json() before verifying
  2. Verify the signatureconstruct_event(payload, sig, whsec_...) blocks forged events + replays
  3. Dedupe by event.idClaim the id first; no-op on repeats (Stripe retries + reorders)
  4. Sync via one functionsync_stripe_to_db(customer_id) from every path — no drift
  5. Return 2xx fastAck in seconds; offload heavy work or Stripe retries you
Skip any one of these and you get signature failures or double-provisioning.

What happens when a payment fails (and how do you stop silent churn)?

This is the most-skipped, most-revenue-critical piece. Here is the number that should change how you build: 20 to 40 percent of SaaS churn is involuntary — expired cards, declines, insufficient funds — not customers choosing to leave.

When a renewal fails, Stripe does not cancel. It sets the status to past_due and runs Smart Retries, ML-timed retry attempts over roughly two weeks (the exact count and window are configurable in your Dashboard retry settings). The invoice carries attempt_count and next_payment_attempt so you can see where it is in the sequence.

Here is my opinionated call, and I will defend it: keep access during past_due and run your own dunning emails. Do not revoke on the first invoice.payment_failed. Stripe reports Smart Retries recover around 55 percent of failed recurring payments on average — that is Stripe’s blended figure across its base; independent B2C consumer-card datasets often see lower, in the 25 to 35 percent range. Either way, if you cut access the moment a card declines, you are manufacturing churn you could have saved.

On FinHOA, my first version did exactly the wrong thing — it revoked on the first failed renewal. HOA treasurers whose cards had simply expired suddenly lost access mid-month, opened angry tickets, and some never came back. Moving to a past_due grace window plus a short email sequence with a portal link turned most of those into silent successful retries.

Configure the failed-payment end state — cancel versus mark unpaid — in your Dashboard Billing settings. Smart Retries has no customer-communication layer of its own, so pair it with your own emails and a Customer Portal link to update the card. And handle customer.subscription.trial_will_end (fires ~3 days out) to confirm a payment method before the trial converts, so the very first charge doesn’t fail on a card the customer forgot about.

How do you handle trials, proration, and self-serve cancellation?

Trials. Set trial_period_days on the subscription. Status is trialing, no charge yet, and trial_will_end fires three days out so you can nudge for a card.

Proration. On a mid-cycle upgrade or downgrade, Stripe auto-prorates: it credits unused time on the old price and charges the prorated new price. You control this with proration_behavior (create_prorations, none, or always_invoice). To show the customer the exact charge before applying it, use Invoice.create_preview — the legacy /v1/invoices/upcoming endpoint was deprecated in the 2025 Basil API and now needs explicit subscription_details.

Change plans by updating the subscription items with new price IDs. Never delete and recreate the subscription — that throws away trial state, the billing anchor, and proration history.

# Preview the proration, then apply it, invoicing immediately.
preview = stripe.Invoice.create_preview(
    customer=customer_id, subscription=sub.id,
    subscription_details={"items": [{"id": item_id, "price": new_price_id}]},
)
stripe.Subscription.modify(
    sub.id,
    items=[{"id": item_id, "price": new_price_id}],
    proration_behavior="always_invoice",
)

Cancellation. Set cancel_at_period_end=true to keep access until the paid period ends; gate on current_period_end, not the click time. When the period ends, subscription.deleted fires.

My practitioner opinion: do not hand-roll billing UI. The Customer Portal (stripe.billing_portal.Session.create) handles card updates, plan changes, cancellations, and invoice history. Every change flows back as the same subscription.updated / subscription.deleted webhooks — so you write provisioning logic once and portal-driven cancellations just work. This is also why I keep a thin billing admin dashboard around it: the Portal serves customers, and a small internal tool gives the team a synced view without anyone poking at Stripe directly.

Hosted Checkout + Portal vs. custom Elements — which should you use?

Default to hosted. Checkout + Billing + Customer Portal gives you SCA/3DS handling, tax, Apple/Google Pay, dunning UI, and self-serve flows for almost no code, and it keeps you in PCI SAQ-A — the lightest compliance scope. Build with the Payment Element and PaymentIntents only when you genuinely need a fully custom in-app checkout UX; then you own SCA handling, retry UI, and a much bigger PCI footprint.

The hard rule, either way: card data never touches your server, logs, or database. You store only Stripe IDs (cus_, sub_, pm_). The moment a raw PAN passes through your backend you inherit enormous PCI obligations.

Security non-negotiables, recapped: verify signatures with the raw body, dedupe by event.id, return 2xx fast, pass an idempotency key on writes, and map customer-to-user via client_reference_id or metadata at session creation — never by trusting a client-supplied ID.

One footgun that bites in production: the test/live split. Keys, endpoints, signing secrets, products, prices, and customers are all duplicated per mode. The signing secret (whsec_) differs per endpoint and per mode. Deploy with the test whsec_ in production and every live event fails signature verification silently — nobody gets provisioned and you find out from support tickets.

Be honest about the division of labor. The Portal and Billing remove a lot, but you still own the webhook handler, the user-to-customer mapping, and the access-grant logic. Stripe runs the billing engine; you run the entitlement.

Hosted Checkout + Portal vs. custom Payment Element

Hosted (Checkout + Portal)

  • Almost no payment UI code
  • SCA/3DS, Apple/Google Pay handled
  • Self-serve plan changes + cancel via Portal
  • Dunning UI included
  • PCI SAQ-A (lightest scope)

Custom (Payment Element)

  • Fully custom in-app UX
  • You own SCA + retry UI
  • You build management flows
  • You build dunning prompts
  • Larger PCI scope to maintain
For a SaaS shipping subscriptions in 2026, hosted is the right default.

How much does Stripe actually cost in 2026?

Here is honest fee math, because this is the number founders miss when they set prices.

US card processing is 2.9% + $0.30 per successful charge. Stripe Billing adds a unified flat 0.7% of billing volume on top (the old 0.5% Starter / 0.8% Scale tiers are gone, and the 0.5% legacy promo ended June 30, 2025).

Worked example for a $50/month subscription: roughly $0.30 + ~$1.45 (2.9%) + ~$0.35 (0.7% Billing) before extras. Add cross-border (~1.5%) and currency conversion (~1%) for international customers and many SaaS land all-in around 4.5 to 6.5 percent, not the 2.9% headline.

Cost Rate (approximate, 2026)
US card processing 2.9% + $0.30 per charge
Stripe Billing 0.7% of billing volume
Cross-border cards +~1.5%
Currency conversion +~1%
Mexico (Stripe MX, domestic) ~3.6% + MXN 3.00
Mexico, international cards +0.5%
Mexico, currency conversion +2%
Dispute / chargeback $15 non-refundable each
Stripe Tax ~0.5% per transaction where registered (tiered; check current pricing)

Mexico-specific rates differ from the US — state them separately rather than assuming the US numbers apply. And here is my first-hand opinion for a Puebla audience: many LatAm founders incorporate and bill in USD via a US entity (a Stripe Atlas Delaware C-corp, for instance) to charge in dollars and dodge the 2% FX conversion on every single charge. The trade-off is USD payouts and US banking to manage. It is the right move often enough that it’s worth modeling before you launch.

Stripe Tax calculates and collects (~0.5% where you’re registered, on a tiered schedule) but does not auto-file your returns in most regions. Turn it on early; retrofitting tax after you’ve crossed a nexus threshold is miserable. Whatever you do, model these fees into your pricing from day one, and always check current Stripe pricing — these numbers move.

FAQ: Stripe subscription integration questions

Do I need Stripe Billing or just Payments? Billing, for recurring subscriptions. It gives you the status state machine, Smart Retries, proration, and the Customer Portal. Payments alone won’t run a subscription properly.

How do I test webhooks locally? Run stripe login, then stripe listen --forward-to localhost:8000/webhooks/stripe — it prints a local whsec_ to verify against and tunnels real events to your machine. Trigger specific ones with stripe trigger invoice.payment_failed. Test cards: 4242 4242 4242 4242 (succeeds), 4000 0000 0000 0341 (attaches but fails on charge), 4000 0027 6000 3184 (requires 3DS).

Do I need a backend or PCI compliance? Yes, you need a backend for the webhook endpoint. Hosted Checkout keeps you in PCI SAQ-A. Never store card data.

Can customers cancel themselves? Yes — the Customer Portal. Changes return as the same subscription.updated / subscription.deleted webhooks, so your provisioning logic handles them with zero extra code.

invoice.paid vs checkout.session.completed for granting access? Use invoice.paid as the money-arrived signal that extends access on first charge and every renewal. checkout.session.completed links the Stripe customer to your user and is the first signal for trials (where no payment has happened yet).

What about taxes or Stripe in Mexico? Turn Stripe Tax on early. Note that Mexico rates (~3.6% + MXN 3.00) differ from the US, and consider the USD-entity option if you sell globally.

Closing: ship the boring, correct version

Here is the whole post in one breath: Stripe is the source of truth, your app is a read-replica synced by verified, idempotent webhooks. Provision on the webhook, never on the redirect. subscription.status is your access gate; the Customer Portal owns self-serve. Don’t reinvent what Stripe already gives you.

The done-right checklist:

  • Verify signatures using the raw body
  • Idempotency in both directions (dedupe event.id, key your writes)
  • Never provision on the success redirect
  • Handle invoice.payment_failed — keep access during past_due
  • Never touch raw card data; store only Stripe IDs
  • Respect the test/live split (keys, secrets, prices)
  • Log every event and run a reconciliation cron

This is the exact architecture behind the real billing I shipped on FinHOA’s dues collection and Mercanto’s marketplace payouts. None of it is clever. It’s boring and correct, and boring-and-correct is what keeps revenue from leaking while you sleep.

If you’d rather have it built right the first time, this is the kind of work I do day to day — see my SaaS development service, or if you’re weighing whether to bring it in-house, here’s how to hire an app developer who won’t trust the redirect.