All tutorials

Tutorial

Stripe Billing Integration: Plans, Proration, and Dunning in a Django + FastAPI + Next.js Stack

A production pattern for Stripe billing integration: plans, proration, webhook state machines, and dunning across Django, FastAPI, and Next.js.

djangofastapiwebhooks

Most Stripe tutorials stop the moment the first payment succeeds. That’s the easy 20%. The other 80% — what happens when a customer upgrades mid-cycle, downgrades at renewal, cancels then changes their mind, or their card gets declined on the third of the month — is where billing systems actually earn their keep.

If you’re searching for a stripe billing integration pattern, you already know how to take a payment. What you need is the full subscription lifecycle: a data model that survives plan changes, a webhook-driven state machine that keeps your database and Stripe in sync, correct proration behavior, and a dunning flow that recovers revenue instead of silently losing it.

The direct answer: treat Stripe as the source of truth for billing state, mirror that state into your own database via webhooks (never by trusting client-side redirects alone), and derive all product entitlements from that mirrored state rather than from “the user clicked upgrade.” Everything below builds out that pattern across Django, FastAPI, and Next.js.

The architecture: who owns what

In a three-tier stack, it’s tempting to scatter billing logic wherever it’s convenient — a Stripe call in a Next.js API route here, a webhook handler in FastAPI there. Don’t. Pick one service as the billing system of record and make everything else a consumer of its state.

A layout that works well:

  • Django owns the primary data model — customers, subscriptions, invoices, entitlements — because its ORM and admin make billing data easy to inspect and reason about.
  • FastAPI owns the Stripe webhook endpoint and any latency-sensitive billing reads (e.g., “does this user currently have access?”) if you’re already using FastAPI for a high-throughput API layer.
  • Next.js never talks to Stripe directly for anything that mutates subscription state. It calls your own backend, which calls Stripe.

If you’re only running Django, put the webhook handler there instead — the principle matters more than the specific service boundary.

Step 1: Model your pricing before you touch the API

Before creating a single Stripe object, decide your pricing model on paper. This is the step most teams skip, and it’s the one that causes the most expensive rework later.

Common shapes for SaaS pricing:

  • Flat pricing — one price, one set of features. Simplest to build, hardest to grow revenue per account.
  • Tiered pricing — Starter/Pro/Enterprise, each a distinct Stripe Price, each unlocking a different entitlement set.
  • Per-seat pricing — price scales with a quantity on the subscription item, common for team tools.
  • Usage-based (metered) billing — price scales with reported usage, common for API products or anything billed by consumption.

Decide now whether you’ll offer a free trial, and if so, whether it requires a card upfront. That single decision changes your webhook handling, because a trial-to-paid conversion and a trial-to-expiration are different state transitions you need to detect and act on.

Write your pricing model down as a table before you open the Stripe Dashboard. It forces you to confront ambiguity — “what happens if a per-seat customer removes seats mid-cycle?” — while it’s still cheap to answer.

Step 2: Set up Products and Prices in Stripe

In Stripe’s model, a Product represents what you’re selling (e.g., “Pro Plan”) and a Price represents how much and how often you charge for it — the recurring interval, currency, and amount.

Practical rules for structuring this:

  1. One Product per plan tier, not one Product for your whole app. “Pro Plan” is a Product; “$49/month” and “$490/year” are two Prices attached to it.
  2. Never reuse a Price ID for a price change. If you need to change the amount, create a new Price and archive the old one — existing subscriptions keep referencing the old Price ID until you explicitly migrate them. This keeps historical invoices accurate.
  3. Store Price IDs in your own database or config, not hardcoded in templates. You’ll reference them from Checkout session creation, from your pricing page, and from upgrade/downgrade logic — a single source avoids drift between environments.
  4. Set up tax rates early if you have any international customers. Retrofitting tax handling onto live subscriptions is more painful than configuring it before launch.

Do this setup in the Stripe Dashboard first, by hand, so you can see the object relationships before you automate anything with the API.

Step 3: Checkout vs. a custom Payment Element

You have two realistic integration paths for collecting the subscription payment method:

Stripe Checkout is a hosted, Stripe-built page you redirect the customer to. It handles card collection, 3D Secure / Strong Customer Authentication (SCA) challenges, and PCI compliance scope almost entirely outside your infrastructure.

A custom flow using the Payment Element embeds Stripe’s payment UI inside your own page via the Subscriptions API, giving you full control over styling and flow, while Stripe still handles the card data directly so it never touches your servers.

How to choose:

FactorCheckoutCustom Payment Element
Time to shipFaster — mostly configurationSlower — you build the surrounding UI and state handling
UI controlLimited to Stripe’s hosted pageFull control, embedded in your app
PCI compliance burdenMinimal — redirect-basedStill minimal, but you own more of the surrounding flow
SCA / 3DS handlingHandled by Stripe’s hosted flowYou build the confirmation step in your own flow
Best fitMost SaaS products, especially early-stageProducts where checkout UX is a differentiator

My default recommendation: start with Checkout. Subscription billing has enough genuinely hard problems — proration, dunning, entitlement sync — without also owning a custom payment form. Move to the Payment Element only when you have a specific, validated reason the hosted page is hurting conversion or UX.

Either path, the subscription itself is created and managed through the Subscriptions API underneath — Checkout just handles the initial collection step.

Step 4: The data model — your real source of truth

Your database should mirror Stripe’s billing state, not invent its own. A minimal schema that holds up in production:

# Django models.py

class Customer(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    stripe_customer_id = models.CharField(max_length=255, unique=True)

class Subscription(models.Model):
    customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
    stripe_subscription_id = models.CharField(max_length=255, unique=True)
    stripe_price_id = models.CharField(max_length=255)
    status = models.CharField(max_length=32)  # store the status string Stripe reports; don't assume a fixed list of values
    quantity = models.PositiveIntegerField(default=1)
    current_period_end = models.DateTimeField()
    cancel_at_period_end = models.BooleanField(default=False)
    updated_at = models.DateTimeField(auto_now=True)

class Entitlement(models.Model):
    customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
    feature_key = models.CharField(max_length=64)
    is_active = models.BooleanField(default=False)

Three design decisions worth calling out:

  • status is a string field that stores the status Stripe reports — from this you derive whatever booleans your app needs (e.g. an “is active” check). A single boolean loses detail such as trialing, past_due, or canceled-but-not-yet-expired, and those distinctions are exactly what dunning and access control depend on.
  • Entitlement is a separate table from Subscription. This is the layer your application actually checks for access control — “can this user use feature X” — and it gets rebuilt from subscription state on every relevant webhook event, rather than computed ad hoc in request handlers scattered across the app.
  • stripe_subscription_id and stripe_price_id are stored as opaque strings, never parsed or assumed stable in format — Stripe owns that namespace.

Step 5: Webhooks — the actual state machine

Checkout redirects and client-side success callbacks only tell you the user’s browser returned to your app. They do not, by themselves, confirm the subscription is active, paid, or will stay that way. Webhooks are the more reliable signal to build state from.

Register an endpoint and verify each request’s signature as described in Stripe’s webhook documentation.

# Illustrative sketch only — check the Stripe SDK docs for exact exception classes and current best practice before using this in production.

import os
from fastapi import FastAPI, Request, HTTPException
import stripe

app = FastAPI()
STRIPE_WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, STRIPE_WEBHOOK_SECRET
        )
    except Exception:
        # Consult the Stripe SDK docs for the exact exception classes to catch here
        raise HTTPException(status_code=400, detail="Invalid signature")

    handle_event(event)  # dispatch to your own logic
    return {"received": True}

The events that matter most for subscription lifecycle:

  • checkout.session.completed — an event worth subscribing to; verify its exact semantics in Stripe’s event reference before deciding how it should update your local Customer and Subscription records.
  • customer.subscription.updated — an event worth subscribing to; confirm in Stripe’s event reference exactly which changes (plan, quantity, status) trigger it before treating it as your sync point for the Subscription table.
  • customer.subscription.deleted — in the design recommended in this article, treat this as the point at which the subscription has actually ended and revoke entitlements here, not when cancellation is merely requested. Confirm this behaviour against Stripe’s own documentation before relying on it.
  • Subscribe to invoice.payment_failed and treat it as your signal to start the notification flow below; confirm the event in Stripe’s event reference.
  • invoice.paid — an event worth subscribing to; check Stripe’s event reference to confirm its exact meaning before using it to re-sync the subscription or clear past-due flags in your own records.

