Stupe

payment management for SaaS
Author

Benedict Thekkel

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 retry

Env: 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.id

metadata.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.body before anything touches it. DRF parsers will corrupt the signature — use a plain Django view, not an APIView.
  • 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.id dedupe 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) vs always_invoice (bills immediately) vs none. For B2B clinics, create_prorations is the sane default — nobody wants a $4.17 card charge when they add a receptionist.
  • Downgrade floor: enforce quantity >= active_users server-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.upcoming hooks. A post_save signal firing SubscriptionItem.modify on a bulk import will rate-limit you and generate 40 proration line items.
  • Cache the subscription_item_id on 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.updated for 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.retrieve ad hoc everywhere. Use stripe-mock or a thin billing/gateway.py wrapper you can fake.
  • Test clocks (stripe.test_helpers.TestClock) for trial-end/renewal flows. Genuinely useful, underused.
  • Test card 4242... succeeds; 4000 0000 0000 0341 attaches then fails on charge; 4000 0000 0000 9995 insufficient 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

  1. DRF parsing the webhook body → signature fails. Plain Django view.
  2. Trusting success_url to grant access. User closes tab, no access, angry clinic.
  3. No event.id dedupe → double seat charges.
  4. Handling checkout.session.completed only and not customer.subscription.updated → your DB drifts the moment anyone uses the portal.
  5. Creating a second Customer for the same clinic. Unique constraint + select_for_update.
  6. Price IDs hardcoded per environment. Test-mode and live-mode price IDs differ. Settings, not constants.
  7. Deleting Prices. You can’t. Archive them. Design your price naming assuming they live forever.
  8. sub.status == "incomplete" — SCA/3DS pending. It’s a real state you’ll hit with some AU cards.
Back to top