Add Stripe subscriptions to Django in 7 minutes 💵

Updated: Fri 26 April 2024

I'll show you the fastest way to add stripe subscriptions to your Django app.

Our sample stripe Tennis ball subscription


Here's a video of the final product we'll build, with Stripe subscriptions to slot into your Django app:


Here's an optional video guide (featuring me 🙂) walking through the written guide:


Start: Choose your approach 🚡

  • Option 1: Keep an id to the stripe objects. Use this to get your Stripe info when you need it.
  • Option 2: Keep copies of the stripe objects in the database, synchronized with Stripe.
Flaws with option 2 1. Duplication: By needing to keep your database synchronised with Stripe's, you:
  • Add the risk of synchronisation errors, and showing wrong payment data to your users (which they won't like)
  • Duplicate Stripe's data (which is easy to access)
2. Extra code cost: You need to maintain extra code to synchronise the data. If you use dj-stripe or another third-party package to do this, then you rely on their extra code to synchronise correctly.
3. Users don't care: Users don't care about you having a more sophisticated payment system. Start with the simplest system, and then add complexity if it is clearly needed.

-> For me, option 1 (keeping Stripe ids in your database and then fetching payment and subscription data when needed) clearly wins.


Edit: There's a good guide here from Zach about how to use dj-stripe if you're keen on adding a larger solution immediately.


-> I want to maximise development speed. So, we'll use option 1 below.


Side note: I used this approach for subscriptions for my product Photon Designer:

Image of Photon Designer pricing page

Edit: Thanks to TwilightOldTimer on Reddit for carefully reading the guide and pointing out two corrections. Now corrected ⭐


Enough chitter chatter. Let's start! 👨‍🚀

0. Create your Django project

pip install --upgrade django python-dotenv stripe

django-admin startproject core .
python manage.py startapp sim
  • Add our app sim to the INSTALLED_APPS in settings.py:
# settings.py
INSTALLED_APPS = [
    'sim',
    ...
]
  • Add this to the top of your settings.py to load your environment variables, including the Stripe keys:
# settings.py
from pathlib import Path
import os
from dotenv import load_dotenv


load_dotenv()

1. Create product on Stripe dashboard

We need to create a test product on the Stripe dashboard to test our subscription.

(Although many, these steps are all simple. See me doing them in 1 min in the below video.)

  1. Go to the Stripe dashboard
  2. Toggle the Test mode switch
  3. Go to the Product catalogue section (Search 'Product catalogue' in the search bar)
  4. Click 'Add product'
  5. Set your price lookup key to a unique value
  6. Activate your customer portal here
  7. Visit Stripe test webhook to point to your local server here
  8. Install the Stripe CLI (We'll use this later)
  9. Note your endpoint secret for the webhook (beginning with "whsec_". We'll add this to STRIPE_WEBHOOK_SECRET in our environment variables.

Here's a video of me doing the above:

Add your Stripe API key to your environment

  • Visit the Developers section of the Stripe dashboard to get your other API keys (we got the webhook secret earlier)
  • Create a .env file in at core/.env and add your Stripe keys:
STRIPE_SECRET_KEY=<sk_test_51>
STRIPE_PUBLIC_KEY=<pk_test_51>
STRIPE_WEBHOOK_SECRET=<whsec_51>

2. Add your model to store the necessary Stripe data

  • Add the following to your models.py:
from django.db import models
from django.contrib.auth.models import User


class CheckoutSessionRecord(models.Model):
    user = models.ForeignKey(
        User, on_delete=models.CASCADE, help_text="The user who initiated the checkout."
    )
    stripe_customer_id = models.CharField(max_length=255)
    stripe_checkout_session_id = models.CharField(max_length=255)
    stripe_price_id = models.CharField(max_length=255)
    has_access = models.BooleanField(default=False)
    is_completed = models.BooleanField(default=False)

  • Run your migrations:
python manage.py makemigrations
python manage.py migrate

Create subscription page

  • Create a templates folder in sim

  • Create a file at sim/templates/subscribe.html:

<!DOCTYPE html>
<html>
  <head>
    <title>Subscribe to a cool new product</title>
    <script src="https://js.stripe.com/v3/"></script>
  </head>
  <body>
    <header>
        <p>
            Logged in as {{ request.user.email }}
        </p>
    </header>
    <section>

<!--        Show product details-->
      <div class="product">
        <div class="description">
          <h3>Starter - Monthly tennis ball delivery 🎾</h3>
          <h5>$20.00 / month</h5>
        </div>
      </div>

<!--        Go to checkout button -->
      <form class="checkout-form" action="{%  url 'create-checkout-session' %}" method="POST">
          {% csrf_token %}

        <!-- Add a hidden field with the lookup_key of your stripe Price -->
        <input type="hidden" name="price_lookup_key" value="standard_monthly" />
        <button id="checkout-and-portal-button" type="submit">Checkout</button>
      </form>
    </section>
  </body>
</html>

<style>
    .product {
        display: flex;
        justify-content: center;
        padding: 20px 10px;
        border: 1px dashed lightgreen;
    }
    .checkout-form {
        display: flex;
        justify-content: center;
        padding: 20px 10px;
    }

</style>

Add a success page with a customer portal

  • Create a file sim/templates/success.html containing:
<!DOCTYPE html>
<html>
<head>
  <title>Thanks for your order!</title>
  <link rel="stylesheet" href="style.css">
  <script src="client.js" defer></script>
</head>
<body>
  <section>

    <div class="product Box-root">
      <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="14px" height="16px" viewBox="0 0 14 16" version="1.1">
          <defs/>
          <g id="Flow" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
              <g id="0-Default" transform="translate(-121.000000, -40.000000)" fill="#E184DF">
                  <path d="M127,50 L126,50 C123.238576,50 121,47.7614237 121,45 C121,42.2385763 123.238576,40 126,40 L135,40 L135,56 L133,56 L133,42 L129,42 L129,56 L127,56 L127,50 Z M127,48 L127,42 L126,42 C124.343146,42 123,43.3431458 123,45 C123,46.6568542 124.343146,48 126,48 L127,48 Z" id="Pilcrow"/>
              </g>
          </g>
      </svg>
      <div class="description Box-root">
        <h3>Subscription to Starter plan successful!</h3>
      </div>
    </div>

      <div> User = {{request.user}} </div>

<!--      Go to stripe customer portal to let user manage subscription. -->
    <form action="{%  url  'direct-to-customer-portal' %}" method="POST">
        {% csrf_token %}
      <input type="hidden" id="session-id" name="session_id" value="" />
      <button id="checkout-and-portal-button" type="submit">Manage your billing information</button>
    </form>
  </section>
</body>
</html>

Add a cancel page

  • Create a file sim/templates/cancel.html containing:
<!DOCTYPE html>
<html>
<head>
  <title>Checkout canceled</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <section>
    <p>Picked the wrong subscription? Shop around then come back to pay!</p>
  </section>
</body>
</html>


3. Add your views, including the endpoint for the Stripe webhook

  • Add the following to your views.py (Scroll down to the copy button to copy the whole thing):
import os
import json
from django.shortcuts import render, redirect, reverse
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt

import stripe
from django.contrib.auth import login
from django.contrib.auth.models import User
from . import models


DOMAIN = "http://localhost:8000"  # Move this to your settings file or environment variable for production.
stripe.api_key = os.environ['STRIPE_SECRET_KEY']


def subscribe(request) -> HttpResponse:
    # We login a sample user for the demo.
    user, created = User.objects.get_or_create(
        username='AlexG', email="alexg@example.com"
    )
    if created:
        user.set_password('password')
        user.save()
    login(request, user)
    request.user = user

    return render(request, 'subscribe.html')


def cancel(request) -> HttpResponse:
    return render(request, 'cancel.html')


def success(request) -> HttpResponse:

    print(f'{request.session = }')

    stripe_checkout_session_id = request.GET['session_id']

    return render(request, 'success.html')


def create_checkout_session(request) -> HttpResponse:
    price_lookup_key = request.POST['price_lookup_key']
    try:
        prices = stripe.Price.list(lookup_keys=[price_lookup_key], expand=['data.product'])
        price_item = prices.data[0]

        checkout_session = stripe.checkout.Session.create(
            line_items=[
                {'price': price_item.id, 'quantity': 1},
                # You could add differently priced services here, e.g., standard, business, first-class.
            ],
            mode='subscription',
            success_url=DOMAIN + reverse('success') + '?session_id={CHECKOUT_SESSION_ID}',
            cancel_url=DOMAIN + reverse('cancel')
        )

        # We connect the checkout session to the user who initiated the checkout.
        models.CheckoutSessionRecord.objects.create(
            user=request.user,
            stripe_checkout_session_id=checkout_session.id,
            stripe_price_id=price_item.id,
        )

        return redirect(
            checkout_session.url,  # Either the success or cancel url.
            code=303
        )
    except Exception as e:
        print(e)
        return HttpResponse("Server error", status=500)


def direct_to_customer_portal(request) -> HttpResponse:
    """
    Creates a customer portal for the user to manage their subscription.
    """
    checkout_record = models.CheckoutSessionRecord.objects.filter(
        user=request.user
    ).last()  # For demo purposes, we get the last checkout session record the user created.

    checkout_session = stripe.checkout.Session.retrieve(checkout_record.stripe_checkout_session_id)

    portal_session = stripe.billing_portal.Session.create(
        customer=checkout_session.customer,
        return_url=DOMAIN + reverse('subscribe')  # Send the user here from the portal.
    )
    return redirect(portal_session.url, code=303)


@csrf_exempt
def collect_stripe_webhook(request) -> JsonResponse:
    """
    Stripe sends webhook events to this endpoint.
    We verify the webhook signature and updates the database record.
    """
    webhook_secret = os.environ.get('STRIPE_WEBHOOK_SECRET')
    signature = request.META["HTTP_STRIPE_SIGNATURE"]
    payload = request.body

    try:
        event = stripe.Webhook.construct_event(
            payload=payload, sig_header=signature, secret=webhook_secret
        )
    except ValueError as e:  # Invalid payload.
        raise ValueError(e)
    except stripe.error.SignatureVerificationError as e:  # Invalid signature
        raise stripe.error.SignatureVerificationError(e)

    _update_record(event)

    return JsonResponse({'status': 'success'})


def _update_record(webhook_event) -> None:
    """
    We update our database record based on the webhook event.

    Use these events to update your database records.
    You could extend this to send emails, update user records, set up different access levels, etc.
    """
    data_object = webhook_event['data']['object']
    event_type = webhook_event['type']

    if event_type == 'checkout.session.completed':
        checkout_record = models.CheckoutSessionRecord.objects.get(
            stripe_checkout_session_id=data_object['id']
        )
        checkout_record.stripe_customer_id = data_object['customer']
        checkout_record.has_access = True
        checkout_record.save()
        print('🔔 Payment succeeded!')
    elif event_type == 'customer.subscription.created':
        print('🎟️ Subscription created')
    elif event_type == 'customer.subscription.updated':
        print('✍️ Subscription updated')
    elif event_type == 'customer.subscription.deleted':
        checkout_record = models.CheckoutSessionRecord.objects.get(
            stripe_customer_id=data_object['customer']
        )
        checkout_record.has_access = False
        checkout_record.save()
        print('✋ Subscription canceled: %s', data_object.id)
)

Add your urls

  • Add the following to your core/urls.py:
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('sim.urls')),
]
  • Create a urls.py file in your sim app and add the following:
