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.

KC Ramo · DjangoZen Team Jul 11, 2026 28 min read 327 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.

Creating the Customer exactly once

The Checkout snippet assumes request.user.stripe_customer_id already exists. Create it lazily the first time a user enters a billing flow, and guard against the classic duplicate: a user double-clicks "Subscribe" or opens two tabs, two requests race, and you end up with two Stripe Customers for one account — with the card saved on one and the subscription on the other. Lock the user row, re-check, and create with an idempotency key derived from the user id.

from django.db import transaction

def get_or_create_customer(user):
    if user.stripe_customer_id:
        return user.stripe_customer_id
    with transaction.atomic():
        locked = type(user).objects.select_for_update().get(pk=user.pk)
        if not locked.stripe_customer_id:
            customer = stripe.Customer.create(
                email=locked.email,
                metadata={"user_id": str(locked.pk)},
                idempotency_key=f"customer-{locked.pk}",
            )
            locked.stripe_customer_id = customer.id
            locked.save(update_fields=["stripe_customer_id"])
        return locked.stripe_customer_id

Holding a row lock across a network call is normally something to avoid, but here the lock is per user, the call is short, and the alternative is a data-integrity bug that support has to untangle by hand. The metadata matters too: it makes every Customer traceable back to your database from the dashboard and from webhook payloads. On the Checkout Session itself, also pass client_reference_id=str(request.user.id). Stripe echoes it back on the checkout.session.completed event, so the webhook can always find the local user, even in the rare case where the customer id was never saved locally.

The success page is a receipt, not a trigger

Append the literal placeholder {CHECKOUT_SESSION_ID} to the success URL (in a plain string, not an f-string, or Python will try to interpolate it). Stripe substitutes the real session id when it redirects. The success view can retrieve the session to show an accurate message, but it must not grant access: that stays the webhook's job.

success_url=request.build_absolute_uri("/billing/success/") + "?session_id={CHECKOUT_SESSION_ID}",

# views.py
def billing_success(request):
    session = stripe.checkout.Session.retrieve(request.GET["session_id"])
    if session.client_reference_id != str(request.user.id):
        raise PermissionDenied
    confirmed = Subscription.objects.filter(
        user=request.user, status__in=("active", "trialing"),
    ).exists()
    return render(request, "billing/success.html", {
        "confirmed": confirmed,
        "payment_status": session.payment_status,  # paid / unpaid / no_payment_required
    })

The webhook usually lands within a second or two, but sometimes the browser wins the race. When confirmed is false, render a "confirming your payment" state that polls a small JSON endpoint reading your own database, rather than hammering the Stripe API from the page. Expect payment_status to be unpaid for delayed payment methods such as bank debits — the money arrives later via checkout.session.async_payment_succeeded — and no_payment_required for a subscription that starts with a free trial.

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.

Closing the gaps in the minimal handler

The handler above is right in spirit but has two gaps that only show up under load. The exists()-then-create() check is a race: two deliveries of the same event handled concurrently by two workers can both pass the check and both run the side effect. And the event is recorded before the work runs, so if activate_subscription raises, the event is already marked processed; Stripe sees a 500, retries, and the retry is silently skipped — the one delivery that could have fixed things is thrown away. Let a unique constraint do the deduplication, and put the record and the work in one transaction so they commit or roll back together.

class StripeEvent(models.Model):
    event_id = models.CharField(max_length=255, unique=True)
    type = models.CharField(max_length=100)
    received_at = models.DateTimeField(auto_now_add=True)


HANDLERS = {
    "checkout.session.completed": handle_checkout_completed,
    "customer.subscription.created": sync_subscription_from_event,
    "customer.subscription.updated": sync_subscription_from_event,
    "customer.subscription.deleted": sync_subscription_from_event,
    "invoice.paid": handle_invoice_paid,
    "invoice.payment_failed": handle_invoice_failed,
    "charge.dispute.created": handle_dispute,
}