Treat your handler as idempotent and reconciliation-first: on any relevant event, re-fetch the subscription object from Stripe (or trust the event payload) and overwrite your local row rather than trying to apply a diff. Make your handler idempotent and overwrite local state from the latest Stripe object rather than patching incrementally — this is a more resilient approach than relying on strict event ordering.

Step 6: Proration — the part everyone gets wrong

Proration is where “basic Stripe familiarity” usually stops. Here’s the mental model: when a subscription item changes mid-cycle (a different Price, or a changed quantity), Stripe calculates credit for unused time on the old price and a charge for the remaining time on the new price, and nets them against each other.

Three controls matter most:

  • proration_behavior determines whether proration happens at all when you update a subscription — options broadly include creating prorations automatically, not prorating, or deferring to the next invoice, depending on what the update call supports.
  • create_prorations / invoice timing controls whether the prorated amount is added as invoice line items now and billed immediately, or rolled into the next regular invoice.
  • Billing cycle anchor determines what date Stripe treats as the renewal date — this matters because some upgrade flows reset it and some don’t, and an unexpected anchor shift is a classic source of billing-support tickets.

Before you commit to a proration behavior in your UI, call Stripe’s upcoming invoice preview for the customer with the proposed change applied, and show the customer the actual number before they confirm — don’t compute an estimate yourself and risk it disagreeing with what Stripe actually charges.

One nuance worth internalizing: proration credit is generally based on unused time at the old price rather than a fresh full-cycle charge at the new price. The exact default proration behavior depends on your configuration, so don’t assume a specific outcome for a given upgrade timing — confirm it by checking the upcoming-invoice preview before the change is applied. If your business wants to charge a full cycle upfront regardless of timing, that’s a deliberate deviation from default proration behavior and needs explicit configuration rather than being Stripe’s default assumption.

Step 7: Upgrades and downgrades

Treat these as two different UX decisions, not one “change plan” button:

Upgrades typically take effect immediately — the customer gets access to the higher tier right away, with the price difference prorated onto the current cycle. Implement this as a subscription item update that changes the Price (and triggers proration per Step 6).

Downgrades are usually better scheduled for the end of the current billing period, rather than applied immediately — this avoids giving the customer a prorated refund for time already paid at the higher tier, and avoids the support confusion of “why did my access drop mid-month.” You can implement scheduled downgrades either by storing the pending price change yourself and applying it in your invoice.paid or renewal handling, or by using a subscription schedule object that encodes the future phase directly in Stripe.

A subtlety to handle explicitly: if a plan change would leave a subscription in an incomplete state — for example a required payment that needs additional authentication — your handling needs a path for that pending/incomplete status rather than assuming every update call succeeds synchronously. Don’t flip the local status field to the new plan until the corresponding webhook confirms it.

For seat-based products, “upgrade” often just means increasing quantity on the existing subscription item rather than swapping Prices at all — same proration mechanics apply, scaled by seat count. For usage-based (metered) pricing, there’s no quantity to adjust; instead your application reports usage records against the subscription item, and Stripe aggregates those into the next invoice — which means your entitlement logic needs to track usage limits independently of subscription status, since a customer can be “active” and still be over their usage cap.

Step 8: Cancellation and reactivation

Default to cancel_at_period_end rather than canceling immediately. This lets the customer keep access through the period they already paid for, which is both fairer and reduces immediate support friction. Your UI should clearly show “cancels on [date]” and offer an explicit “undo cancellation” action, which is simply unsetting that flag before the period ends.

The actual access revocation should happen on customer.subscription.deleted, not when cancellation is requested — that event fires when the subscription truly ends, which is the only moment you should flip entitlements off.

If you’d rather not build cancellation, plan-switching, and payment-method-update UI yourself, Stripe’s Customer Portal is a hosted alternative you can link customers to, and it still drives the same webhook events your backend already handles. It’s a reasonable way to ship self-service billing management fast, with less custom UI to maintain.

