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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
| Event | When it fires | Typical local action |
|---|---|---|
checkout.session.completed | Customer finishes Checkout | Link 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_failed | A delayed payment method settles or fails | Fulfil or cancel the pending order |
customer.subscription.created / updated / deleted | Any change of status, price, quantity, or cancellation | Re-fetch the subscription and sync the local mirror |
customer.subscription.trial_will_end | Three days before a trial ends | Remind the user, nudge them to add a card |
invoice.paid | An invoice is settled | Record the payment; extend access |
invoice.payment_failed | A renewal attempt is declined | Show an "update your card" banner; the status change arrives separately |
invoice.payment_action_required | A renewal needs 3D Secure | Email the customer a link to the invoice's hosted_invoice_url |
charge.dispute.created | A chargeback is opened | Alert a human, flag the account, start collecting evidence |
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.
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.
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.
Idempotency keys are simple to add and surprisingly easy to misuse. The failure modes seen most often in production:
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.CheckoutAttempt or Order row, key on its primary key, and reuse the key only for retries of that attempt.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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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 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.
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.
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.
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.
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 number | Behaviour | What it verifies |
|---|---|---|
4242 4242 4242 4242 | Succeeds, no authentication | The happy path and webhook-driven activation |
4000 0027 6000 3184 | Requires 3D Secure authentication | Your flow survives requires_action and the challenge |
4000 0000 0000 0002 | Declined | Error messaging and no access granted |
4000 0000 0000 9995 | Declined for insufficient funds | Decline-code specific messaging |
4000 0000 0000 0341 | Attaches to the customer, then fails when charged | Renewal failure, past_due, and dunning — combine with a test clock |
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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Every webhook fails signature verification | Wrong secret: the stripe listen secret, the test endpoint secret, and the live endpoint secret are all different | Load the secret per environment; log (never the secret itself) which environment rejected the event |
| Signature fails only in production | Something between Stripe and Django modifies the body — a proxy re-encoding JSON, middleware reading and re-serialising the payload | Verify against the untouched raw request.body bytes; keep the webhook URL out of body-rewriting layers |
| Timestamp outside tolerance errors | Server clock drift, or replaying an old captured payload | Run NTP; resend through the dashboard or CLI rather than re-posting stored bodies |
| Same event handled twice | The exists()/create() race, or no dedup at all | Unique constraint plus one transaction, as above |
| Deliveries marked failed with timeouts | Slow handlers inside the request | Acknowledge fast, process in a worker |
| "No such customer" errors | Test-mode ids in a live database, or a live key in staging | Keep data separate per mode; assert the key prefix matches the environment at startup |
| Paying user has no access | Missed webhook, or a late event about an old subscription overwrote the new one | Run the sync function for that user; add the stale-event guard; check reconciliation drift |
| Duplicate Customers per user | Concurrent first-time checkouts | Row 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.
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.