💳 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¶
- Create a Stripe account at stripe.com
- Get your API keys from Dashboard → Developers → API Keys
- 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¶
- Go to Products
- Create product (e.g., "Pro Plan")
- Add pricing (e.g., $29/month)
- 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:
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¶
-
User visits pricing page:
-
User clicks "Subscribe":
-
Stripe processes payment and creates subscription
-
Webhook updates database:
-
User redirected to success page:
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¶
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:
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¶
- Go to Stripe Dashboard → Developers → Webhooks
- Click "Add endpoint"
- Enter URL (same path in both modes):
https://yourdomain.com/billing/webhook/stripe/ - Select events to listen to
- 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:
-
Verify webhook endpoint is accessible:
-
Check webhook signing secret matches
.env -
Test with Stripe CLI:
-
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:
-
Sync from Stripe:
-
Check subscription status in Stripe Dashboard
-
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¶
- Use Test Mode - Always test with test keys first
- Implement Webhooks - Never rely solely on redirect callbacks
- Handle Idempotency - Stripe may send duplicate webhooks
- Sync Regularly - Use
djstripe_sync_modelsto catch any missed updates - Monitor Failed Payments - Set up alerts for
invoice.payment_failed - Validate on Backend - Never trust client-side data
- Use Customer Portal - Let Stripe handle payment method updates
- Test Edge Cases - Failed payments, cancellations, upgrades/downgrades
- Log Everything - Track all subscription events for debugging
- Keep Stripe Updated - Regularly update
stripeanddj-stripepackages
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¶
- Stripe Documentation
- dj-stripe Documentation
- Stripe API Reference
- Stripe Testing Guide
- Stripe Webhooks Best Practices
Related Documentation¶
- Feature Gating - Control access by subscription tier
- Feature Flags - Gradual feature rollouts
- Teams - Team subscriptions and per-seat billing