from django.contrib import admin
from django.urls import path, include
from sim import views

urlpatterns = [
    path('subscribe/', views.subscribe, name='subscribe'),
    path('cancel/', views.cancel, name='cancel'),
    path('success/', views.success, name='success'),
    path('create-checkout-session/', views.create_checkout_session, name='create-checkout-session'),
    path('direct-to-customer-portal/', views.direct_to_customer_portal, name='direct-to-customer-portal'),
    path('collect-stripe-webhook/', views.collect_stripe_webhook, name='collect-stripe-webhook'),
]

4. Run your server

  • Run your server with python manage.py runserver
  • Visit http://localhost:8000/subscribe
  • Click the subscribe button and complete the payment form with the test card number 4242 4242 4242 4242 and any future date and CVC
  • Then visit http://localhost:8000/success to see your success page
  • Also, click to visit the customer portal to see your subscription

5. Test your endpoint that collect the stripe webhook

After a successful payment, Stripe will send a webhook to your endpoint. This includes events noting the payment and subscription.

We'll test this in development by running the Stripe CLI to send a synthetic event to your endpoint.

  • Install the Stripe CLI if you haven't already. This is easy with Homebrew. See the instructions in the link.
  • Login to the Stripe CLI
stripe login
  • Set your webhook to forward events to our local server. Change this if you're not using port 8000.
stripe listen --forward-to localhost:8000/collect-stripe-webhook/

Then run your server in a separate terminal, make a payment, and see the webhook event in your terminal. You should see something like this.

2050-09-29 15:00:00  <--  [200] POST http://localhost:8000/collect-stripe-webhook/ [evt_1J4]

Complete ✅ You've added Stripe subscriptions to your Django app

You've added Stripe subscriptions to your Django app. You can now manage subscriptions and payments in your Django app.

How to deploy to production with real money 💵

This is also quick to do. You'll simply need to:

  • Change your Stripe keys in your .env file to your live keys
  • Add a new product on the Stripe dashboard in live mode
  • Update your DOMAIN to your production domain (Use an environment variable or settings file to vary this)

That's it. You're now a step closer to making big bags of cash with your Django app 💰

P.S Photon Designer

Do you dream of creating Django products so quickly they break the space-time continuum? I'm building: Photon Designer. It lets you create Django UI faster than a cat jumps away from a cucumber 🥒

If you'd like to create your Django UI faster, check out Photon Designer - and prepare to build your UI faster than a photon escaping a black hole (In a friendly way).

Let's get visual.

Do you want to create beautiful django frontends effortlessly?
Click below to book your spot on our early access mailing list (as well as early adopter prices).