💳 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_PUBLIC_KEY=pk_test_51...
STRIPE_SECRET_KEY=sk_test_51...
STRIPE_WEBHOOK_SECRET=whsec_...
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¶
# config/settings/base.py
INSTALLED_APPS = [
# ...
'djstripe',
]
# Stripe Configuration
STRIPE_LIVE_PUBLIC_KEY = env('STRIPE_PUBLIC_KEY')
STRIPE_LIVE_SECRET_KEY = env('STRIPE_SECRET_KEY')
STRIPE_TEST_PUBLIC_KEY = env('STRIPE_PUBLIC_KEY')
STRIPE_TEST_SECRET_KEY = env('STRIPE_SECRET_KEY')
STRIPE_LIVE_MODE = False # Set to True in production
DJSTRIPE_WEBHOOK_SECRET = env('STRIPE_WEBHOOK_SECRET')
DJSTRIPE_FOREIGN_KEY_TO_FIELD = 'id'
DJSTRIPE_USE_NATIVE_JSONFIELD = True
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='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
def api_endpoint(request):
subscription = get_active_subscription(request.user)
# Check if user is within limits
can_use, remaining = check_usage_limit(
subscription,
metric='api_calls',
limit=10000
)
if not can_use:
return JsonResponse({'error': 'API limit exceeded'}, status=429)
# Process API request
result = process_request(request)
# Record usage
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, 'metadata'):
subscription.metadata.current_usage = {}
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
@usage_limit('api_calls', limit=10000, period='month')
def api_endpoint(request):
"""Limited to 10,000 API calls per month."""
# Usage tracked automatically
return JsonResponse({'data': 'response'})
Templates¶
Display Pricing Plans¶
{% load billing_tags %}
<div class="pricing-plans">
{% for plan in plans %}
<div class="plan {% if plan.is_popular %}popular{% endif %}">
<h3>{{ plan.name }}</h3>
<p class="price">${{ plan.stripe_price.unit_amount|divide:100 }}/mo</p>
<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¶
{% load billing_tags %}
{% if user.has_active_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 %}
Usage Meters¶
{% load billing_tags %}
{% get_usage user 'api_calls' as api_usage %}
<div class="usage-meter">
<h4>API Calls</h4>
<p>{{ api_usage.current }} / {{ api_usage.limit }}</p>
<div class="progress-bar">
<div class="progress" style="width: {{ api_usage.percentage }}%"></div>
</div>
{% if api_usage.is_near_limit %}
<p class="warning">⚠️ Approaching limit. Consider upgrading.</p>
{% endif %}
{% if api_usage.is_over_limit %}
<p class="error">❌ Limit exceeded. Please upgrade to continue.</p>
{% endif %}
</div>
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(
'/djstripe/webhook/',
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_PUBLIC_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# For dj-stripe
DJSTRIPE_WEBHOOK_SECRET=whsec_...
Webhook Configuration¶
- Go to Stripe Dashboard → Developers → Webhooks
- Click "Add endpoint"
- Enter URL:
- Basic mode:
https://yourdomain.com/billing/webhook/ - Advanced mode:
https://yourdomain.com/djstripe/webhook/ - 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/djstripe/webhook/ - [ ] 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