Django Stripe Subscriptions: Production Guide
Build Django Stripe subscriptions with Checkout, verified webhooks, durable entitlement state, a billing portal, and lifecycle tests.
For production Django Stripe subscriptions, use Stripe Checkout to collect payment, verified webhooks to receive billing changes, and a local Django entitlement record to decide who can use the product. Treat the success redirect as user feedback, not proof of payment. Make every webhook handler idempotent because Stripe can retry events and does not guarantee delivery order.
The Checkout button is the easy part. The real billing system begins after the browser leaves your site: renewals fail, customers cancel at period end, events arrive twice, and a user closes the tab before returning to your success page. This guide builds around those failure modes.
Contents
- Define the production contract
- Use three ledgers for billing state
- Create subscription Checkout on the server
- Verify and record Stripe webhooks
- Map Stripe status to Django entitlement
- Choose the events that change access
- Add the customer portal
- Move slow side effects out of the webhook
- Test the full subscription lifecycle
- Production checklist
Define the Django Stripe subscriptions production contract
A correct subscription integration keeps billing truth and product access related without pretending they are the same record.
Stripe owns payment methods, invoices, PaymentIntents, Prices, and Subscriptions. Django owns the product account, plan capabilities, usage limits, grace periods, and the final answer to “may this user do this now?” Webhooks reconcile changes from Stripe into that local decision.
Write the contract down before writing views:
| Concern | Source of truth | Django responsibility |
|---|---|---|
| Price and billing interval | Stripe Price | Allowlist the Price IDs your server may sell |
| Payment result | Stripe Invoice and PaymentIntent | React to verified events |
| Subscription lifecycle | Stripe Subscription | Store the IDs and the latest state needed for access |
| Product access | Django entitlement | Grant, restrict, or revoke features according to your policy |
| Event processing | Durable Django event row | Prevent duplicate side effects and retain an audit trail |
This boundary fixes a common design error: checking Stripe on every protected request. That turns a product authorization decision into a network call and makes a Stripe outage an application outage. Store enough local state to answer access checks, then reconcile it from verified billing events.
It also keeps the integration replaceable. Your application code can ask an entitlement service whether an account may use a feature without knowing whether the latest change came from Checkout, the customer portal, an admin action, or a renewal invoice.
Define the account boundary at the same time. A subscription sold to a workspace should not be owned only by the member who happened to complete Checkout. Save the workspace ID in local state and in Stripe metadata, then authorize billing management against workspace membership and role. This prevents a departed employee from remaining the only route to the company's billing record.
Finally, define what a support operator can see and repair. A useful billing screen shows the local account, Stripe customer and subscription IDs, the last processed event, current entitlement, and any reconciliation error. It should link to Stripe rather than copying sensitive payment data into Django. That small operational surface turns “the customer paid but cannot log in” from a database investigation into a bounded reconciliation task.
Use three ledgers for billing state
The most reliable mental model is three small ledgers, each with one job.
The Stripe ledger contains the full financial history. Do not duplicate every Stripe object unless your reporting or support workflows need that local query surface.
The entitlement ledger is a Django model tied to the account that consumes the product. For a single-user SaaS that may be a profile. For a B2B product it is usually a workspace or organization, because a person can leave while the company subscription remains active.
The event ledger records which Stripe events your application accepted and
processed. Give event_id a unique constraint. A cache-only marker can reduce
repeat work, but it is not an audit trail and can disappear during a cache
flush.
A compact starting point looks like this:
from django.db import models
class BillingAccount(models.Model):
workspace = models.OneToOneField("teams.Workspace", on_delete=models.CASCADE)
stripe_customer_id = models.CharField(max_length=255, unique=True)
stripe_subscription_id = models.CharField(max_length=255, blank=True)
stripe_product_id = models.CharField(max_length=255, blank=True)
subscription_status = models.CharField(max_length=32, blank=True)
access_until = models.DateTimeField(null=True, blank=True)
cancel_at_period_end = models.BooleanField(default=False)
class ProcessedStripeEvent(models.Model):
event_id = models.CharField(max_length=255, unique=True)
event_type = models.CharField(max_length=255)
object_id = models.CharField(max_length=255, blank=True)
processed_at = models.DateTimeField(auto_now_add=True)
The exact fields depend on the product. The invariant matters more: Stripe identifiers help you reconcile; local entitlement fields help you authorize; the event row helps you repeat safely.
The Djass-derived Stripe handlers follow the same direction by mapping subscription data into explicit profile states and keeping customer and subscription IDs on the application record. The useful part is not a particular enum name. It is making the event-to-domain transition visible and testable.
Add database constraints around the business invariant, not only the incoming event. A Stripe customer should map to one billable account in the common case, and a Stripe subscription ID should not silently move between accounts. If the product permits multiple concurrent subscriptions, represent that explicitly with a separate subscription model instead of overloading one set of columns.
These ledgers also make reconciliation possible. A scheduled job can compare recently changed Stripe subscriptions with local billing accounts, flag unmatched objects, and replay a safe state transition. Reconciliation is not a replacement for webhooks; it is a repair path for missed configuration, deployment failures, or application bugs. Record what changed and why so the repair remains auditable.
Create subscription Checkout on the Django server
Create Checkout Sessions from an authenticated POST endpoint. Never accept a raw Price ID from the browser and pass it through unchecked. Map a stable local plan key to a server-controlled Stripe Price ID.
import stripe
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.urls import reverse
from django.views.decorators.http import require_POST
stripe.api_key = settings.STRIPE_SECRET_KEY
PRICE_BY_PLAN = {
"starter": settings.STRIPE_STARTER_PRICE_ID,
"pro": settings.STRIPE_PRO_PRICE_ID,
}
@login_required
@require_POST
def create_subscription_checkout(request):
plan = request.POST.get("plan")
price_id = PRICE_BY_PLAN.get(plan)
if not price_id:
return JsonResponse({"error": "Unsupported plan."}, status=400)
success_url = request.build_absolute_uri(
reverse("billing_success")
) + "?session_id={CHECKOUT_SESSION_ID}"
cancel_url = request.build_absolute_uri(reverse("pricing"))
session = stripe.checkout.Session.create(
mode="subscription",
line_items=[{"price": price_id, "quantity": 1}],
customer_email=request.user.email,
client_reference_id=str(request.user.pk),
metadata={"user_id": str(request.user.pk), "plan": plan},
subscription_data={
"metadata": {"user_id": str(request.user.pk), "plan": plan}
},
success_url=success_url,
cancel_url=cancel_url,
)
return JsonResponse({"url": session.url})
Stripe's current subscription integration
guide
uses mode=subscription and treats webhooks as the server-side channel for
asynchronous changes. Metadata gives the webhook a durable account hint, but it
is not authorization. Your handler must still validate that the referenced
account exists and that the subscribed Product or Price is one your application
recognizes.
The success page may retrieve the Checkout Session to show immediate feedback. It should not be the only place that grants access. The customer can pay and close the tab before the redirect, and some payment methods complete asynchronously.
The example uses customer_email to keep the first Checkout flow compact. Once
an account has a Stripe Customer, pass its customer ID instead. Reusing that
customer keeps invoices, portal access, and subscription history attached to
the same billing identity. Before creating another Session, decide what should
happen when the account already has an active subscription: open the portal,
schedule a plan change, or reject the request. Accidentally creating a second
subscription is not an upgrade flow.
Protect session creation against double clicks and network retries. Stripe's idempotent request guidance supports an idempotency key for POST requests. Derive a bounded key from the account and a server-side purchase attempt rather than accepting an arbitrary key from the browser. Save the attempt locally so you can return the same Checkout URL while it remains valid and start a new attempt when it expires.
Treat currency, tax behavior, trial configuration, coupons, and allowed plan transitions as catalog policy. They should be server-controlled alongside the Price allowlist. The browser may choose among options you rendered, but it should not define the amount, interval, or Stripe object being purchased.
Verify and record Stripe webhooks in Django
A Stripe webhook endpoint receives an external POST, so it cannot carry Django's browser CSRF token. Exempt only this endpoint from CSRF middleware, then replace that trust boundary with Stripe signature verification.
Stripe requires the raw request body, the Stripe-Signature header, and the
endpoint secret. Parsing and re-serializing JSON before verification changes the
payload and can invalidate the signature.
import stripe
from django.conf import settings
from django.db import transaction
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def stripe_webhook(request):
try:
event = stripe.Webhook.construct_event(
payload=request.body,
sig_header=request.headers.get("Stripe-Signature", ""),
secret=settings.STRIPE_WEBHOOK_SECRET,
)
except (ValueError, stripe.error.SignatureVerificationError):
return HttpResponse(status=400)
with transaction.atomic():
processed_event, created = ProcessedStripeEvent.objects.get_or_create(
event_id=event["id"],
defaults={
"event_type": event["type"],
"object_id": event["data"]["object"].get("id", ""),
},
)
if not created:
return HttpResponse(status=200)
reconcile_billing_event(event)
return HttpResponse(status=200)
This sketch keeps the event marker and state change in one database transaction. In a larger system, give the row a processing status and error field so a failed handler can be retried without pretending the event completed.
Stripe's webhook documentation states two constraints that should shape the implementation:
- an endpoint can receive the same event more than once;
- events are not guaranteed to arrive in the order they were created.
Do not write handlers that assume customer.subscription.created always arrives
before invoice.paid. Upsert the local billing account, and retrieve the current
Subscription or Invoice from Stripe when the event payload is not enough to
make a safe decision.
Separate acceptance from processing in larger systems. Acceptance means
the signature is valid and the event payload has been stored durably.
Processing means the event has produced a local state transition. A row with
received, processing, processed, and failed states makes retries and
alerts visible. Store the minimum payload needed for controlled replay, apply a
retention policy, and avoid logging full objects that may contain customer
details.
Return 2xx only for events you have safely accepted. Invalid signatures
deserve 400; a transient database failure should remain a failure so Stripe
can retry. For an event type you intentionally ignore, record or log that
decision and return success. This distinction prevents permanent configuration
mistakes from looking like healthy processing.
When fetching the latest Stripe object to tolerate out-of-order delivery, do not let an older event overwrite a newer local state after the fetch. Base the transition on the retrieved object and store its relevant timestamps or versioning evidence. The handler's goal is convergence on current billing truth, not faithfully replaying arrival order.
Map Stripe subscription status to Django entitlement
Stripe status is input to your access policy, not the policy itself. Decide the mapping explicitly and test every row.
| Stripe state or flag | Conservative product decision | User experience |
|---|---|---|
trialing |
Grant trial capabilities | Show trial end and payment-method status |
active |
Grant paid capabilities | Show current plan and renewal date |
past_due |
Apply a documented grace or restriction policy | Explain the failed payment and link to billing recovery |
incomplete |
Do not grant paid access yet | Ask the customer to finish payment or authentication |
incomplete_expired |
No paid access | Start a new Checkout flow |
unpaid |
Revoke paid access | Send the customer to payment recovery |
canceled |
Revoke paid access | Preserve data according to your retention policy |
paused |
Revoke or pause paid capabilities by policy | Explain how to resume |
cancel_at_period_end=true |
Keep access until the paid period ends | Show the cancellation date and an undo path |
Stripe's subscription webhook
guide describes the
status lifecycle and recommends revoking access for unpaid and canceled
subscriptions. It also explains that past_due behavior depends on your retry
and recovery settings. That is why copying a blanket
status in {"active", "past_due"} check into every view is too blunt for many
products.
Scheduled cancellation is another common edge. Stripe sends
customer.subscription.updated when cancel_at_period_end changes and
customer.subscription.deleted when cancellation finishes. A “cancelled”
label in your local state must not accidentally remove access before the paid
period ends.
Keep authorization code boring:
def can_use_paid_features(billing_account, now):
if billing_account.subscription_status in {"trialing", "active"}:
return True
if billing_account.subscription_status == "past_due":
return billing_account.access_until is not None and billing_account.access_until > now
return False
The function should consume a reconciled local record. Do not scatter Stripe API calls through decorators, templates, API views, and background tasks.
Centralize the decision in an entitlement service and return a reason with the
boolean result. “Past due until July 31” is actionable; False is not. The
reason lets the UI show recovery guidance, the API return a stable error code,
and support explain the restriction without interpreting raw Stripe status.
Plan changes need their own policy. An upgrade may take effect immediately
while a downgrade waits until the next period. Stripe controls proration and
invoice behavior, but Django still controls when capabilities change. Test the
combination of subscription status, current Product, scheduled Product, and
paid-through time instead of treating every subscription.updated event as an
instant plan replacement.
Choose the Stripe events that change access
Subscribe only to events your application handles. A practical recurring billing set usually includes:
| Event | Application action |
|---|---|
checkout.session.completed |
Link Checkout to the local account; do not assume every payment method has settled |
customer.subscription.created |
Store identifiers, Product, status, trial dates, and period data |
customer.subscription.updated |
Reconcile status, plan changes, scheduled cancellation, and period dates |
customer.subscription.deleted |
Mark the subscription terminal and revoke access |
invoice.paid |
Record successful renewal and extend the local paid-through entitlement |
invoice.payment_failed |
Start recovery messaging and apply the past_due policy |
invoice.payment_action_required |
Tell the customer authentication is required |
customer.subscription.trial_will_end |
Warn the customer before a trial ends |
The invoice events matter because a subscription is recurring. A tutorial that
handles only checkout.session.completed can pass the first-payment demo while
missing the first renewal failure.
Stripe's subscription overview shows how PaymentIntent, Invoice, and Subscription states interact. It also notes that asynchronous payment methods can take a different path through those states. Reconcile the objects you actually received instead of assuming all payment methods behave like an immediately successful card.
For a simple product, direct Stripe SDK code plus focused models may be enough. dj-stripe is useful when you want broader Stripe model syncing, Django ORM queries across those objects, and packaged webhook handling. It is a build-versus-package decision, not a prerequisite for Stripe Billing.
Add the Stripe customer portal
Do not build card updates, invoice history, plan changes, and cancellation UI unless the product needs a custom workflow. Stripe's customer portal can manage billing information, payment methods, invoices, subscriptions, and cancellation.
Create each portal session from an authenticated POST. Read the Stripe customer ID from the current user's billing account; never accept it from a request parameter.
from django.core.exceptions import PermissionDenied
@login_required
@require_POST
def create_billing_portal_session(request):
workspace = request.user.workspace
membership = workspace.memberships.get(user=request.user)
if not membership.can_manage_billing:
raise PermissionDenied("A billing manager role is required.")
billing = workspace.billingaccount
session = stripe.billing_portal.Session.create(
customer=billing.stripe_customer_id,
return_url=request.build_absolute_uri(reverse("billing_settings")),
)
return redirect(session.url)
Portal sessions are temporary. Create a fresh one when the user asks to manage billing, and keep processing the resulting changes through the same webhook reconciliation path.
Configure portal capabilities deliberately in Stripe. If customers may cancel but not change plans, do not expose plan changes. If they may switch plans, verify that the allowed Products, Prices, proration behavior, and downgrade timing match the entitlement policy in Django. The portal is hosted UI; it does not remove the need for product rules.
The return URL is navigation, not confirmation that a requested change completed. Refresh the billing screen from local reconciled state and show a pending state when necessary. A portal visit can produce several events, and the customer may close it without changing anything.
Test authorization separately from Stripe behavior. A member from workspace A
must not be able to create a portal session for workspace B by changing a URL,
form value, or JavaScript object. Resolve the Stripe customer through the
server-side account relationship and apply the same billing-role permission
used elsewhere in the product. Adapt the example's membership model and
can_manage_billing field to your own role system; do not remove that check.
Move slow side effects out of the Stripe webhook
Signature verification, durable event recording, and the minimum entitlement transition belong close to the request. Slow email, analytics, CRM updates, and expensive downstream work belong in a queue.
Stripe recommends a quick 2xx response before complex processing. If you use
Q2, enqueue after the database transaction commits so a worker cannot read
state that later rolls back.
from django.db import transaction
from django_q.tasks import async_task
transaction.on_commit(
lambda: async_task(
"apps.billing.tasks.run_subscription_side_effects",
processed_event_id=processed_event.pk,
)
)
The Djass Q2 background-job guide covers serializable task inputs, worker operation, and failure visibility. The broader Django background-task decision guide explains when Q2 fits and when Celery's routing or workflow primitives justify its larger operational surface.
Keep the task idempotent too. Webhook deduplication protects one boundary, but a worker can still retry after partially completing a side effect.
Give each side effect its own stable key, such as
stripe-event-id:payment-failed-email. Persist the outcome before considering
the job complete. That prevents a worker timeout after sending email from
sending the same message again on retry. For analytics and CRM calls, include
the event ID as the downstream idempotency or deduplication key when the
destination supports one.
The Django analytics with PostHog guide separates Checkout intent from provider-confirmed payment, then maps the verified Stripe event into a tested server-truth conversion.
Do not defer the entitlement update merely to make the endpoint fast unless the product accepts that delay. A useful split is: verify and persist the event quickly, reconcile the small local billing record transactionally, respond, then enqueue communications and integrations. If reconciliation itself needs network calls or expensive work, persist an accepted event and make the processing state visible so access lag and failures can be monitored.
Test Django Stripe subscriptions through time
Unit tests should construct representative event payloads and call the reconciliation function directly. Keep signature verification in a smaller endpoint test so most billing tests do not need to generate signatures.
Divide the suite into three layers:
- policy tests pass local billing records into the entitlement service and cover every status, grace deadline, and plan transition;
- reconciliation tests pass Stripe-shaped events or retrieved objects into the domain transition and assert the resulting rows;
- boundary tests verify signatures, authentication, Price allowlisting, portal authorization, transaction behavior, and queue handoff.
This split makes most tests fast while preserving a small set of realistic integration checks. Freeze time in grace-period and cancellation tests. Assert both the capability result and the user-facing reason so a wording or error-code regression does not hide behind a correct boolean.
At minimum, cover:
- successful Checkout links the correct local account;
- the same event ID is processed once;
- an update received before a create event still converges on current state;
trialingbecomesactive;activebecomespast_due, then recovers afterinvoice.paid;- scheduled cancellation retains access through the paid period;
customer.subscription.deletedrevokes access;- an unknown customer or Product changes no entitlement;
- a portal session can be created only for the authenticated billing account;
- queued side effects can repeat without duplicate email or analytics events.
Run the Stripe CLI beside the Django server during development:
stripe listen \
--events checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed \
--forward-to localhost:8000/stripe-webhook/
Use the signing secret printed by stripe listen in the local environment.
Do not reuse the Dashboard endpoint secret for the CLI listener.
One-off fixture events do not prove that renewals work. Stripe Billing simulations advance subscription time so you can exercise trial endings, renewals, plan changes, and payment failures without waiting for a real billing period.
Keep a lifecycle table with the expected local result after each simulated step. For example: trial starts → trial access; first invoice paid → paid access; renewal fails → grace access with deadline; retry succeeds → active access; cancel at period end → active access with cancellation notice; period ends → revoked access. This table is a compact acceptance contract shared by product, engineering, support, and the automated test suite.
Production checklist
Before enabling live recurring billing, verify:
- Checkout creation requires an authenticated POST.
- The server maps local plan keys to an allowlisted Price catalog.
- Secret keys and webhook secrets come from environment or secret storage.
- The live webhook endpoint uses the live endpoint secret.
- Signature verification uses the unmodified request body.
- Accepted event IDs have a durable unique constraint.
- Handlers tolerate duplicate and unordered delivery.
- Product entitlement is stored locally and has an explicit status policy.
invoice.paid, payment failure, and action-required paths are handled.- Scheduled cancellation and final cancellation are different states.
- The customer portal is configured and created only for the authenticated account.
- Slow side effects run asynchronously and are idempotent.
- Logs include event ID, event type, Stripe object ID, local account ID, and outcome without including secrets or full payment payloads.
- Alerts cover webhook failures, growing side-effect queues, and billing records that cannot be matched to a local account.
- Sandbox lifecycle tests and at least one live low-value end-to-end test pass.
Djass can generate a Django SaaS repository with the Use Stripe option, which adds subscription, Checkout, billing, webhook, and pricing-page scaffold pieces. Review the available generator options before generating, then apply the policy and lifecycle tests in this guide to the product you are building. The scaffold gives you known files and boundaries; your plan rules, grace period, tax setup, and support process still belong to your product.
If you want that maintained starting point, see the current Djass pricing. Djass itself currently sells lifetime access rather than a recurring subscription, so the generator's Stripe option should not be read as a claim about Djass's own billing model.
Django Stripe subscriptions FAQ
Should the Checkout success page grant subscription access?
No. Use the success page for immediate feedback, but grant durable access from verified server-side billing state. The customer can close the browser before returning, and some payment methods finish asynchronously.
Which Stripe webhook events are essential for subscriptions?
Start with Checkout completion, subscription created/updated/deleted,
invoice.paid, invoice.payment_failed, and
invoice.payment_action_required. Add trial-ending or usage events only when
the product has a defined action for them.
Should past_due users keep access?
That is a product policy. A short grace period can be reasonable while Stripe
retries payment, but encode the deadline explicitly and explain it to the
customer. Stripe recommends revoking access when a subscription becomes
unpaid or canceled.
Do Django projects need dj-stripe?
No. Use dj-stripe when local Stripe model syncing and packaged webhook integration are worth the dependency and upgrade surface. Direct Stripe SDK handlers are reasonable for a smaller billing model if you implement signature verification, idempotency, reconciliation, and lifecycle tests yourself.
How do you test subscription renewals?
Use Stripe's sandbox, CLI event forwarding, and Billing simulations/test clocks. Advance a simulated subscription through trial end, renewal, payment failure, recovery, scheduled cancellation, and final cancellation, then assert that Django entitlement matches each stage.
Primary references checked
This guide was verified on July 28, 2026 against Stripe's current documentation for subscription webhooks, webhook security and delivery, subscription status, customer management, and Billing simulations, plus Django's CSRF reference.