Skip to content

💳 Stripe Integration

Production-ready subscription billing and payment processing for Django Keel SaaS projects.

Overview

Django Keel provides two Stripe integration modes:

  • Basic Mode - Simple Stripe API integration for checkout and subscriptions
  • Advanced Mode - Full-featured integration with dj-stripe for subscription lifecycle management

Choose Basic Mode when: - You need simple one-time payments or basic subscriptions - You want minimal dependencies and lighter codebase - You prefer more control over Stripe API interactions

Choose Advanced Mode when: - You need full subscription management with models and admin - You want automatic webhook handling and database sync - You're building a complex SaaS with usage-based billing - You need customer portal, invoices, and payment methods management

Quick Comparison

Feature Basic Mode Advanced Mode
Checkout ✅ Stripe Checkout API ✅ Stripe Checkout API
Subscriptions ✅ Basic tracking ✅ Full lifecycle management
Webhooks ✅ Manual handlers ✅ Automatic sync with dj-stripe
Database Models Simple (Customer, Subscription) Full Stripe objects mirrored
Admin Interface Basic Full Stripe data management
Customer Portal ✅ Redirect to Stripe ✅ Integrated portal
Usage-Based Billing Manual implementation ✅ Built-in support
Payment Methods Via Stripe Dashboard ✅ Manage in Django
Invoice Management Via Stripe Dashboard ✅ Full invoice tracking
Dependencies stripe only stripe + dj-stripe
Complexity Lower Higher

Basic Mode

Setup

1. Get Stripe Keys

  1. Create a Stripe account at stripe.com
  2. Get your API keys from Dashboard → Developers → API Keys
  3. Get webhook secret from Dashboard → Developers → Webhooks

2. Configure Environment

# .env
STRIPE_TEST_PUBLIC_KEY=pk_test_51...
STRIPE_TEST_SECRET_KEY=sk_test_51...
STRIPE_LIVE_MODE=False
STRIPE_WEBHOOK_SECRET=whsec_...

Settings derive STRIPE_PUBLIC_KEY / STRIPE_SECRET_KEY from the test or live key pair based on STRIPE_LIVE_MODE. In production, set STRIPE_LIVE_PUBLIC_KEY, STRIPE_LIVE_SECRET_KEY, and STRIPE_LIVE_MODE=True.

3. Create Products in Stripe Dashboard

  1. Go to Products
  2. Create product (e.g., "Pro Plan")
  3. Add pricing (e.g., $29/month)
  4. Copy the Price ID (starts with price_...)

Implementation

Models (Basic Mode)

Generated models in apps/billing/models.py:

class StripeCustomer(models.Model):
    """Links Django user to Stripe customer."""

    user = models.OneToOneField(User, on_delete=models.CASCADE)
    stripe_customer_id = models.CharField(max_length=255, unique=True)
    created_at = models.DateTimeField(auto_now_add=True)


class Subscription(models.Model):
    """Tracks user subscriptions."""

    customer = models.ForeignKey(StripeCustomer, on_delete=models.CASCADE)
    stripe_subscription_id = models.CharField(max_length=255, unique=True)
    status = models.CharField(max_length=20)  # active, canceled, past_due, trialing
    plan_name = models.CharField(max_length=100)
    current_period_start = models.DateTimeField()
    current_period_end = models.DateTimeField()

Create Checkout Session

from apps.billing.utils import create_checkout_session


def checkout_view(request):
    # Your Stripe Price ID
    price_id = "price_1234567890"

    session = create_checkout_session(
        user=request.user,
        price_id=price_id,
        success_url=request.build_absolute_uri("/billing/success/"),
        cancel_url=request.build_absolute_uri("/pricing/"),
    )

    # Redirect to Stripe Checkout
    return redirect(session.url)

Handle Webhooks

Webhooks automatically update subscriptions in apps/billing/webhooks.py:

# Handled events:
# - customer.subscription.created
# - customer.subscription.updated
# - customer.subscription.deleted
# - invoice.payment_succeeded
# - invoice.payment_failed

Configure webhook endpoint in Stripe Dashboard:

https://yourdomain.com/billing/webhook/stripe/

Select events to listen to: - customer.subscription.created - customer.subscription.updated - customer.subscription.deleted - invoice.payment_succeeded - invoice.payment_failed

Customer Portal

Redirect users to Stripe Customer Portal to manage subscriptions:

from apps.billing.utils import get_customer_portal_url


def portal_view(request):
    stripe_customer = request.user.stripe_customer
    return_url = request.build_absolute_uri("/billing/subscription/")

    portal_url = get_customer_portal_url(stripe_customer.stripe_customer_id, return_url)

    return redirect(portal_url)

