Django and Stripe Payments Tutorial

Django and Stripe Payments Tutorial

In this tutorial, you will learn how to setup Stripe Payments with Django. With the boom of the creator economy, being able to sell a digital product is a massive skill. The aim of this tutorial is to show how quick and simple it is to sell your own digital product using Stripe and Django.

The final code for this tutorial is in the Django Stripe Tutorial repository in the version2 branch.

If you prefer video format, you can watch the video version of this tutorial here. Note that it is using a slightly older version of the Stripe docs and so the code is different as well.

Overview

Stripe is one of the most popular solutions for handling online payments. It has a fairly simple API and official SDK's. We will make use of the Stripe Python package in this project.

Stripe provides fantastic documentation to get started. Stripe's guide shows how to build a prebuilt checkout page using Stripe Checkout.

This tutorial will start with integrating Stripe Checkout and once that is complete we will then move to use a custom payment flow with Stripe Payments.

Following Along

To get started you can create a new Django project. If you'd like to follow along with the exact code for this tutorial you can use the django-stripe-tutorial GitHub repository. The final code can be found on the master branch.

A couple of things you can do before getting into the code;

  1. Create a virtual environment
  2. Install Django with pip install django==3.2.6
  3. Create a Django project with django-admin startproject tutorial
  4. Run Django's migrations with python manage.py migrate
  5. Create a superuser with python manage.py createsuperuser
  6. Create a templates directory in the root of the project
  7. Add it to the settings.py file:
TEMPLATES = [
    {
        'DIRS': [BASE_DIR / 'templates'],
        ...
    },
]

The version of Django used in this tutorial is 3.2.6. If you are using a later version consider using this version first and then upgrading to the latest version after the tutorial

Stripe Price's

Stripe Price objects are the latest way to create pricing options for a product. Once you create a product, for e.g a Newsletter, you can create multiple pricing tiers like a $10/month option and a $20/month option, as well as once-off payment options. These pricing tiers can also managed in your Stripe Dashboard under Products.

Modelling Products in Django

The goal of this integration with Stripe is to sell one specific product. Unless you are okay with hardcoding everything, it is better to model the product(s) using Django's Model. In Stripe you can create a Product and add as many Pricing tiers to that product as you want. We will create the Django models to represent this logic as close as possible.

Make sure to register the app in your settings.py

Create two models; Product and Price. We'll store these models inside a new Django app called products.

from django.db import models
 
 
class Product(models.Model):
    name = models.CharField(max_length=100)
    stripe_product_id = models.CharField(max_length=100)
    
    def __str__(self):
        return self.name
    
 