Step 9: Dunning — recovering failed payments

Card failures are routine, not exceptional: cards expire, issuing banks apply temporary holds, balances run short. A subscription product without dunning logic quietly bleeds revenue every month from customers who didn’t actually mean to cancel.

invoice.payment_failed is a candidate event to build dunning around — confirm in Stripe’s event reference that it fits your use case. If you subscribe to it and it fires:

  1. Set local subscription status to reflect past_due (mirroring whatever status Stripe reports), rather than immediately revoking access — give the retry process a chance to work.
  2. Trigger a notification to the customer — email is the baseline — telling them the charge failed and how to update their payment method.
  3. Review Stripe’s billing documentation for the current retry and recovery settings available for failed payments.
  4. If retries exhaust and the invoice is still unpaid, the subscription will move to a different status than past_due. Your app should revoke access based on the status Stripe reports at that point — check Stripe’s docs for the current set of status values.

Design your past-due UX deliberately: a banner prompting the customer to update their card, a direct link to the Customer Portal or your own payment-method-update flow, and — if you want to be aggressive about recovery — an escalating email sequence timed to the retry schedule. The goal is to make fixing the card the path of least resistance, because most failed payments are recoverable if the customer notices in time.

Step 10: Deriving entitlements — the part that touches your whole app

Everything above exists to answer one question correctly, everywhere in your app: does this user currently have access to X?

Don’t answer that question by checking Subscription.status == "active" scattered across views, API routes, and Next.js middleware. Centralize it.

# Django service function, called by both Django views and FastAPI

def get_entitlements(customer: Customer) -> dict:
    sub = customer.subscriptions.filter(
        status__in=["active", "trialing", "past_due"]
    ).first()
    if not sub:
        return {"plan": None, "features": []}
    return {
        "plan": sub.stripe_price_id,
        "features": FEATURE_MAP.get(sub.stripe_price_id, []),
        "seats": sub.quantity,
        "grace_period": sub.status == "past_due",
    }

Rebuild this entitlement record on every webhook event that touches subscription status, rather than computing it lazily on each request — a reconciliation job run on webhook receipt keeps the Entitlement table (from Step 4) consistent even if an individual request-time check has a bug.

In the Next.js layer, call your backend’s entitlement endpoint from a server component or API route and use it to gate UI and server actions — never trust a client-stored “I’m subscribed” flag, since it can drift from reality the moment a card fails. If you’re using FastAPI as a serverless-style API layer alongside Django, that get_entitlements logic is a good candidate to expose as its own fast, cacheable endpoint, since it’ll be called far more often than billing state actually changes.

Where this breaks in practice

The failure mode I’d watch for most is entitlement drift: your database says “active,” Stripe says “past_due,” and nobody notices until a customer complains about being charged for a feature they can’t use (or, worse, using a feature they shouldn’t still have). Periodic reconciliation — a scheduled job that walks active local subscriptions and compares status against Stripe’s API, not just webhook delivery — is cheap insurance against webhook delivery gaps. Pair it with structured logging around every webhook event your system processes, so when a customer’s access doesn’t match their billing status, you can trace exactly which event fired, when, and what state transition it triggered.

FAQ

Do I need both Stripe Checkout and the Payment Element? No — pick one path for initial subscription signup. Checkout is the faster, lower-maintenance default for most SaaS products; move to a custom Payment Element only when you have a specific reason the hosted page limits your UX.

Should isSubscribed be a boolean on my user model? Avoid it where possible. A single boolean loses detail such as trialing, past_due, or canceled-at-period-end, and those states can drive different UI and access decisions. Store the status string Stripe reports and derive booleans from it where needed.

When should I revoke access after a cancellation? On customer.subscription.deleted, not when the customer clicks “cancel.” Cancellation requests should set cancel_at_period_end and keep access live until the period actually ends.

How do I know what a plan change will actually cost before charging the customer? Use Stripe’s upcoming invoice preview with the proposed change applied, and display that number to the customer directly — don’t calculate your own estimate of the prorated amount.

What’s the single most common mistake in DIY Stripe billing integrations? Trusting client-side state (a redirect, a success page, a local flag) instead of webhook-confirmed server state as the source of truth for access control.