Basic Mode Example Flow

  1. User visits pricing page:

    # views.py
    def pricing(request):
        plans = [
            {"name": "Pro", "price": 29, "price_id": "price_..."},
            {"name": "Enterprise", "price": 99, "price_id": "price_..."},
        ]
        return render(request, "billing/pricing.html", {"plans": plans})
    

  2. User clicks "Subscribe":

    def checkout(request):
        price_id = request.POST.get("price_id")
    
        session = create_checkout_session(
            user=request.user,
            price_id=price_id,
            success_url=request.build_absolute_uri("/billing/success/"),
            cancel_url=request.build_absolute_uri("/pricing/"),
        )
    
        return redirect(session.url)
    

  3. Stripe processes payment and creates subscription

  4. Webhook updates database:

    # Webhook handler creates Subscription record
    # with status='active', plan details, etc.
    

  5. User redirected to success page:

    def success(request):
        return render(request, "billing/success.html")
    

Advanced Mode (dj-stripe)

Setup

1. Install dj-stripe

Already included when you select stripe_mode: advanced during project generation.

2. Configure Settings

The generated config/settings/base.py already contains:

# Stripe Configuration
STRIPE_LIVE_PUBLIC_KEY = env("STRIPE_LIVE_PUBLIC_KEY", default="")
STRIPE_LIVE_SECRET_KEY = env("STRIPE_LIVE_SECRET_KEY", default="")
STRIPE_TEST_PUBLIC_KEY = env("STRIPE_TEST_PUBLIC_KEY", default="")
STRIPE_TEST_SECRET_KEY = env("STRIPE_TEST_SECRET_KEY", default="")
STRIPE_LIVE_MODE = env.bool("STRIPE_LIVE_MODE", default=False)

# dj-stripe advanced integration
STRIPE_PUBLISHABLE_KEY = STRIPE_LIVE_PUBLIC_KEY if STRIPE_LIVE_MODE else STRIPE_TEST_PUBLIC_KEY
STRIPE_SECRET_KEY = STRIPE_LIVE_SECRET_KEY if STRIPE_LIVE_MODE else STRIPE_TEST_SECRET_KEY
DJSTRIPE_WEBHOOK_SECRET = env("DJSTRIPE_WEBHOOK_SECRET", default="")
DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id"
DJSTRIPE_USE_NATIVE_JSONFIELD = True

Set STRIPE_TEST_PUBLIC_KEY, STRIPE_TEST_SECRET_KEY, and DJSTRIPE_WEBHOOK_SECRET in your .env.

3. Run Migrations

python manage.py migrate djstripe

4. Sync Stripe Data

# Sync products and prices from Stripe
python manage.py djstripe_sync_models Product Price

# Sync customers (if you have existing ones)
python manage.py djstripe_sync_models Customer

Models (Advanced Mode)

dj-stripe provides models that mirror all Stripe objects:

from djstripe.models import Customer, Subscription, Price, Product, Invoice


# Your custom models
class SubscriptionMetadata(models.Model):
    """Extended subscription data."""

    subscription = models.OneToOneField(
        Subscription, on_delete=models.CASCADE, related_name="subscription_metadata"
    )
    features = models.JSONField(default=dict)
    usage_limits = models.JSONField(default=dict)
    current_usage = models.JSONField(default=dict)


class PlanConfiguration(models.Model):
    """Configure subscription plans."""

    stripe_product = models.OneToOneField(Product, on_delete=models.CASCADE)
    stripe_price = models.ForeignKey(Price, on_delete=models.CASCADE)
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)
    features = models.JSONField(default=dict)
    limits = models.JSONField(default=dict)
    is_popular = models.BooleanField(default=False)


class UsageRecord(models.Model):
    """Track metered usage."""

    subscription = models.ForeignKey(Subscription, on_delete=models.CASCADE)
    metric = models.CharField(max_length=100)  # e.g., 'api_calls'
    quantity = models.PositiveIntegerField()
    timestamp = models.DateTimeField(auto_now_add=True)

Implementation

Configure Plans

Create plan configurations in Django admin:

# In Django admin
plan = PlanConfiguration.objects.create(
    stripe_product=Product.objects.get(id="prod_..."),
    stripe_price=Price.objects.get(id="price_..."),
    name="Pro Plan",
    slug="pro",
    features={
        "api_access": True,
        "advanced_analytics": True,
        "priority_support": True,
    },
    limits={
        "api_calls": 10000,
        "team_members": 10,
        "storage_gb": 100,
    },
    is_popular=True,
)