class Price(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    stripe_price_id = models.CharField(max_length=100)
    price = models.IntegerField(default=0)  # cents
    
    def get_display_price(self):
        return "{0:.2f}".format(self.price / 100)

Run the makemigrations and migrate command.

The price field is an IntegerField. This is a personal preference but you're welcome to use another field. An IntegerField works well in this case because we can store the price of the product in cents. That way we always work with round numbers. We also have a method for displaying the price in dollars.

Configuring Stripe

Following the Stripe docs, we will want to install the Stripe Python package and save it in our requirements.

pip install stripe
pip freeze > requirements.txt

Next, we need to head into our Stripe dashboard and grab our API keys. Make sure you're in your test environment so you've got the right keys. You will need your public key and secret key. If you don't have existing API keys you can create them in your Stripe Dashboard under  Developers and then API keys. Copy those values and paste them into your Django settings file.

Create two settings variables:

STRIPE_PUBLIC_KEY = "pk_test_1234"
STRIPE_SECRET_KEY = "sk_test_1234"

Stripe Checkout Views

With Stripe configured we will add the views necessary to integrate Stripe Checkout.

Stripe Checkout works as follows:

  • Create a Checkout "session"
  • The session is unique for each checkout and has a bunch of properties
  • One of the properties is the session's URL
  • Stripe's JavaScript module will redirect us to the session URL where we will complete the checkout process by entering our card details and purchasing the product.
  • If the payment was successful we get redirected to a success URL
  • If we cancel the payment we get redirected to a cancel URL

We will create a Django view to call the Stripe API and create a Checkout Session:

import stripe
from django.conf import settings
from django.http import JsonResponse
from django.views import View
from .models import Price
 
stripe.api_key = settings.STRIPE_SECRET_KEY
 
 
class CreateCheckoutSessionView(View):
    def post(self, request, *args, **kwargs):
        price = Price.objects.get(id=self.kwargs["pk"])
        domain = "https://yourdomain.com"
        if settings.DEBUG:
            domain = "http://127.0.0.1:8000"
        checkout_session = stripe.checkout.Session.create(
            payment_method_types=['card'],
            line_items=[
                {
                    'price': price.stripe_price_id,
                    'quantity': 1,
                },
            ],
            mode='payment',
            success_url=domain + '/success/',
            cancel_url=domain + '/cancel/',
        )
        return redirect(checkout_session.url)

In this view we call stripe.checkout.Session.create and pass in some parameters which are explained in the Stripe docs.

We're passing in line_items which contain a list of items to be paid for. Each item has a price and quantity. The "price" value is not the price amount in cents or dollars. It is the Stripe ID of the Price object. You can find the ID of the price in your Stripe dashboard under the Price objects' information. When creating a Product you add Price information shown in the image below:

Adding Pricing tiers to a Product

In the image you can see the ID has a value of custom_id_1234. In this case I have passed in a custom value but if left blank the ID would have a format like this: price_1HoUSCROiJzDcnGnwPftYj4x. This price ID is what must be passed into the line_items.

At the end of the view, we return a redirect to the session URL.

Success and Cancel Views

Create two views:

from django.views.generic import TemplateView
 
 
class SuccessView(TemplateView):
    template_name = "success.html"
 
class CancelView(TemplateView):
    template_name = "cancel.html"

Add the corresponding templates for these views:

<html>
 
<head>
    <title>Checkout canceled</title>
</head>
 
<body>
    <section>
        <p>Forgot to add something to your cart? <a href="{% url 'landing' %}">Try again</a></p>
    </section>
</body>
 
</html>
<html>
 
<head>
    <title>Thanks for your order!</title>
</head>
 
<body>
    <section>
        <p>
            We appreciate your business! If you have any questions, please email
            <a href="mailto:orders@example.com">orders@example.com</a>.
        </p>
        <a href="{% url 'landing' %}">Test again</a>
    </section>
</body>
 
</html>

Now pass those views into urls.py :

from django.contrib import admin
from django.urls import path
from products.views import (
    CreateCheckoutSessionView,
    SuccessView,
    CancelView,
)
 
urlpatterns = [
    path('admin/', admin.site.urls),
    path('cancel/', CancelView.as_view(), name='cancel'),
    path('success/', SuccessView.as_view(), name='success'),
    path('create-checkout-session/<pk>/', CreateCheckoutSessionView.as_view(), name='create-checkout-session')
]

Product Landing Page

Having configured all this we now need a product landing page that will display some information about our product as well as prompt the visitor to purchase it. Here we create a basic view to do just that:

from .models import Product
 
class ProductLandingPageView(TemplateView):
    template_name = "landing.html"
 
    def get_context_data(self, **kwargs):
        product = Product.objects.get(name="Test Product")
        prices = Price.objects.filter(product=product)
        context = super(ProductLandingPageView,
                        self).get_context_data(**kwargs)
        context.update({
            "product": product,
            "prices": prices
        })
        return context

We are hardcoding the product by its name. Make sure to go to the Django admin and create a new product with the name "Test Product". The reason we're hardcoding this is just that we're focussing on selling a single product at this point. If we wanted to sell multiple products we could change this to become more dynamic. Create the Price objects for this Product in the Django admin so that they match the pricing tiers on Stripe.

Add this view to the urls.py:

from products.views import (
    ...
    ProductLandingPageView
)
 
urlpatterns = [
    path('', ProductLandingPageView.as_view(), name='landing'),
    ...
]

Create the template for the landing page inside the templates folder:

<!DOCTYPE html>
<html>
 
<head>
    <title>Buy cool new product</title>
    <link rel="stylesheet" href="style.css">
    <script src="https://polyfill.io/v3/polyfill.min.js?version=3.52.1&features=fetch"></script>
    <script src="https://js.stripe.com/v3/"></script>
</head>
 
<body>
    <section>
        <div class="product">
            <div class="description">
                <h3>{{ product.name }}</h3>
                <hr />
                {% for price in prices %}
 
                <div>
                    <h5>${{ price.get_display_price }}</h5>
                    <form action="{% url 'create-checkout-session' price.id %}" method="POST">
                        {% csrf_token %}
                        <button type="submit">Checkout</button>
                    </form>
                </div>
 
                {% endfor %}
            </div>
        </div>
    </section>
</body>
 
</html>

In this template we are looping through the price objects so that the user can decide which pricing tier to pay for.

Each pricing tier has its own form which sends a POST request to the Django view that creates the Checkout Session.

Testing Checkout View

First, make sure you have all the necessary Products and Prices created.

On Stripe create a test product and two pricing tiers: Create a Product on Stripe Dashboard

In products/admin.py add the following:

from django.contrib import admin
from .models import Product, Price
 
 
class PriceInlineAdmin(admin.TabularInline):
    model = Price
    extra = 0
 
 
class ProductAdmin(admin.ModelAdmin):
    inlines = [PriceInlineAdmin]
 
 
admin.site.register(Product, ProductAdmin)

Create the Product and Price instances in the Django admin: Creating Products and Prices in the Django admin

Notice the Stripe product ID and price ID's that come from the Stripe dashboard.

Test a Payment

At this point, you can go through the checkout process.

  1. Click on one of the checkout buttons.
  2. You should be redirected to the Stripe Checkout page.
  3. Fill in the dummy credit card information with the card number: 4242 4242 4242 4242
  4. You should be redirected to the success page.

You can also test cancelling a payment as well as test the different checkout buttons to see the price change.

Webhooks

At this point, we are able to accept payments online. But we need some way of knowing for certain that payment occurred. Landing on the success page is not proof of payment. This is where Stripe Webhooks come in.

A webhook is like a notification that an event took place. We can listen for specific events such as a payment occurring, a customer being created, and many more.

What we have to do is listen specifically for the checkout.session.completed event. When we receive this event we will be given the email of the user that checked out and be able to send that user an email giving them access to the product they purchased.

You can also follow the Stripe docs on fulfilling orders to learn more about webhooks. We will be following that guide here.

Stripe CLI

The Stripe CLI is very handy for testing webhooks. Simply install the CLI and login to your Stripe account so that you are ready to continue.

Once your CLI is installed and ready, run the following command in a new terminal:

stripe listen --forward-to localhost:8000/webhooks/stripe/

This will send events to our local Django server on the path /webhooks/stripe/

You should notice in the terminal that Stripe gives you a webhook secret key. Take that key and store it in your settings.py :

STRIPE_WEBHOOK_SECRET = "whsec_1234"

Django Webhook Handler

We will now create a Django view to handle request on /webhooks/stripe/. Here is what that view looks like:

from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse
 
@csrf_exempt
def stripe_webhook(request):
    payload = request.body
    sig_header = request.META['HTTP_STRIPE_SIGNATURE']
    event = None
 
    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except ValueError as e:
        # Invalid payload
        return HttpResponse(status=400)
    except stripe.error.SignatureVerificationError as e:
        # Invalid signature
        return HttpResponse(status=400)
 
    # Handle the checkout.session.completed event
    if event['type'] == 'checkout.session.completed':
        session = event['data']['object']
        customer_email = session["customer_details"]["email"]
        payment_intent = session["payment_intent"]
 
        # TODO - send an email to the customer
 
    return HttpResponse(status=200)

There are a couple of things happening here.

  1. We make this view exempt from requiring a CSRF token. This is because Stripe will be sending us POST requests and Django requires POST requests to contain a CSRF token. We know that Stripe won't contain the CSRF token so hence we make it exempt.
  2. We then have to verify that the webhook came from Stripe. That's what the try-except block does. In it, we construct a webhook event using the payload, Stripe signature, and our Stripe webhook secret.
  3. If the webhook is verified we can then access the data from the event. If the event type is checkout.session.completed we then execute the logic necessary to fulfil the order.

Add this view to the URLs:

from products.views import stripe_webhook
 
path('webhooks/stripe/', stripe_webhook, name='stripe-webhook'),

Giving access to the customer

The last thing to do is to give the customer access to the product they purchased. From the webhook we know who the customer is but we don't yet know what product they purchased and also at what price. Depending on the price they paid you might have different levels of access you want to provide. For example the $20 price could give the customer access to bonus material.

In this case, we're selling digital products. Most digital products are things like PDF's, E-books, Notion templates, and Airtable databases. So we can either send the actual file or a URL to the product.


I'd like to mention it's up to you on how you want to handle the process of giving access. The JustDjango Tutorial Hub is a good example in this case because all we do is send an email with a link to the Airtable database (the product).

If you want to do something more complex where you require the user to login to get access to the product, then take a look at our Build a Gumroad Clone course where we build it exactly like that.


First we will need to update our Product model to contain the actual content we're selling. Add these two fields to the model:

file = models.FileField(upload_to="product_files/", blank=True, null=True)
url = models.URLField()

In this case, the file is optional but the URL is required. This is just for simplicity. Here you should update your fields according to whatever you're trying to sell.

Remember to make migrations and migrate:

python manage.py makemigrations
python manage.py migrate

Now in the webhook we need to find out what product the user purchased. We can do this by checking the line items on the checkout session. Add the following to the end of the webhook view:

...
 
# Handle the checkout.session.completed event
if event['type'] == 'checkout.session.completed':
    session = event['data']['object']
    customer_email = session["customer_details"]["email"]
    line_items = stripe.checkout.Session.list_line_items(session["id"])
 
    stripe_price_id = line_items["data"][0]["price"]["id"]
    price = Price.objects.get(stripe_price_id=stripe_price_id)
    product = price.product

Print out the line_items to get a better idea of the data sent in the webhook. Here we are grabbing the first line item because we know there will only be one. We then get the price_id and the corresponding Price instance and Product instance in our database.

From here you can decide how to handle the rest of the order fulfilment. We will just send an email with the link to the product.

Update the end webhook event handler to look like this:

from django.core.mail import send_mail
 
...
 
# Handle the checkout.session.completed event
if event['type'] == 'checkout.session.completed':
    session = event['data']['object']
    customer_email = session["customer_details"]["email"]
    line_items = stripe.checkout.Session.list_line_items(session["id"])
 
    stripe_price_id = line_items["data"][0]["price"]["id"]
    price = Price.objects.get(stripe_price_id=stripe_price_id)
    product = price.product
 
    send_mail(
        subject="Here is your product",
        message=f"Thanks for your purchase. The URL is: {product.url}",
            recipient_list=[customer_email],
            from_email="your@email.com"
        )

Add the EMAIL_BACKEND to your settings.py file:

EMAIL_BACKEND = "django.core.mail.backends.bash.EmailBackend"

Go through the checkout process again to make sure you're receiving an email after being redirected to the success page.

We are now able to sell access to our digital product using Stripe Checkout.

Stripe Payments

The only thing you can do to make this better is to implement a custom payment flow. Stripe's guide we followed also has a walkthrough for implementing a custom payment flow.

To implement Stripe Payments we need to understand another part of Stripe's API: PaymentIntents. A PaymentIntent is an object that stores information about the payment and most importantly is linked to a Stripe Customer.

When a user goes through the payment flow we will need to create a new PaymentIntent that will be used throughout the payment flow. First we'll create a view that creates the PaymentIntent:

class StripeIntentView(View):
    def post(self, request, *args, **kwargs):
        try:
            req_json = json.loads(request.body)
            customer = stripe.Customer.create(email=req_json['email'])
            price = Price.objects.get(id=self.kwargs["pk"])
            intent = stripe.PaymentIntent.create(
                amount=price.price,
                currency='usd',
                customer=customer['id'],
                metadata={
                    "price_id": price.id
                }
            )
            return JsonResponse({
                'clientSecret': intent['client_secret']
            })
        except Exception as e:
            return JsonResponse({'error': str(e)})

The view is loading the request.body from JSON data. We then grab the customer's email from that data to create a Stripe Customer. The reason we're doing this is that we have to link the PaymentIntent to a Customer. Otherwise, we won't be able to tell who made the payment. Hence we create the Stripe Customer using the provided email and pass the Customer into the PaymentIntent.

We also pass in metadata that contains the price ID. This is a convenient way to pass in extra information about the payment. The metadata is contained in the webhook. That way we can easily know which product was purchased.

Create another view for the custom payment template:

class CustomPaymentView(TemplateView):
    template_name = "custom_payment.html"
 
    def get_context_data(self, **kwargs):
        product = Product.objects.get(name="Test Product")
        prices = Price.objects.filter(product=product)
        context = super(CustomPaymentView, self).get_context_data(**kwargs)
        context.update({
            "product": product,
            "prices": prices,
            "STRIPE_PUBLIC_KEY": settings.STRIPE_PUBLIC_KEY
        })
        return context

Add the views to the URLs:

from products.views import StripeIntentView, custom_payment_view
 
path('create-payment-intent/<pk>/', StripeIntentView.as_view(), name='create-payment-intent'),
path('custom-payment/', CustomPaymentView.as_view(), name='custom-payment')

Stripe JS

Create custom_payment.html and add the following:

<!DOCTYPE html>
<html>
 
<head>
    <title>Custom payment</title>
    <script src="https://polyfill.io/v3/polyfill.min.js?version=3.52.1&features=fetch"></script>
    <script src="https://js.stripe.com/v3/"></script>
</head>
    
<body>
    <section>
        <div class="product">
            <div class="description">
                <h3>{{ product.name }}</h3>
                <hr />
                <select>
                    {% for price in prices %}
                    <option value="{{ price.id }}">${{ price.get_display_price }}</option>
                    {% endfor %}
                </select>
            </div>
 
            <form id="payment-form">
                {% csrf_token %}
                <input type="text" id="email" placeholder="Email address" />
                <div id="card-element">
                    <!--Stripe.js injects the Card Element-->
                </div>
                <button id="submit">
                    <div class="spinner hidden" id="spinner"></div>
                    <span id="button-text">Pay</span>
                </button>
                <p id="card-error" role="alert"></p>
                <p class="result-message hidden">
                    Payment succeeded, see the result in your
                    <a href="https://dashboard.stripe.com" target="_blank">Stripe dashboard.</a> Refresh the page to
                    pay again.
                </p>
            </form>
        </div>
    </section>
    <script></script>
</body>
 
</html>

In this template we are looping through the pricing tiers inside a select tag. This is so that we can select which price we want to pay for.

You can also add some css which you'll find in the Stripe guide's global.css file.

Inside the script tag at the bottom add the following JavaScript:

var csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value;
var stripe = Stripe("{{ STRIPE_PUBLIC_KEY }}");
document.querySelector("button").disabled = true;
var elements = stripe.elements();
var style = {
    base: {
    color: "#32325d",
    fontFamily: 'Arial, sans-serif',
    fontSmoothing: "antialiased",
    fontSize: "16px",
    "::placeholder": {
        color: "#32325d"
    }
    },
    invalid: {
    fontFamily: 'Arial, sans-serif',
    color: "#fa755a",
    iconColor: "#fa755a"
    }
};
var card = elements.create("card", { style: style });
// Stripe injects an iframe into the DOM
card.mount("#card-element");
card.on("change", function (event) {
    // Disable the Pay button if there are no card details in the Element
    document.querySelector("button").disabled = event.empty;
    document.querySelector("#card-error").textContent = event.error ? event.error.message : "";
});
var form = document.getElementById("payment-form");
form.addEventListener("submit", function(event) {
    event.preventDefault();
    var selectedPrice = document.getElementById("prices").value
    // Complete payment when the submit button is clicked
    fetch(`/create-payment-intent/${selectedPrice}/`, {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        'X-CSRFToken': csrftoken
    },
    body: JSON.stringify({
        email: document.getElementById('email').value
    })
    })
    .then(function(result) {
        return result.json();
    })
    .then(function(data) {
        payWithCard(stripe, card, data.clientSecret);
    });
});
 
