Django Advanced

Stripe with Django in Depth: Checkout, Subscriptions, Webhooks, Idempotency, and SCA

A production guide to Stripe in Django: the object model, Checkout vs PaymentIntents, subscription lifecycles, why webhooks are the source of truth, idempotency keys, and Strong Customer Authentication — without card data touching your server.

DjangoZen Team Jul 11, 2026 16 min read 5 views

Accepting payments looks simple in the demo and gets subtle fast: card authentication rules, webhooks that arrive out of order, duplicate charges from retries, subscriptions that lapse silently. This tutorial covers Stripe with Django the way it needs to work in production — the object model, Checkout versus PaymentIntents, subscription lifecycles, why webhooks are the source of truth, idempotency, and Strong Customer Authentication — without ever letting card data touch your server.

The Stripe object model

Before writing code, get the objects straight. A Customer represents a buyer and stores their payment methods. A Product is a thing you sell and a Price is a specific amount and interval for it (a product can have monthly and yearly prices). A PaymentIntent tracks a single payment through authentication and capture. A Subscription ties a customer to recurring prices and generates Invoices, each of which drives a PaymentIntent. Almost every integration bug traces back to confusing these — for example treating a subscription's success as a single payment rather than a recurring stream of invoices.

Keys and configuration

Stripe gives you a test key pair and a live key pair. Keep both out of source control and select by environment.

# settings.py
STRIPE_SECRET_KEY = os.environ["STRIPE_SECRET_KEY"]
STRIPE_PUBLISHABLE_KEY = os.environ["STRIPE_PUBLISHABLE_KEY"]
STRIPE_WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]

import stripe
stripe.api_key = STRIPE_SECRET_KEY

The secret key is server-side only; the publishable key is safe to expose to the browser and is used by Stripe.js to tokenize card details. That split is the foundation of staying out of PCI scope: the card number is entered into a Stripe-hosted field and never reaches your Django process.

Checkout Session vs raw PaymentIntent

For most apps, Stripe Checkout is the right default: a hosted, PCI-compliant, mobile-optimized payment page that Stripe maintains and that handles authentication for you. You create a session server-side and redirect.

session = stripe.checkout.Session.create(
    mode="subscription",
    line_items=[{"price": plan.stripe_price_id, "quantity": 1}],
    customer=request.user.stripe_customer_id,
    success_url=request.build_absolute_uri("/billing/success/"),
    cancel_url=request.build_absolute_uri("/billing/cancel/"),
    idempotency_key=f"checkout-{request.user.id}-{plan.id}",
)
return redirect(session.url)

Drop to raw PaymentIntents with Stripe Elements only when you need a fully custom in-page flow. It is more work and more responsibility (you handle the authentication step, errors, and retries yourself), so reach for it deliberately, not by default.

Subscriptions and the billing lifecycle

A subscription is a state machine, not an event. It moves through trialing, active, past_due, canceled, and unpaid, and Stripe drives those transitions as invoices succeed or fail. Your job is to mirror that state locally so your app knows who has access. Do not compute access from your own checkout success handler — compute it from subscription status delivered by webhooks, because a card can fail on renewal months later with no user action.

Webhooks are the source of truth

The single most important rule in a payments integration: the redirect back to your success URL is not proof of payment. The user can close the tab, the network can drop, and asynchronous payment methods settle minutes later. The authoritative signal is the webhook Stripe sends to your server. Verify its signature, then act on it idempotently.

@csrf_exempt
def stripe_webhook(request):
    try:
        event = stripe.Webhook.construct_event(
            request.body, request.META["HTTP_STRIPE_SIGNATURE"],
            settings.STRIPE_WEBHOOK_SECRET,
        )
    except (ValueError, stripe.error.SignatureVerificationError):
        return HttpResponse(status=400)

    # Idempotency: ignore events we've already processed
    if StripeEvent.objects.filter(event_id=event["id"]).exists():
        return HttpResponse(status=200)
    StripeEvent.objects.create(event_id=event["id"])

    if event["type"] == "invoice.payment_succeeded":
        activate_subscription(event["data"]["object"])
    elif event["type"] == "customer.subscription.deleted":
        revoke_access(event["data"]["object"])
    return HttpResponse(status=200)

Two details make this production-grade. First, signature verification with the endpoint's webhook secret — without it, anyone who finds your URL can forge "payment succeeded". Second, idempotency: Stripe retries deliveries and can send the same event twice, so record processed event IDs and no-op on repeats. Return a 2xx quickly; do slow work asynchronously or Stripe will time out and retry.

Strong Customer Authentication and 3D Secure

European regulation (SCA/PSD2) requires many card payments to pass an extra authentication step — 3D Secure. If you use Checkout or the PaymentIntents API correctly, Stripe handles this automatically: the PaymentIntent enters requires_action, the customer completes the challenge, and only then does it succeed. The failure mode is old integrations that assume a card charge is instantly final. Always drive off the PaymentIntent's final status, and never assume a created intent means captured money.

Idempotency keys

Networks fail after Stripe has already acted. If your create-charge request times out and you retry, you can double-charge — unless you send an idempotency key. Stripe remembers the key for 24 hours and returns the original result instead of performing the action twice.

stripe.PaymentIntent.create(
    amount=2499, currency="eur", customer=cust_id,
    idempotency_key=f"order-{order.id}",
)

Use a key derived from your own domain object (the order id), not a random value, so that a retry of the same logical operation carries the same key.

Failed payments and dunning

