Subscriptions in Django with Stripe Billing
Let Stripe own the billing state and Django own the entitlement. Mixing the two is where it goes wrong.
Zeeshan
Founder · Published
Let Stripe own the billing state and Django own the entitlement. Mixing the two is where it goes wrong.
Zeeshan
Founder · Published
The recurring Django-plus-Stripe mistake is modeling too much. Teams build Plan, Subscription, and Invoice tables mirroring Stripe's objects, then spend months keeping two systems in sync. Stripe already is the billing database. Django's job is to answer one question: is this user entitled to this feature right now?
class Customer(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
stripe_customer_id = models.CharField(max_length=255, unique=True)
# Denormalized from Stripe, updated only by webhooks. Never written
# from a request handler. That is how the two drift apart.
subscription_status = models.CharField(max_length=32, default="none")
plan_key = models.CharField(max_length=64, blank=True)
current_period_end = models.DateTimeField(null=True, blank=True)
@property
def has_active_access(self) -> bool:
return self.subscription_status in {"active", "trialing"}Three fields, one derived property. Everything else: proration, invoices, payment retries, tax: stays in Stripe where it is already solved.
Django's CSRF middleware will reject Stripe's POST, and request.body must be read raw. Both are one-liners, but both are required.
@csrf_exempt
@require_POST
def stripe_webhook(request):
try:
event = stripe.Webhook.construct_event(
payload=request.body, # raw bytes, not request.POST
sig_header=request.META["HTTP_STRIPE_SIGNATURE"],
secret=settings.STRIPE_WEBHOOK_SECRET,
)
except (ValueError, stripe.error.SignatureVerificationError):
return HttpResponse(status=400)
if event["type"].startswith("customer.subscription."):
sync_subscription(event["data"]["object"])
return HttpResponse(status=200)Skipping payment_failed is the most common gap. Without it a card expiry silently downgrades a paying customer, or worse, does not downgrade a churned one.
Gate features on the derived property, never on a live Stripe API call in the request path. A synchronous call to Stripe on every page view adds latency and couples your availability to theirs. The denormalized field, kept current by webhooks, is both faster and more resilient.
Stripe insights, monthly
One email a month, no spam, unsubscribe anytime.
No spam, unsubscribe anytime.
The raw-body problem bites PHP integrations harder than most. Here's the correct shape.
The Elements provider, the client secret lifecycle, and the re-render bug that breaks most first attempts.
Shopify Payments covers standard storefronts well: here's where it stops being enough.