def process_event(event):
    handler = HANDLERS.get(event["type"])
    if handler is None:
        return  # acknowledged, deliberately ignored
    with transaction.atomic():
        _, created = StripeEvent.objects.get_or_create(
            event_id=event["id"], defaults={"type": event["type"]},
        )
        if not created:
            return  # duplicate delivery
        handler(event["data"]["object"])

In the view, replace everything after signature verification with a single process_event(event) call followed by the 200 response. On PostgreSQL this behaves exactly as you want under concurrency. The second transaction's insert blocks on the first one's uncommitted row; if the first commits, the second gets an IntegrityError, which get_or_create turns into a lookup returning created=False; if the first rolls back because its handler failed, the second insert simply succeeds and the work runs again. Let handler exceptions propagate out of the view so Stripe receives a 5xx and retries — swallowing them and returning 200 converts a transient bug into permanent data loss.

Moving work off the request path

If a handler does anything slow — sending email, provisioning resources, calling other APIs — verify the signature in the view, persist the event id, and hand the rest to a task queue such as Celery. Enqueue with transaction.on_commit() so a worker never picks up an id whose row is not yet visible, and have the task re-fetch the event with stripe.Event.retrieve(event_id) instead of trusting a payload that may have been stored minutes ago. The view then returns 200 in milliseconds, and retries become the task queue's responsibility rather than Stripe's — which means the task needs its own retry policy and a dead-letter alert, or failures simply vanish.

Which events to subscribe to

Register the endpoint for only the events you handle. Every extra event type is load on your endpoint and noise in your logs, and an unhandled type still has to be acknowledged. A typical subscription SaaS needs this set:

EventWhen it firesTypical local action
checkout.session.completedCustomer finishes CheckoutLink the Stripe customer to the user via client_reference_id; fulfil one-off orders when payment_status is paid
checkout.session.async_payment_succeeded / checkout.session.async_payment_failedA delayed payment method settles or failsFulfil or cancel the pending order
customer.subscription.created / updated / deletedAny change of status, price, quantity, or cancellationRe-fetch the subscription and sync the local mirror
customer.subscription.trial_will_endThree days before a trial endsRemind the user, nudge them to add a card
invoice.paidAn invoice is settledRecord the payment; extend access
invoice.payment_failedA renewal attempt is declinedShow an "update your card" banner; the status change arrives separately
invoice.payment_action_requiredA renewal needs 3D SecureEmail the customer a link to the invoice's hosted_invoice_url
charge.dispute.createdA chargeback is openedAlert a human, flag the account, start collecting evidence

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.

Saving cards and charging off-session

SCA gets harder when the customer is not present: usage top-ups, a charge when a back-ordered item ships, a balance settled at the end of a project. The pattern is to save the card while the customer is present — Checkout with mode="setup", or a SetupIntent confirmed with Elements — so authentication happens once, up front, and the card is set up for future merchant-initiated charges. Later you create the payment with off_session=True and confirm=True. The bank can still insist on authentication; when it does, the confirmation fails with the error code authentication_required and you have to bring the customer back on-session.

def charge_saved_card(order):
    try:
        return stripe.PaymentIntent.create(
            amount=order.amount_cents,
            currency=order.currency,
            customer=order.user.stripe_customer_id,
            payment_method=order.payment_method_id,
            off_session=True,
            confirm=True,
            metadata={"order_id": str(order.pk)},
            idempotency_key=f"order-{order.pk}-offsession",
        )
    except stripe.CardError as e:
        if e.code == "authentication_required":
            order.mark_needs_customer_action()
            send_complete_payment_email(order)  # link to an on-session page
        else:
            order.mark_failed(e.code)
        return None

The failed intent is also delivered to your webhook as payment_intent.payment_failed, carrying your order_id in metadata — a reliable place to attach the PaymentIntent id to the order so the on-session page can confirm that same intent with Stripe.js instead of creating a second one. For subscription renewals Stripe runs this dance for you: the invoice's PaymentIntent enters requires_action, invoice.payment_action_required fires, and the hosted invoice page lets the customer complete the challenge.

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.

Where idempotency keys go wrong