Create Checkout Session

from apps.billing.utils import create_checkout_session


def checkout_view(request):
    price_id = request.POST.get("price_id")

    session = create_checkout_session(
        user=request.user,
        price_id=price_id,
        success_url=request.build_absolute_uri(
            "/billing/success/?session_id={CHECKOUT_SESSION_ID}"
        ),
        cancel_url=request.build_absolute_uri("/pricing/"),
        metadata={"plan_slug": "pro"},  # Custom metadata
    )

    return redirect(session.url)

Get User's Subscription

from apps.billing.utils import get_active_subscription


def dashboard_view(request):
    subscription = get_active_subscription(request.user)

    if subscription:
        # User has active subscription
        plan_config = PlanConfiguration.objects.get(stripe_product=subscription.plan.product)

        context = {
            "subscription": subscription,
            "plan": plan_config,
            "features": plan_config.features,
            "limits": plan_config.limits,
        }
    else:
        # No subscription
        context = {"subscription": None}

    return render(request, "dashboard.html", context)

Track Usage

from apps.billing.utils import record_usage, check_usage_limit, get_active_subscription


def api_endpoint(request):
    # check_usage_limit(user, metric) returns True when the user is OVER the limit
    if check_usage_limit(request.user, "api_calls"):
        return JsonResponse({"error": "API limit exceeded"}, status=429)

    # Process API request
    result = process_request(request)

    # Record usage
    subscription = get_active_subscription(request.user)
    record_usage(subscription, metric="api_calls", quantity=1)

    return JsonResponse(result)

Webhooks (Advanced)

dj-stripe automatically handles webhooks and syncs data:

# apps/billing/webhooks.py
from django.dispatch import receiver
from djstripe import webhooks


@receiver(webhooks.WEBHOOK_SIGNALS["customer.subscription.created"])
def handle_subscription_created(sender, event, **kwargs):
    """Handle new subscription."""
    subscription = event.data["object"]

    # Create metadata
    from .models import SubscriptionMetadata

    SubscriptionMetadata.objects.create(subscription_id=subscription["id"])


@receiver(webhooks.WEBHOOK_SIGNALS["invoice.payment_succeeded"])
def handle_payment_succeeded(sender, event, **kwargs):
    """Handle successful payment."""
    invoice = event.data["object"]

    # Reset usage counters for the billing period
    subscription = Subscription.objects.get(id=invoice["subscription"])
    if hasattr(subscription, "subscription_metadata"):
        subscription.subscription_metadata.current_usage = {}
        subscription.subscription_metadata.save()


@receiver(webhooks.WEBHOOK_SIGNALS["customer.subscription.deleted"])
def handle_subscription_deleted(sender, event, **kwargs):
    """Handle canceled subscription."""
    subscription = event.data["object"]

    # Revoke access, send email, etc.
    logger.info(f"Subscription canceled: {subscription['id']}")

Configure webhook endpoint in Stripe Dashboard:

https://yourdomain.com/billing/webhook/stripe/

Customer Portal

from apps.billing.utils import get_customer_portal_url


def customer_portal(request):
    return_url = request.build_absolute_uri("/billing/subscription/")
    portal_url = get_customer_portal_url(request.user, return_url)

    return redirect(portal_url)

Feature Gating with Stripe

Check Subscription Status

from apps.billing.decorators import subscription_required


@subscription_required()
def premium_feature(request):
    """Requires active subscription."""
    return render(request, "premium.html")

Check Specific Plan

from apps.billing.decorators import plan_required


@plan_required("pro")
def pro_only_feature(request):
    """Only for Pro subscribers."""
    return render(request, "pro_feature.html")


@plan_required("pro", "enterprise")
def premium_feature(request):
    """For Pro or Enterprise subscribers."""
    return render(request, "premium.html")

Check Feature Access

from apps.billing.decorators import feature_required


@feature_required("advanced_analytics")
def analytics_view(request):
    """Requires subscription with analytics feature."""
    return render(request, "analytics.html")

Usage Limits

from apps.billing.decorators import usage_limit_check


@usage_limit_check("api_calls")
def api_endpoint(request):
    """Blocked once the user is over their limit for this metric."""
    # Record usage yourself with record_usage()
    return JsonResponse({"data": "response"})

See Feature Gating for details on limits and usage recording.

Templates

Display Pricing Plans

Pass PlanConfiguration objects to the template as plans:

<div class="pricing-plans">
  {% for plan in plans %}
  <div class="plan {% if plan.is_popular %}popular{% endif %}">
    <h3>{{ plan.name }}</h3>

    <ul class="features">
      {% for feature, enabled in plan.features.items %}
        {% if enabled %}
        <li>✓ {{ feature|title }}</li>
        {% endif %}
      {% endfor %}
    </ul>

    <ul class="limits">
      {% for limit, value in plan.limits.items %}
      <li>{{ value }} {{ limit|title }}</li>
      {% endfor %}
    </ul>

    {% if user.is_authenticated %}
      <form method="post" action="{% url 'billing:checkout' %}">
        {% csrf_token %}
        <input type="hidden" name="price_id" value="{{ plan.stripe_price.id }}">
        <button type="submit" class="btn btn-primary">Subscribe</button>
      </form>
    {% else %}
      <a href="{% url 'account_signup' %}" class="btn btn-primary">Get Started</a>
    {% endif %}
  </div>
  {% endfor %}
</div>

Subscription Status

Pass the subscription from your view (e.g. via get_active_subscription(request.user)):

{% if subscription %}
<div class="subscription-status">
  <h3>Your Subscription</h3>
  <p>Plan: {{ subscription.plan.product.name }}</p>
  <p>Status: {{ subscription.status }}</p>
  <p>Renews: {{ subscription.current_period_end|date:"F j, Y" }}</p>

  <a href="{% url 'billing:customer_portal' %}" class="btn">
    Manage Subscription
  </a>
</div>
{% else %}
<div class="no-subscription">
  <p>You don't have an active subscription.</p>
  <a href="{% url 'billing:pricing' %}" class="btn">View Plans</a>
</div>
{% endif %}

Testing

Test Mode

Stripe provides test mode with test cards:

# Test cards
# Success: 4242 4242 4242 4242
# Decline: 4000 0000 0000 0002
# Require authentication: 4000 0025 0000 3155

Unit Tests (Basic Mode)

import pytest
from unittest.mock import patch, MagicMock
from apps.billing.utils import create_checkout_session


@pytest.mark.django_db
def test_create_checkout_session(user):
    """Test creating a checkout session."""
    with patch("stripe.checkout.Session.create") as mock_create:
        mock_create.return_value = MagicMock(
            id="cs_test_123", url="https://checkout.stripe.com/c/pay/cs_test_123"
        )

        session = create_checkout_session(
            user=user,
            price_id="price_123",
            success_url="https://example.com/success/",
            cancel_url="https://example.com/cancel/",
        )

        assert session.id == "cs_test_123"
        assert "checkout.stripe.com" in session.url

Webhook Tests (Advanced Mode)

import pytest
import json
from django.test import Client
from djstripe.models import Event


@pytest.mark.django_db
def test_subscription_created_webhook():
    """Test subscription.created webhook."""
    client = Client()

    payload = {
        "type": "customer.subscription.created",
        "data": {
            "object": {
                "id": "sub_123",
                "customer": "cus_123",
                "status": "active",
            }
        },
    }

    # Mock Stripe signature verification
    with patch("stripe.Webhook.construct_event") as mock_verify:
        mock_verify.return_value = payload

        response = client.post(
            "/billing/webhook/stripe/",
            data=json.dumps(payload),
            content_type="application/json",
            HTTP_STRIPE_SIGNATURE="test_signature",
        )

        assert response.status_code == 200

        # Verify event was created
        assert Event.objects.filter(type="customer.subscription.created").exists()

Integration Tests

@pytest.mark.django_db
class TestStripeIntegration:
    def test_full_subscription_flow(self, client, user):
        """Test complete subscription flow."""
        client.force_login(user)

        # 1. Visit pricing page
        response = client.get("/billing/pricing/")
        assert response.status_code == 200

        # 2. Create checkout session
        response = client.post("/billing/checkout/", {"price_id": "price_test_123"})
        assert response.status_code == 302  # Redirect to Stripe

        # 3. Simulate webhook (subscription created)
        # ... webhook handling ...

        # 4. Check subscription is active
        from apps.billing.utils import get_active_subscription

        subscription = get_active_subscription(user)
        assert subscription is not None
        assert subscription.status == "active"

Deployment

Environment Variables

# Production .env
STRIPE_LIVE_PUBLIC_KEY=pk_live_...
STRIPE_LIVE_SECRET_KEY=sk_live_...
STRIPE_LIVE_MODE=True

# Basic mode
STRIPE_WEBHOOK_SECRET=whsec_...

# Advanced mode (dj-stripe)
DJSTRIPE_WEBHOOK_SECRET=whsec_...