Cards expire and get declined on renewal. Stripe's Smart Retries and dunning emails recover a large share of failed subscription payments automatically — enable them in the dashboard. In your app, treat past_due as a soft state: keep access briefly, prompt the user to update their card, and only revoke on canceled/unpaid. Hard-cutting access the instant a renewal fails punishes good customers for an expired card.

Testing and going live

Use the Stripe CLI to forward live webhook events to your local server (stripe listen --forward-to localhost:8000/webhook/stripe/) and to trigger events on demand. Test the whole subscription lifecycle — including renewals and failures — with test clocks, which let you fast-forward time so a monthly renewal happens in seconds. Before switching to live keys, confirm your webhook endpoint is registered in the live dashboard with its own secret, because test and live webhooks have different signing secrets. Getting that wrong means every live event fails verification silently.

Modelling Stripe state in Django

Stripe is your billing system of record, but your app still needs local models to answer "does this user have access?" without a round trip to Stripe on every request. Store the Stripe customer id on your user, and mirror the subscription's status and current-period end locally, updated from webhooks.

class Subscription(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    stripe_subscription_id = models.CharField(max_length=255, unique=True)
    status = models.CharField(max_length=32)         # active, past_due, canceled...
    current_period_end = models.DateTimeField()

    @property
    def is_active(self):
        return self.status in ("active", "trialing")

Access checks read this local mirror; webhooks keep it truthful. That keeps request latency low and means a Stripe outage does not lock out paying customers.

The customer portal

Do not build your own UI for changing cards, downloading invoices, or cancelling — Stripe's hosted Customer Portal does all of it, PCI-compliant and maintained for you. Create a portal session and redirect; the customer manages their own billing and returns to your app.

session = stripe.billing_portal.Session.create(
    customer=request.user.stripe_customer_id,
    return_url=request.build_absolute_uri("/billing/"),
)
return redirect(session.url)

Every change the customer makes there arrives back as a webhook, so your mirrored state stays correct without you writing a single billing form.

Plan changes and proration

When a customer upgrades mid-cycle, they should not pay twice. Stripe handles this with proration: changing a subscription's price credits the unused portion of the old plan and charges the prorated difference for the new one. You update the subscription item and choose the proration behaviour; the invoice math is Stripe's. The trap is downgrades — decide deliberately whether a downgrade takes effect immediately (with a credit) or at period end, because "immediately" can mean handing back money you would rather apply next cycle.

Tax, invoicing, and receipts

Cross-border digital sales carry tax obligations — EU VAT, US sales tax — that you do not want to compute by hand. Stripe Tax determines the right rate from the customer's location and adds it to the invoice, and Stripe generates compliant invoices and receipts. If you are on a small-business VAT exemption you may switch this off, but revisit it the moment you cross a cross-border threshold, because unremitted VAT is a liability that compounds quietly.

Refunds and disputes

Refunds are a simple API call, but disputes (chargebacks) are not — the customer's bank claws back the money and you must submit evidence to contest it. Handle the charge.dispute.created webhook, gather evidence programmatically where you can, and track your dispute rate: a high rate risks your Stripe account itself. Design for refunds and disputes as first-class flows, not afterthoughts, because payments that go backwards are where sloppy integrations lose real money.

Usage-based and metered billing

Not every product is a flat monthly fee. For API calls, seats, or consumption, Stripe supports metered billing: you report usage during the period and Stripe bills the total at renewal. The discipline is idempotent, accurate reporting — report each unit of usage once, keyed so retries do not double-count, and reconcile your own records against Stripe's invoices. Metered billing is where under-reporting quietly loses revenue and over-reporting angers customers, so treat the usage pipeline with the same rigor as the payment path.

Multiple currencies and localized pricing

Selling internationally means charging in the customer's currency, not converting at display time. Create a Price per currency on each product and select the right one from the customer's locale, so a European sees euros and an American sees dollars, each an exact amount you set rather than a fluctuating conversion. Combined with Stripe's local payment methods, localized pricing measurably improves conversion — customers trust a clean local price far more than a converted one with a surprising decimal.

Reconciling Stripe with your own records

Even a correct integration drifts over time — a webhook is missed during a deploy, a manual refund is issued in the dashboard, a subscription is edited by support. Left unchecked, your local mirror slowly disagrees with Stripe, and the disagreements are exactly the cases that matter: access granted to someone who stopped paying, or denied to someone who did. The defense is reconciliation: a scheduled job that pages through Stripe's subscriptions and invoices and compares them against your database, flagging or auto-correcting mismatches. Treat Stripe as the source of truth on money and your database as a cache of it, and run reconciliation often enough that drift is caught in hours, not discovered in an angry support ticket.

Webhook ordering and races

Webhooks are not guaranteed to arrive in the order the events happened. A subscription.updated can land before the subscription.created it logically follows, and processing them naively leaves you in the wrong state. Two habits fix this. First, do not trust the event's payload as the latest truth for slow-changing objects — on receipt, re-fetch the object from Stripe by id so you always act on its current state. Second, make handlers order-independent and idempotent: compute the resulting local state from the fetched object rather than applying deltas, so replays and out-of-order deliveries converge to the same answer. Payments logic that assumes ordered, exactly-once delivery is logic that will eventually corrupt state under load.

Security and PCI scope

The golden rule: card data never touches your server. Collect it with Stripe.js/Elements or Checkout so the raw PAN goes straight to Stripe and you only ever handle tokens and IDs. That keeps you in the lightest PCI compliance tier. Store Stripe customer and subscription IDs on your models, never card numbers. Log webhook payloads carefully — they can contain personal data — and lock down the webhook endpoint to signature-verified requests only.