Idempotency keys are simple to add and surprisingly easy to misuse. The failure modes seen most often in production:

  • Same key, different parameters. Stripe does not return the cached result if the parameters differ — it rejects the request with an idempotency error. The Checkout key checkout-{user}-{plan} breaks as soon as anything else in the request varies, for example build_absolute_uri producing a different host behind a misconfigured proxy.
  • Key scope too wide. Keyed on user and plan alone, a user who abandons Checkout and comes back later the same day receives the same Session again — possibly one that has already been completed or expired. Scope keys to one attempt: create a local CheckoutAttempt or Order row, key on its primary key, and reuse the key only for retries of that attempt.
  • Key scope too narrow. A random UUID generated inside the function that calls Stripe protects nothing, because a retry of the task generates a new UUID.
  • Library retries versus your retries. Setting stripe.max_network_retries makes stripe-python retry transient network failures itself, adding an idempotency key automatically for those retries. That covers a single call; it does not cover a Celery task that crashes and runs again, which is why domain-derived keys still matter.

Idempotency on Stripe's side does not make your side idempotent. The robust ordering is: write a local Order with status pending and commit; call Stripe with order-{order.id}; store the returned intent id on the order. A crash between the second and third step is harmless — the retry finds the pending order, sends the same key, and gets the same PaymentIntent back rather than a new charge.

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.

A grace period in code

Make the grace window an explicit rule in one place rather than scattered status == "active" checks across views and templates:

from datetime import timedelta
from django.utils import timezone

PAST_DUE_GRACE = timedelta(days=7)

def has_access(sub):
    if sub.status in ("active", "trialing"):
        return True
    if sub.status == "past_due":
        return timezone.now() < sub.current_period_end + PAST_DUE_GRACE
    return False  # canceled, unpaid, incomplete, incomplete_expired, paused, unknown

Note the extra statuses in that last comment. incomplete means the first payment of a new subscription needs authentication or failed; incomplete_expired means it was never completed in time; paused appears when a trial ends without a payment method and you have configured the subscription to pause rather than cancel. Failing closed on anything unrecognised — and logging it — is safer than a list of "bad" states that silently grants access when Stripe adds a new one. Keep your grace period no longer than your Smart Retries schedule, otherwise users keep access after Stripe has already given up.

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.

Changing plans in code

A plan change replaces the price on the existing subscription item; it does not create a new subscription.

def change_plan(local_sub, new_price_id, change_request_id):
    sub = stripe.Subscription.retrieve(local_sub.stripe_subscription_id)
    item_id = sub["items"]["data"][0]["id"]
    return stripe.Subscription.modify(
        sub.id,
        items=[{"id": item_id, "price": new_price_id}],
        proration_behavior="always_invoice",
        idempotency_key=f"plan-change-{change_request_id}",
    )

Two details are easy to trip over. Stripe objects in the Python library are dictionaries, so sub.items is the dict method, not the subscription items — use sub["items"]. And the proration behaviour is a business decision: create_prorations (the default) adds the adjustment to the next invoice, always_invoice bills the difference immediately, and none switches price without any adjustment. For upgrades, invoicing immediately avoids extending a month of the higher tier on credit; for downgrades at period end, look at Subscription Schedules instead of mutating the live subscription. Do not update your local plan field in this function — let the resulting customer.subscription.updated webhook do it, so there is one path that writes billing state.

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.

A reconciliation command

A management command run from cron or a scheduler is enough for most apps. It pages through every subscription with the library's auto-pagination, reports drift, and repairs it through the same sync function the webhooks use.

# billing/management/commands/reconcile_stripe.py
class Command(BaseCommand):
    help = "Compare the local subscription mirror with Stripe and repair drift"

    def handle(self, *args, **options):
        seen, drift = set(), 0
        subs = stripe.Subscription.list(status="all", limit=100)
        for sub in subs.auto_paging_iter():
            seen.add(sub.id)
            local = Subscription.objects.filter(stripe_subscription_id=sub.id).first()
            if local is None or local.status != sub.status:
                drift += 1
                self.stderr.write(f"drift {sub.id}: local={getattr(local, 'status', None)} stripe={sub.status}")
                sync_subscription(sub.id)
        for local in Subscription.objects.exclude(stripe_subscription_id__in=seen):
            self.stderr.write(f"unknown to Stripe: {local.stripe_subscription_id}")
        self.stdout.write(f"checked {len(seen)} subscriptions, repaired {drift}")