Webhook Configuration

  1. Go to Stripe Dashboard → Developers → Webhooks
  2. Click "Add endpoint"
  3. Enter URL (same path in both modes): https://yourdomain.com/billing/webhook/stripe/
  4. Select events to listen to
  5. Copy webhook signing secret to .env

Production Checklist

  • [ ] Switch to live API keys (pk_live_..., sk_live_...)
  • [ ] Configure production webhook endpoint
  • [ ] Test webhooks with Stripe CLI: stripe listen --forward-to localhost:8000/billing/webhook/stripe/
  • [ ] Enable Stripe Radar for fraud protection
  • [ ] Set up billing email notifications
  • [ ] Configure customer portal settings
  • [ ] Test with production Stripe account
  • [ ] Set up monitoring for failed payments
  • [ ] Document subscription cancellation policy
  • [ ] Configure tax collection (Stripe Tax)

Troubleshooting

Webhook Not Received

Symptoms: Subscriptions not updating after payment

Solutions:

  1. Verify webhook endpoint is accessible:

    curl https://yourdomain.com/billing/webhook/stripe/
    

  2. Check webhook signing secret matches .env

  3. Test with Stripe CLI:

    stripe listen --forward-to localhost:8000/billing/webhook/stripe/
    stripe trigger customer.subscription.created
    

  4. Check webhook logs in Stripe Dashboard

Checkout Session Expired

Symptoms: Users get "expired session" error

Solution: Checkout sessions expire after 24 hours. Create a new session if needed:

# Check if session is recent
from datetime import timedelta
from django.utils import timezone

if session.created < timezone.now() - timedelta(hours=23):
    # Create new session
    session = create_checkout_session(...)

Subscription Not Found

Symptoms: get_active_subscription() returns None

Solutions:

  1. Sync from Stripe:

    python manage.py djstripe_sync_models Subscription Customer
    

  2. Check subscription status in Stripe Dashboard

  3. Verify webhook was received and processed

Invoice Payment Failed

Symptoms: Subscription shows past_due

Solution: Stripe automatically retries failed payments. Configure retry logic in Billing settings.

Send email notification to customer:

@receiver(webhooks.WEBHOOK_SIGNALS["invoice.payment_failed"])
def handle_payment_failed(sender, event, **kwargs):
    invoice = event.data["object"]
    customer_email = invoice["customer_email"]

    send_mail(
        subject="Payment Failed",
        message="Your recent payment failed. Please update your payment method.",
        from_email="billing@yourdomain.com",
        recipient_list=[customer_email],
    )

Best Practices

  1. Use Test Mode - Always test with test keys first
  2. Implement Webhooks - Never rely solely on redirect callbacks
  3. Handle Idempotency - Stripe may send duplicate webhooks
  4. Sync Regularly - Use djstripe_sync_models to catch any missed updates
  5. Monitor Failed Payments - Set up alerts for invoice.payment_failed
  6. Validate on Backend - Never trust client-side data
  7. Use Customer Portal - Let Stripe handle payment method updates
  8. Test Edge Cases - Failed payments, cancellations, upgrades/downgrades
  9. Log Everything - Track all subscription events for debugging
  10. Keep Stripe Updated - Regularly update stripe and dj-stripe packages

Common Patterns

Free Trial

# Create subscription with trial
session = stripe.checkout.Session.create(
    customer_email=user.email,
    payment_method_types=["card"],
    line_items=[
        {
            "price": price_id,
            "quantity": 1,
        }
    ],
    mode="subscription",
    subscription_data={
        "trial_period_days": 14,  # 14-day free trial
    },
    success_url=success_url,
    cancel_url=cancel_url,
)

Usage-Based Pricing

# Metered billing
from apps.billing.utils import record_usage


def api_call(request):
    # Process request
    result = process_request(request)

    # Record usage (will be billed at end of period)
    subscription = get_active_subscription(request.user)
    record_usage(subscription, metric="api_calls", quantity=1)

    return JsonResponse(result)

Per-Seat Pricing

# Update subscription quantity when team size changes
import stripe


def add_team_member(request):
    team = request.user.team
    team.members.add(new_member)

    # Update Stripe subscription quantity
    subscription = team.stripe_subscription
    stripe.Subscription.modify(
        subscription.id,
        items=[
            {
                "id": subscription["items"]["data"][0].id,
                "quantity": team.members.count(),
            }
        ],
    )

Proration

# Upgrade/downgrade with proration
stripe.Subscription.modify(
    subscription.id,
    items=[
        {
            "id": subscription["items"]["data"][0].id,
            "price": new_price_id,
        }
    ],
    proration_behavior="create_prorations",  # or 'always_invoice', 'none'
)

Further Reading