Post thumbnail
Published on
· 20 mins · Twitter

Django and Stripe Payments Tutorial

You can follow along using this GitHub repository: https://github.com/justdjango/django-stripe-tutorial

If you prefer video format, you can watch the video version of this tutorial here:

Table of Contents

  • Overview
  • Creating a Django model for products
  • Stripe Checkout
  • Webhooks
    • What are webhooks?
    • Django Integration
    • Giving access to the customer
  • Stripe Payments
    • Stripe JS
    • PaymentIntent webhook
  • Conclusion

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. We will be following a guide which can be found here. The 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 starter-files branch contains the starting files for this tutorial. The final code can be found on the master branch.

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

  1. Run migrations
  2. Create a superuser

Now let's get into it!

Modelling Products in Django

The goal of integrating Stripe is to sell a specific product. Unless we want to hardcode everything, we will need to model our product(s) using Django's Model. Our Product model will be very simple and contain only what is necessary to integrate with Stripe. We'll store this inside a new Django app called products. Make sure to register the app in your settings.py

from django.db import models

class Product(models.Model):
    name = models.CharField(max_length=100)
    price = models.IntegerField(default=0)  # cents

    def __str__(self):
        return self.name

    def get_display_price(self):
        return "{0:.2f}".format(self.price / 100)

A couple of things; the Product model has two properties; a name and a price. The price is being stored using 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.

On the model side of things, this is all we need to start working with Stripe.

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 go ahead and create those now. Copy those values and bring 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 ID
  • The session ID will be used by Stripe on the frontend
  • Stripe's JavaScript module will use the session ID to redirect us to a unique 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 Product

stripe.api_key = settings.STRIPE_SECRET_KEY

class CreateCheckoutSessionView(View):
    def post(self, request, *args, **kwargs):
        product_id = self.kwargs["pk"]
        product = Product.objects.get(id=product_id)
        YOUR_DOMAIN = "http://127.0.0.1:8000"
        checkout_session = stripe.checkout.Session.create(
            payment_method_types=['card'],
            line_items=[
                {
                    'price_data': {
                        'currency': 'usd',
                        'unit_amount': product.price,
                        'product_data': {
                            'name': product.name
                        },
                    },
                    'quantity': 1,
                },
            ],
            metadata={
                "product_id": product.id
            },
            mode='payment',
            success_url=YOUR_DOMAIN + '/success/',
            cancel_url=YOUR_DOMAIN + '/cancel/',
        )
        return JsonResponse({
            'id': checkout_session.id
        })

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

One of the parameters passed in is very important. That is the metadata. This is a dictionary of custom information we want to provide to this Checkout. Here we are passing in the product_id which contains the ID of the Product we want to purchase. This will help us later when we deal with webhooks.

At the end of the view, we return a JsonResponse which contains the session ID of the Checkout.

We also have a cancel URL and success URL specified so create two views to handle those URLs:

from django.views.generic import TemplateView

class SuccessView(TemplateView):
    template_name = "success.html"

class CancelView(TemplateView):
    template_name = "cancel.html"

Now we'll pass those views into our root URL configuration in 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')
]

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:

class ProductLandingPageView(TemplateView):
    template_name = "landing.html"

    def get_context_data(self, **kwargs):
        product = Product.objects.get(name="Test Product")
        context = super(ProductLandingPageView, self).get_context_data(**kwargs)
        context.update({
            "product": product,
            "STRIPE_PUBLIC_KEY": settings.STRIPE_PUBLIC_KEY
        })
        return context

First, 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.

You'll also notice we've added the Stripe public key inside the context of the view. This is so that in the template we can access the public key - which Stripe will need.

Let's create the template for this view. Again following from the Stripe docs you can add the following:

<!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>
          <h5>${{ product.get_display_price }}</h5>
        </div>
      </div>
      <button type="button" id="checkout-button">Checkout</button>
    </section>
    {% csrf_token %}
  </body>
  <script type="text/javascript">
    // Add Stripe JavaScript here
  </script>
</html>