Emit the drift count as a metric and alert when it is non-zero for two runs in a row: occasional drift after a deploy is expected, persistent drift means a handler is broken. Old, superseded subscriptions will show up as "drift" on every run because the one-row-per-user mirror only holds the current one; filter those out (for example, skip ended subscriptions whose customer already has a different live subscription locally) before alerting. Rows "unknown to Stripe" usually mean test-mode ids leaked into a production database, or the command is running with the wrong key. For very large accounts, paginate the local side as well instead of building one huge __in list, and filter the Stripe side with created ranges to spread the work across runs.

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.

A converging sync function

Here is what "re-fetch and compute" looks like for subscriptions. Every subscription event, the reconciliation job, and support tooling all call the same function, so there is exactly one code path that decides local billing state.

from datetime import datetime, timezone as dt_timezone

LIVE = ("active", "trialing", "past_due")

def sync_subscription(subscription_id):
    sub = stripe.Subscription.retrieve(subscription_id)
    user = User.objects.get(stripe_customer_id=sub.customer)
    item = sub["items"]["data"][0]
    period_end = item.get("current_period_end") or sub.get("current_period_end")

    with transaction.atomic():
        local = Subscription.objects.select_for_update().filter(user=user).first()
        if (local and local.stripe_subscription_id != sub.id
                and local.status in LIVE and sub.status not in LIVE):
            return  # late event about an old subscription; ignore
        Subscription.objects.update_or_create(
            user=user,
            defaults={
                "stripe_subscription_id": sub.id,
                "status": sub.status,
                "current_period_end": datetime.fromtimestamp(period_end, tz=dt_timezone.utc),
            },
        )


def sync_subscription_from_event(obj):
    sync_subscription(obj["id"])

The guard handles a real race: a customer cancels, resubscribes a minute later, and the customer.subscription.deleted event for the old subscription arrives after the new one has been synced. Without the check, the late event would overwrite a paying customer's active row with canceled. Because the model is a OneToOneField on the user, the upsert keys on the user, not the subscription id — a resubscription produces a new subscription id that must replace the old one. The period-end lookup checks the subscription item first because newer Stripe API versions report billing periods per item rather than on the subscription; pin your API version and read the field from wherever your version puts it.

Automated tests for the webhook

The Stripe CLI and test clocks exercise the integration end to end, but they are too slow and stateful for a CI suite. The webhook view is the most critical code in the integration, and it can be unit-tested fully offline: Stripe's signature header is t=<timestamp>,v1=<hex HMAC-SHA256>, computed over "<timestamp>.<raw body>" with the endpoint secret, so a test can sign its own payloads and push them through the real verification code.

import hashlib, hmac, json, time
from unittest import mock
from django.test import TestCase, override_settings

SECRET = "whsec_test_only"

def sign(payload: bytes, secret=SECRET):
    ts = int(time.time())
    mac = hmac.new(secret.encode(), f"{ts}.".encode() + payload, hashlib.sha256)
    return f"t={ts},v1={mac.hexdigest()}"


