There is a specific kind of anxiety that hits when you realize your SaaS is giving away premium features to someone who canceled their subscription weeks ago. You go to the forums looking for a solution, and usually, you get buried under suggestions for massive libraries that try to mirror the entire Stripe API into your Postgres DB.
But the real "pro" move—the one the community actually swears by—is to treat your database as a state mirror, not a carbon copy. Here is the blueprint for a sync system that actually works and won't break when Stripe updates their API.
1. The Single Source of Identity
First, stop trying to link users by email. It’s unreliable and prone to collisions. As the top wisdom suggests, your User (or Organization) model needs exactly one piece of Stripe-specific data: the Stripe Customer ID.
# models.py
from django.db import models
from django.contrib.auth.models import User
class UserProfile(models.Model):
user = models.OneToOneField(User, on_on_delete=models.CASCADE)
# The glue that holds it all together
payment_provider_customer_id = models.CharField(max_length=255, blank=True, null=True)
# A simple status flag to keep the app snappy
subscription_active = models.BooleanField(default=False)
2. The Webhook Pulse
You shouldn't be updating your database based on "Success" redirects. Redirects can be closed, timed out, or spoofed. Instead, use Stripe Webhooks to let Stripe tell your server what just happened.
The goal here is to only store the bare minimum. Use webhooks to toggle the "Is this person allowed in?" switch.
# views.py
import stripe
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from .models import UserProfile
@csrf_exempt
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
endpoint_secret = 'whsec_...'
try:
event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
except Exception:
return HttpResponse(status=400)
# The logic: Only update the "Access" state
if event['type'] == 'customer.subscription.deleted':
subscription = event['data']['object']
UserProfile.objects.filter(
payment_provider_customer_id=subscription['customer']
).update(subscription_active=False)
elif event['type'] == 'invoice.payment_succeeded':
invoice = event['data']['object']
UserProfile.objects.filter(
payment_provider_customer_id=invoice['customer']
).update(subscription_active=True)
return HttpResponse(status=200)
3. The Live Query Rule
This is where community suggestions diverge from recommendations that favor "over‑engineering". Don't store a copy of the subscription details in your DB. Why? Because Stripe is better at managing billing than you are. If you need to know when the next billing date is, what the tax rate was, or if they have a specific discount code applied, ask Stripe live.
Caching that data in Django just creates a second place for it to be wrong. When a user visits their "Billing Settings" page, that’s when you hit the API:
# views.py
def billing_settings(request):
profile = request.user.userprofile
# Query Stripe API live for the heavy lifting
customer = stripe.Customer.retrieve(
profile.payment_provider_customer_id,
expand=['subscriptions']
)
context = {
'subscription': customer.subscriptions.data[0] if customer.subscriptions.data else None,
}
return render(request, 'billing.html', context)
By following this "Master and Mirror" approach, you keep your Django logic clean. Your app only cares about one thing: is_active?. Stripe handles the complex math of prorations, trials, and failed retries.
You get a database that is fast to query and a billing system that is 100% accurate because you're pulling the details straight from the source when it matters. It's the "can't go wrong" architecture for a reason—it respects the boundaries of each system.
