KASHII UPDATEZ Everyday Student Requirements & Python Coding Tutorials by Python Kashi
KashiiUpdatez
← Back to Tech Blog

Building SaaS Stripe Payment Integration: Subscriptions, Webhooks & Proration in Django

Build a production-ready Stripe subscription system in Django with webhook handling, subscription lifecycle management, proration for plan upgrades, and the Customer Portal.

Kashinath Chavan
Kashinath Chavan
Django & Web Architecture ⏱️ 4 min read Aug 16, 2026
Follow β†—
Building SaaS Stripe Payment Integration: Subscriptions, Webhooks & Proration in Django

The Stripe Subscription Lifecycle

Most Stripe integration bugs come from misunderstanding the subscription lifecycle. Before writing a single line of code, understand the states a Stripe subscription moves through.

trialing -> active -> (payment fails) -> past_due -> (still failing) -> canceled
                                               |
                                               +-- (payment recovers) -> active
StatusAccess Granted?Charge Attempted?
trialingYesNo
activeYesYes β€” succeeded
past_dueGrace periodYes β€” failed, retrying
canceledNoNo

1. Django Models β€” Tracking Subscriptions

from django.db import models
from django.contrib.auth import get_user_model

User = get_user_model()

class Plan(models.Model):
    TIER_CHOICES = [('FREE', 'Free'), ('PRO', 'Pro'), ('ENTERPRISE', 'Enterprise')]
    name             = models.CharField(max_length=50)
    tier             = models.CharField(max_length=20, choices=TIER_CHOICES)
    stripe_price_id  = models.CharField(max_length=100, unique=True)
    price_monthly    = models.DecimalField(max_digits=8, decimal_places=2)
    features         = models.JSONField(default=dict)

class Subscription(models.Model):
    STATUS_CHOICES = [
        ('trialing', 'Trialing'), ('active', 'Active'),
        ('past_due', 'Past Due'), ('canceled', 'Canceled'),
    ]
    user                   = models.OneToOneField(User, on_delete=models.CASCADE, related_name='subscription')
    plan                   = models.ForeignKey(Plan, on_delete=models.PROTECT)
    stripe_customer_id     = models.CharField(max_length=100, blank=True)
    stripe_subscription_id = models.CharField(max_length=100, blank=True)
    status                 = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trialing')
    current_period_end     = models.DateTimeField(null=True, blank=True)
    cancel_at_period_end   = models.BooleanField(default=False)

    @property
    def is_active(self) -> bool:
        return self.status in ('active', 'trialing')

2. Creating a Stripe Checkout Session

import stripe
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required

stripe.api_key = settings.STRIPE_SECRET_KEY

@login_required
@require_POST
def create_checkout_session(request):
    plan_id = request.POST.get('plan_id')
    plan = Plan.objects.get(id=plan_id, is_active=True)

    sub, _ = Subscription.objects.get_or_create(
        user=request.user,
        defaults={'plan': Plan.objects.get(tier='FREE')}
    )

    if not sub.stripe_customer_id:
        customer = stripe.Customer.create(
            email=request.user.email,
            metadata={'django_user_id': request.user.id}
        )
        sub.stripe_customer_id = customer.id
        sub.save(update_fields=['stripe_customer_id'])

    session = stripe.checkout.Session.create(
        customer=sub.stripe_customer_id,
        payment_method_types=['card'],
        line_items=[{'price': plan.stripe_price_id, 'quantity': 1}],
        mode='subscription',
        subscription_data={'trial_period_days': 14},
        success_url=settings.DOMAIN + '/billing/success/?session_id={CHECKOUT_SESSION_ID}',
        cancel_url=settings.DOMAIN + '/pricing/',
    )

    return JsonResponse({'checkout_url': session.url})

3. Webhook Handler β€” The Critical Part

Never trust frontend callbacks to update subscription status. A user could close the browser, the callback could fail, or a clever user could fake it. Always use Stripe webhooks to update your database.
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse
import logging

logger = logging.getLogger(__name__)

@csrf_exempt
def stripe_webhook(request):
    payload = request.body
    sig_header = request.META.get('HTTP_STRIPE_SIGNATURE', '')

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except stripe.error.SignatureVerificationError:
        return HttpResponse(status=400)

    event_type = event['type']
    data = event['data']['object']

    if event_type in ('customer.subscription.created', 'customer.subscription.updated'):
        _sync_subscription(data)
    elif event_type == 'customer.subscription.deleted':
        _cancel_subscription(data)

    return HttpResponse(status=200)


def _sync_subscription(stripe_sub: dict) -> None:
    from datetime import datetime
    from django.utils.timezone import make_aware

    customer_id = stripe_sub['customer']
    try:
        sub = Subscription.objects.get(stripe_customer_id=customer_id)
    except Subscription.DoesNotExist:
        logger.error(f"No subscription for customer {customer_id}")
        return

    price_id = stripe_sub['items']['data'][0]['price']['id']
    try:
        plan = Plan.objects.get(stripe_price_id=price_id)
    except Plan.DoesNotExist:
        return

    sub.stripe_subscription_id = stripe_sub['id']
    sub.plan = plan
    sub.status = stripe_sub['status']
    sub.cancel_at_period_end = stripe_sub['cancel_at_period_end']
    if stripe_sub.get('current_period_end'):
        sub.current_period_end = make_aware(
            datetime.fromtimestamp(stripe_sub['current_period_end'])
        )
    sub.save()
    logger.info(f"Synced subscription for {sub.user.email}: {sub.status}")

4. Proration β€” Upgrading Plans Mid-Cycle

@login_required
@require_POST
def upgrade_plan(request):
    new_plan = Plan.objects.get(id=request.POST.get('plan_id'))
    sub = request.user.subscription

    stripe_sub = stripe.Subscription.retrieve(sub.stripe_subscription_id)
    current_item_id = stripe_sub['items']['data'][0]['id']

    stripe.Subscription.modify(
        sub.stripe_subscription_id,
        items=[{'id': current_item_id, 'price': new_plan.stripe_price_id}],
        proration_behavior='create_prorations',
    )

    sub.plan = new_plan
    sub.save(update_fields=['plan'])
    return JsonResponse({'success': True, 'new_plan': new_plan.name})

5. Customer Portal β€” Self-Service Billing

@login_required
def billing_portal(request):
    sub = request.user.subscription
    portal_session = stripe.billing_portal.Session.create(
        customer=sub.stripe_customer_id,
        return_url=settings.DOMAIN + '/dashboard/',
    )
    return redirect(portal_session.url)

The Stripe Customer Portal lets users update payment methods, download invoices, upgrade/downgrade plans, and cancel subscriptions β€” all hosted by Stripe with PCI compliance built in.

Topics: #Career #Developer Tools #Django #Payments #Saas #Stripe #Subscriptions #Time-Series #Webhooks
πŸ‘οΈ 3211 views

More from Django & Web Architecture

Chat Chat with Kashii