// Calls stripe.confirmCardPayment
// If the card requires authentication Stripe shows a pop-up modal to
// prompt the user to enter authentication details without leaving your page.
var payWithCard = function(stripe, card, clientSecret) {
    loading(true);
    stripe
    .confirmCardPayment(clientSecret, {
        payment_method: {
        card: card
        }
    })
    .then(function(result) {
        if (result.error) {
        // Show error to your customer
        showError(result.error.message);
        } else {
        // The payment succeeded!
        orderComplete(result.paymentIntent.id);
        }
    });
};
/* ------- UI helpers ------- */
// Shows a success message when the payment is complete
var orderComplete = function(paymentIntentId) {
    loading(false);
    document
    .querySelector(".result-message a")
    .setAttribute(
        "href",
        "https://dashboard.stripe.com/test/payments/" + paymentIntentId
    );
    document.querySelector(".result-message").classList.remove("hidden");
    document.querySelector("button").disabled = true;
};
// Show the customer the error from Stripe if their card fails to charge
var showError = function(errorMsgText) {
    loading(false);
    var errorMsg = document.querySelector("#card-error");
    errorMsg.textContent = errorMsgText;
    setTimeout(function() {
    errorMsg.textContent = "";
    }, 4000);
};
// Show a spinner on payment submission
var loading = function(isLoading) {
    if (isLoading) {
    // Disable the button and show a spinner
    document.querySelector("button").disabled = true;
    document.querySelector("#spinner").classList.remove("hidden");
    document.querySelector("#button-text").classList.add("hidden");
    } else {
    document.querySelector("button").disabled = false;
    document.querySelector("#spinner").classList.add("hidden");
    document.querySelector("#button-text").classList.remove("hidden");
    }
};