For the sake of syntax highlighting I've moved the JavaScript into a separate code block:

const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value;

// Create an instance of the Stripe object with your publishable API key
var stripe = Stripe("{{ STRIPE_PUBLIC_KEY }}");
var checkoutButton = document.getElementById("checkout-button");
checkoutButton.addEventListener("click", function () {
  fetch("{% url 'create-checkout-session' product.id %}", {
    method: "POST",
    headers: {
        'X-CSRFToken': csrftoken
    }
  })
    .then(function (response) {
      return response.json();
    })
    .then(function (session) {
      return stripe.redirectToCheckout({ sessionId: session.id });
    })
    .then(function (result) {
      // If redirectToCheckout fails due to a browser or network
      // error, you should display the localized error message to your
      // customer using error.message.
      if (result.error) {
        alert(result.error.message);
      }
    })
    .catch(function (error) {
      console.error("Error:", error);
    });
});

There are a few things happening in this template.

First, we're showing the product information using the context variables. We're also loading Stripe's JS module in the head of the document.

But most importantly we're listening for the click event on the stripe-checkout button. Inside the event handler, we make a POST request to the Django server to create a Checkout Session for the product. The server returns the JsonResponse which contains the session ID. Using Stripe's JS module we call stripe.redirectToCheckout which takes the session ID and redirects the user to the Stripe Checkout page to complete the payment.


At this point, you can actually go through the checkout process. Click on the checkout button, fill in the dummy credit card information (4242 4242 4242 4242) and you should be redirected to the success page.

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"

We will now create a Django view and URL on /webhooks/stripe/.

Django Webhook Handler

We have to create a Django view that will handle the events sent to us by 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"]
        product_id = session["metadata"]["product_id"]

        product = Product.objects.get(id=product_id)

        # 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. You can see we grab the customer's email as well as the product ID. The product ID is stored in the metadata property because we passed it into the metadata when creating the Checkout Session. This is very handy because we can then grab the Product instance via its ID.

Pass this view into 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. From the webhook, we know who the customer is and what product they purchased. In this case, we're doing that by sending them an email. 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, I've made that 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.

Then, to send an email simply update the 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"]
    product_id = session["metadata"]["product_id"]

    product = Product.objects.get(id=product_id)

    # TODO - send an email to the customer
        send_mail(
        subject="Here is your product",
        message=f"Thanks for your purchase. The URL is: {product.url}",
        recipient_list=[customer_email],
        from_email="[email protected]"
    )

That's it! 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.

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'])
            product_id = self.kwargs["pk"]
            product = Product.objects.get(id=product_id)
            intent = stripe.PaymentIntent.create(
                amount=product.price,
                currency='usd',
                customer=customer['id'],
                metadata={
                    "product_id": product.id
                }
            )
            return JsonResponse({
                'clientSecret': intent['client_secret']
            })
        except Exception as e:
            return JsonResponse({ 'error': str(e) })

You can see our view is loading the request.body from JSON data. We can then extract 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 product ID, as we did in the Stripe Checkout view.

Add this view to the URLs:

from products.views import StripeIntentView

path('create-payment-intent/<pk>/', StripeIntentView.as_view(), name='create-payment-intent'),

Stripe JS

In our landing.html template add the following:

<form id="payment-form">
  <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="" target="_blank">Stripe dashboard.</a> Refresh the page to pay again.
  </p>
</form>

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

Now, in the same template, add the following JS:

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();
  // Complete payment when the submit button is clicked
  fetch("{% url 'create-payment-intent' product.id %}", {
    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");
  }
};

The most important part about this code is that 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. The rest of the JS helps with 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 about the 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']
    product_id = intent["metadata"]["product_id"]

    product = Product.objects.get(id=product_id)

    send_mail(
        subject="Here is your product",
        message=f"Thanks for your purchase. The URL is {product.url}",
        recipient_list=[customer_email],
        from_email="[email protected]"
    )

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.


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 learn.justdjango.com which has a lot of courses focused on making you become a professional Django developer.

Want to talk about this post? Get in touch with me