@override_settings(STRIPE_WEBHOOK_SECRET=SECRET)
class StripeWebhookTests(TestCase):
    url = "/webhook/stripe/"

    def post_event(self, event, signature=None):
        body = json.dumps(event).encode()
        return self.client.post(
            self.url, data=body, content_type="application/json",
            HTTP_STRIPE_SIGNATURE=signature or sign(body),
        )

    def event(self, type_, obj):
        return {"id": "evt_test_1", "object": "event", "type": type_,
                "data": {"object": obj}}

    def test_rejects_forged_signature(self):
        evt = self.event("invoice.paid", {"id": "in_1", "object": "invoice"})
        resp = self.post_event(evt, signature="t=1,v1=00")
        self.assertEqual(resp.status_code, 400)

    @mock.patch("billing.webhooks.sync_subscription")
    def test_duplicate_delivery_is_processed_once(self, sync):
        evt = self.event("customer.subscription.updated",
                         {"id": "sub_1", "object": "subscription"})
        self.assertEqual(self.post_event(evt).status_code, 200)
        self.assertEqual(self.post_event(evt).status_code, 200)
        sync.assert_called_once_with("sub_1")

    @mock.patch("billing.webhooks.sync_subscription", side_effect=RuntimeError)
    def test_failed_handler_is_retried(self, sync):
        evt = self.event("customer.subscription.updated",
                         {"id": "sub_1", "object": "subscription"})
        with self.assertRaises(RuntimeError):
            self.post_event(evt)
        self.assertFalse(StripeEvent.objects.filter(event_id="evt_test_1").exists())

The last test pins down the rollback behaviour of the hardened handler: a failing handler must leave no StripeEvent row behind, so Stripe's retry gets a second chance. (The Django test client re-raises view exceptions by default, which is why the test expects the exception rather than a 500.) Patch names where they are looked up — here the module holding the handlers — not where they are defined. If you enqueue work with transaction.on_commit(), wrap the post in self.captureOnCommitCallbacks(execute=True), because TestCase never commits and the callbacks would otherwise never run.

Test cards worth scripting

In test mode, specific card numbers produce specific behaviour. Use them in a manual QA script or a browser test against Checkout before every release that touches billing:

Card numberBehaviourWhat it verifies
4242 4242 4242 4242Succeeds, no authenticationThe happy path and webhook-driven activation
4000 0027 6000 3184Requires 3D Secure authenticationYour flow survives requires_action and the challenge
4000 0000 0000 0002DeclinedError messaging and no access granted
4000 0000 0000 9995Declined for insufficient fundsDecline-code specific messaging
4000 0000 0000 0341Attaches to the customer, then fails when chargedRenewal failure, past_due, and dunning — combine with a test clock

Troubleshooting a payments integration

Most production incidents in a Stripe integration fall into a handful of patterns. The webhook deliveries view in the Stripe dashboard shows every attempt, the response code your server returned, and the response body — it is the first place to look.

SymptomLikely causeFix
Every webhook fails signature verificationWrong secret: the stripe listen secret, the test endpoint secret, and the live endpoint secret are all differentLoad the secret per environment; log (never the secret itself) which environment rejected the event
Signature fails only in productionSomething between Stripe and Django modifies the body — a proxy re-encoding JSON, middleware reading and re-serialising the payloadVerify against the untouched raw request.body bytes; keep the webhook URL out of body-rewriting layers
Timestamp outside tolerance errorsServer clock drift, or replaying an old captured payloadRun NTP; resend through the dashboard or CLI rather than re-posting stored bodies
Same event handled twiceThe exists()/create() race, or no dedup at allUnique constraint plus one transaction, as above
Deliveries marked failed with timeoutsSlow handlers inside the requestAcknowledge fast, process in a worker
"No such customer" errorsTest-mode ids in a live database, or a live key in stagingKeep data separate per mode; assert the key prefix matches the environment at startup
Paying user has no accessMissed webhook, or a late event about an old subscription overwrote the new oneRun the sync function for that user; add the stale-event guard; check reconciliation drift
Duplicate Customers per userConcurrent first-time checkoutsRow lock plus user-scoped idempotency key

One structural safeguard pays for itself: fail fast at startup if the configuration is inconsistent. A check that the secret key starts with sk_live_ (or rk_live_ for a restricted key) in production — and with the test prefix everywhere else — catches the misconfigured deploy before it charges, or fails to charge, anyone. Pin the Stripe API version your code was written against, both in the library configuration (stripe.api_version) and on the webhook endpoint, and upgrade it deliberately with a test run, because payload shapes change between versions and a silent change is indistinguishable from a bug.

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.