There is a lot happening in this script.

At the top of the file we extract the csrftoken from the page as well as load Stripe with our public key.

When the credit card form is submitted, we send a request to the Django server to create a PaymentIntent. Using the data from the response we call the payWithCard function which calls stripe.confirmCardPayment using the entered credit card details.

At the bottom there are also a lot of helpers for animating the loading effect and displaying feedback messages.

At this point, you should be able to submit the credit card form and see the payment is successful.

However, we're not receiving any webhooks for this type payment.

PaymentIntent webhook

We have to listen for a different event to handle custom payments. The event is payment_intent.succeeded. In our Django event handler add the following condition:

...
 
elif event["type"] == "payment_intent.succeeded":
    intent = event['data']['object']
 
    stripe_customer_id = intent["customer"]
    stripe_customer = stripe.Customer.retrieve(stripe_customer_id)
 
    customer_email = stripe_customer['email']
    price_id = intent["metadata"]["price_id"]
 
    price = Price.objects.get(id=price_id)
    product = price.product
 
    send_mail(
        subject="Here is your product",
        message=f"Thanks for your purchase. The URL is {product.url}",
        recipient_list=[customer_email],
        from_email="your@email.com"
    )

This should look familiar. We're doing one extra step though; we grab the Stripe Customer ID from the webhook event and then call the Stripe API to fetch the customer so that we can get the email associated with it. Once we have the customer's email we can send them an email as we did in the checkout.session.completed event. We're also grabbing the price_id from the metadata and using it to fetch the corresponding Price and Product instances from the database.

Final Test

Go to the custom payment on /custom-payment/ and test the payment process again. You should see the success message display on the page as well as have an email printed in the terminal.


And there you have it. You've now built a product landing page that uses both Stripe Checkout and Stripe Payments to sell your digital product.

Conclusion

Once again, you can find the finished code here.

There is one area of improvement that you should consider if you're using a custom payment flow. Right now we've set it up so that every time a user enters their card details in the custom payment flow, a new Stripe Customer is created. An improvement would be to store customers in a Django model. That way you can store the Stripe Customer ID locally and not have to create a new customer every time. This would also make the view a lot faster as there would be one less API call.


If you enjoyed this tutorial and you want to learn more consider JustDjango Pro which has a lot of courses focused on making you become a professional Django developer.