Stupe
The one rule that matters
Stripe is the source of truth; your DB is a read replica. Never mutate subscription state in your request handlers. Every state change lands via webhook. If you find yourself writing subscription.status = "active" in a Checkout success view, you’ve built a bug.
Setup
Since stripe-python v6, the SDK pins the API version at release time — v15.x currently pins 2026-06-24.dahlia. So your account’s dashboard API version is irrelevant for SDK calls, but it does control webhook payload shape unless you set api_version on the endpoint. Pin it explicitly:
# billing/stripe_client.py
import stripe
from django.conf import settings
stripe.api_key = settings.STRIPE_SECRET_KEY
stripe.max_network_retries = 2 # SDK-level idempotent retryEnv: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID_*. Publishable key only matters if you use Elements — with Checkout you don’t need it client-side at all.
Data model
Keep it thin. Don’t mirror Stripe’s schema.
class Clinic(models.Model):
stripe_customer_id = models.CharField(max_length=64, blank=True, db_index=True)
class Subscription(models.Model):
clinic = models.OneToOneField(Clinic, on_delete=models.CASCADE)
stripe_subscription_id = models.CharField(max_length=64, unique=True)
stripe_price_id = models.CharField(max_length=64)
status = models.CharField(max_length=32) # mirror Stripe verbatim
quantity = models.PositiveIntegerField(default=1) # seats
current_period_end = models.DateTimeField(null=True)
cancel_at_period_end = models.BooleanField(default=False)
updated_from_event_at = models.DateTimeField(null=True) # ordering guard
class ProcessedWebhookEvent(models.Model):
event_id = models.CharField(max_length=64, primary_key=True)
created_at = models.DateTimeField(auto_now_add=True)Entitlement check reads local state only — never call Stripe in the request path:
ACTIVE = {"active", "trialing", "past_due"} # decide if past_due gets access
def has_access(clinic):
sub = getattr(clinic, "subscription", None)
return bool(sub and sub.status in ACTIVE)Note: in recent API versions current_period_end moved off the subscription onto subscription items. Read sub["items"]["data"][0]["current_period_end"] and treat top-level as legacy.
Customer creation
Create the Stripe customer lazily, once, and guard against races (double-click → two customers is the classic bug):
def get_or_create_customer(clinic):
if clinic.stripe_customer_id:
return clinic.stripe_customer_id
with transaction.atomic():
clinic = Clinic.objects.select_for_update().get(pk=clinic.pk)
if clinic.stripe_customer_id:
return clinic.stripe_customer_id
cus = stripe.Customer.create(
email=clinic.billing_email,
name=clinic.name,
metadata={"clinic_id": str(clinic.id)},
idempotency_key=f"clinic-cus-{clinic.id}",
)
clinic.stripe_customer_id = cus.id
clinic.save(update_fields=["stripe_customer_id"])
return cus.idmetadata.clinic_id on every object you create. It’s your only lifeline when reconciling from the dashboard.
Checkout session (DRF)
class CreateCheckoutSession(APIView):
permission_classes = [IsClinicAdmin]
def post(self, request):
clinic = request.user.clinic
session = stripe.checkout.Session.create(
mode="subscription",
customer=get_or_create_customer(clinic),
line_items=[{"price": settings.STRIPE_PRICE_SEAT, "quantity": clinic.seat_count()}],
success_url=f"{settings.FRONTEND_URL}/billing?checkout=success&session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{settings.FRONTEND_URL}/billing?checkout=cancelled",
client_reference_id=str(clinic.id),
subscription_data={"metadata": {"clinic_id": str(clinic.id)}},
allow_promotion_codes=True,
)
return Response({"url": session.url})subscription_data.metadata matters — Checkout Session metadata does not propagate to the subscription.
React just redirects. No Stripe.js needed:
const { mutate } = useMutation({
mutationFn: () => api.post('/billing/checkout/').then(r => r.data),
onSuccess: ({ url }) => { window.location.href = url },
})success_url is a hint, not a fulfillment signal — the user can close the tab. Fulfil in the webhook only. Use the redirect to show a “provisioning…” state and poll/invalidate your billing query.
Customer Portal
Don’t build cancel/update-card/invoice-history UI. It’s a wasted sprint.
sess = stripe.billing_portal.Session.create(
customer=clinic.stripe_customer_id,
return_url=f"{settings.FRONTEND_URL}/billing",
)Configure the portal in the dashboard (which prices are upgradeable, whether cancellation is immediate or at period end, proration behaviour).
Webhooks — the actual integration
@csrf_exempt
@require_POST
def stripe_webhook(request):
try:
event = stripe.Webhook.construct_event(
request.body, request.META["HTTP_STRIPE_SIGNATURE"],
settings.STRIPE_WEBHOOK_SECRET,
)
except (ValueError, stripe.SignatureVerificationError):
return HttpResponse(status=400)
_, created = ProcessedWebhookEvent.objects.get_or_create(event_id=event["id"])
if not created:
return HttpResponse(status=200)
handle_stripe_event.delay(event["id"], event["type"], event["data"]["object"])
return HttpResponse(status=200)Non-negotiables:
- Raw body.
request.bodybefore anything touches it. DRF parsers will corrupt the signature — use a plain Django view, not anAPIView. - Exempt CSRF and auth. Put it on a path outside your DRF router.
- Return 200 fast, work in Celery. Stripe times out at ~20s and retries with backoff for up to 3 days. A slow handler = duplicate deliveries.
- Idempotency.
event.iddedupe table, as above. You will receive duplicates. - Out-of-order delivery. Stripe does not guarantee ordering. Guard with a monotonic field or, simplest and most robust: on any subscription event, ignore the payload’s contents and re-fetch:
@shared_task
def handle_stripe_event(event_id, event_type, obj):
if event_type.startswith(("customer.subscription.", "checkout.session.")):
sub_id = obj["id"] if event_type.startswith("customer.subscription") else obj.get("subscription")
if sub_id:
sync_subscription(stripe.Subscription.retrieve(sub_id))The re-fetch costs one API call and eliminates an entire class of ordering bugs. Do this.
Events you actually need:
| Event | Why |
|---|---|
checkout.session.completed |
Link subscription → clinic, activate |
customer.subscription.created/updated/deleted |
The real state machine (status, quantity, plan, cancel_at_period_end) |
invoice.paid |
Renewal confirmed; receipt |
invoice.payment_failed |
Dunning — email Renae/the clinic |
customer.subscription.trial_will_end |
3-day trial nudge |
Ignore the rest. payment_intent.* is noise in a subscription flow.
Per-seat pricing
Licensed pricing (quantity on the subscription item) is right for clinics — not metered. Sync seats when users are added/removed:
def sync_seats(clinic):
sub = clinic.subscription
stripe.SubscriptionItem.modify(
_item_id(sub),
quantity=clinic.seat_count(),
proration_behavior="create_prorations",
)Decisions to make deliberately:
- Proration:
create_prorations(default, charges on next invoice) vsalways_invoice(bills immediately) vsnone. For B2B clinics,create_prorationsis the sane default — nobody wants a $4.17 card charge when they add a receptionist. - Downgrade floor: enforce
quantity >= active_usersserver-side, or you’ll have 3 seats and 7 logins. - Don’t sync on every user save. Debounce via Celery, or compute at invoice time with
invoice.upcominghooks. Apost_savesignal firingSubscriptionItem.modifyon a bulk import will rate-limit you and generate 40 proration line items. - Cache the
subscription_item_idon your model. Don’t re-fetch to find it.
Idempotency keys
On every mutating call that a user can trigger twice. Key must be deterministic and scoped to the intent, not the request:
idempotency_key=f"clinic-{clinic.id}-sub-create-{plan_id}"Stripe caches the response for 24h. Note: a request with the same key but different params returns an error, which is what you want.
Testing
stripe listen --forward-to localhost:8000/api/billing/webhook/in dev. It prints a different signing secret than the dashboard — that’s the #1 local 400.stripe trigger customer.subscription.updatedfor smoke tests, but its fixtures are generic. For real tests, build a Stripe event JSON fixture and call your handler directly.- Unit tests: don’t mock
stripe.Subscription.retrievead hoc everywhere. Usestripe-mockor a thinbilling/gateway.pywrapper you can fake. - Test clocks (
stripe.test_helpers.TestClock) for trial-end/renewal flows. Genuinely useful, underused. - Test card
4242...succeeds;4000 0000 0000 0341attaches then fails on charge;4000 0000 0000 9995insufficient funds.
Australian specifics (relevant to you)
- Stripe Tax handles GST if you enable
automatic_tax={"enabled": True}on Checkout — needs customer address collection (customer_update={"address": "auto"}). Register your ATO GST obligation in the Tax settings first. - Tax invoices: Stripe’s hosted invoices are compliant if your business name/ABN is set on the account.
- Payouts are AUD to an AU bank account; 2-day rolling default.
Things that will bite you
- DRF parsing the webhook body → signature fails. Plain Django view.
- Trusting
success_urlto grant access. User closes tab, no access, angry clinic. - No
event.iddedupe → double seat charges. - Handling
checkout.session.completedonly and notcustomer.subscription.updated→ your DB drifts the moment anyone uses the portal. - Creating a second Customer for the same clinic. Unique constraint +
select_for_update. - Price IDs hardcoded per environment. Test-mode and live-mode price IDs differ. Settings, not constants.
- Deleting Prices. You can’t. Archive them. Design your price naming assuming they live forever.
sub.status == "incomplete"— SCA/3DS pending. It’s a real state you’ll hit with some AU cards.
Recommended shape
billing/
models.py # Subscription, ProcessedWebhookEvent
gateway.py # thin wrapper over stripe.* — the only module importing stripe
services.py # get_or_create_customer, sync_subscription, sync_seats
webhooks.py # plain Django view: verify, dedupe, enqueue
tasks.py # handle_stripe_event
views.py # DRF: checkout session, portal session, GET /billing/status
permissions.py # HasActiveSubscriptionm
One GET /api/billing/status/ returning {status, quantity, current_period_end, cancel_at_period_end, portal_available} feeds the whole React page via TanStack Query. Invalidate it on return from Checkout/Portal.
Skip dj-stripe. It mirrors Stripe’s entire schema into your DB and you’ll spend more time fighting its migrations and API-version lag than the ~400 lines above cost you.