In this tutorial we're going to build a basic page with React to accept payments using PayPal.

Introduction

Services like Gumroad are useful for selling access to digital products like Airtable databases, Notion templates and other private links. In this tutorial you will learn how to sell digital products using PayPal payments.

We will use PayPal Checkout to handle the payments and use Django to provide access to the private content.

If you'd prefer to watch the video instead of read:

Prerequisites

This tutorial is better for learners who are already comfortable working with Django and React. If you are new to Django consider watching Getting Started with Django first. To continue with the tutorial make sure:

  • You have a good understanding of the basics of Django
  • You have a good understanding of the basics of React
  • You are familiar with TypeScript. However, it's not required

Setting up your PayPal business account

Go to the PayPal website and create an account. When creating your account make sure you select the type of account as a "business account" in order to have all of the necessary tools visible in your account. If you already have an existing PayPal account that is a personal account you can always upgrade it to a business account in your profile settings.

PayPal developer documentation

Once you have an account head to the PayPal developer website. There are two important links in the navigation bar; Docs and Tools . Under Docs you will find all of the documentation and tutorials to start accepting payments. Under Tools you will find more information about PayPal's sandbox and API tools.

Hovering over Docs,  click on the Overview.  Now, in the sidebar, under Developer Resources click on Get Started. You should now be here. This page is explains how to obtain API credentials. We will not be working with the API as much as this documentation shows. However, follow the first four steps shown. Those steps are as follows:

  1. Log in to the Developer Dashboard with your PayPal account.
  2. Under the DASHBOARD menu, select My Apps & Credentials.
  3. Make sure you're on the Sandbox tab to get the API credentials you'll use while you're developing code. After you test and before you go live, switch to the Live tab to get live credentials.
  4. Under the App Name column, select Default Application, which PayPal creates with a new Developer Dashboard account. Select Create App if you don't see the default app.

Under your Default Application you will see your credentials. Take note of your Client ID and Secret. You will need both of these later.

PayPal Sandbox Account

When developing and testing you can use fake accounts to login to PayPal and pay for things. In the DASHBOARD menu select Accounts. You will see two accounts. One business type account and one personal type account. You can use the business account to receive funds and the personal account as the account that pays. You can login to both of these accounts. Click on the (...) button to View/Edit the account. There you will find the password for each sandbox account.

Django Project Setup

We are going to use the Cookiecutter Django package to bootstrap our Django project. This is a great tool for generating production-ready Django projects while not having to deal with as much boilerplate code. If you're not familiar with it you can read more on Dockerizing Django using Cookiecutter.

Install Cookiecutter

To use Cookiecutter Django you'll need to have Cookiecutter installed. Cookiecutter is a Python package that is recommended to be globally installed so that you can use it at any point in your terminal.

Install Cookiecutter:

pip install cookiecutter

Create a Virtual Environment

The type of virtual environment is up to you. I prefer virtualenv.

virtualenv venv
source venv/bin/activate

Create the Django Project

Inside your project folder run this command to use the Cookiecutter Django template:

cookiecutter gh:pydanny/cookiecutter-django

This command will output a few questions you need to answer about the project you want to build. Once the project is generated you can run the project locally or using Docker - whichever is your preference.

I will be using Docker to run the project. If you haven't used Docker before, consider learning the basics along with Cookiecutter.

Build the images:

docker-compose -f local.yml build

And then run the containers with Docker Compose:

docker-compose -f local.yml up

Once the containers are up you should see the Django project load on your localhost.

The version of Django used in this tutorial is 3.1.13 which is version specified in the Cookiecutter Django.

React Project Setup

We will use CRA (Create React App) to create the React project. Read the getting started guide to learn more about Create React App.

Integrating Django and React

One of the trickiest parts of working with Django and React is deciding how to architect the project structure. There are a few ways to integrate Django and React. In this tutorial we will setup the project folder as a monorepo (single repository contain both projects).

Create React App

We will use the npx command to create the React project. This is installed when installing npm . We will also pass in the typescript flag to bootstrap the project configured with TypeScript.

Where the frontend folder is placed is up to you but for consistency we are going to run this command inside the django_react_paypal folder in the Django project so that the React project folder is placed alongside the Django templates folder.

npx create-react-app frontend --template typescript
The version of React used in this tutorial is 17.0.2

Dockerizing React

Since the Django project is Dockerized it makes sense to Dockerize the React project as well. We already have a local.yml file for Docker Compose that contains all of the services of the project so we will add a React service to this file. But first we will create a Dockerfile for the React Image.

Inside frontend create a folder compose/local and inside that create a Dockerfile with the following contents:

FROM node:14-alpine AS development
ENV NODE_ENV development

WORKDIR /app

# Cache and Install dependencies - very important step
COPY package.json .
COPY package-lock.json .

RUN npm install

COPY . .

EXPOSE 3000

CMD [ "npm", "run", "start" ]

Take note of the COPY commands. These are very important because they cache the dependencies of the project.

Now inside the local.yml file add the react service:

services:
  
  ...

  react:
    build:
      context: ./django_react_paypal/frontend
      dockerfile: ./compose/local/Dockerfile
      target: development
    image: django_react_paypal_local_react
    container_name: django_react_paypal_react
    volumes:
      - ./django_react_paypal/frontend/src:/app/src
    ports:
      - 3000:3000

The service points to the frontend folder as the root of the code and also points to the corresponding Dockerfile inside the frontend folder to build the React image.

After updating the local.yml file you will need to rebuild the Docker images with docker-compose -f local.yml build to implement the new changes.

Now after running docker-compose -f local.yml up you should have four services running; Django, Postgres, Docs and React.

Selling Once-Off Digital Products

With all of the project configuration done we can now start with the PayPal payments. Before writing any code it's important to understand how the payments will give users access to the products they purchase.

The image above shows the process for how a user gains access to the digital product. The steps are as follows:

  1. The user visits the page and starts the payment process by clicking on the PayPal button
  2. After a successful payment the user is redirected to a success page
  3. PayPal sends a webhook event to the Django backend confirming a new order
  4. Django sends the user an email with the URL of the product

PayPal Checkout

Login to your PayPal business dashboard. In the navigation bar, under "Pay and Get Paid", click on PayPal Checkout. You can use PayPal Checkout for simple fixed price payments or for a shopping cart experience. Clicking on the first button to "Start Setup" will bring you to a page to configure the PayPal Checkout details. If you were using standard HTML pages you could fill in these details and copy the code from this PayPal Checkout to your page and you'd be finished. Of course for React it will be different but it's good to understand how the underlying code will look.

PayPal React Package

We are going to use the official PayPal React package to setup PayPal in the React project.

In the frontend project install this package with:

npm install @paypal/react-paypal-js

Now inside App.tsx replace everything with the following:

import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";

export default function App() {
    return (
        <PayPalScriptProvider options={{ "client-id": "test" }}>
            <PayPalButtons style={{ layout: "horizontal" }} />
        </PayPalScriptProvider>
    );
}

The PayPalScriptProvider requires our PayPal client ID. We will add this as an environment variable.

Replace "test" with process.env.REACT_APP_PAYPAL_CLIENT_ID. This will load the variable REACT_APP_PAYPAL_CLIENT_ID from environment variables. React requires that all environment variable names start with REACT_APP.

Now create a file .env.local in the root of the frontend folder and inside it put the following:

REACT_APP_PAYPAL_CLIENT_ID=your-paypal-client-id

Make sure to replace it with your own client ID.

Lastly we need to tell TypeScript that REACT_APP_PAYPAL_CLIENT_ID is a valid environment variable. Inside react-app-env.d.ts add the following:

declare namespace NodeJS {
    export interface ProcessEnv {
        REACT_APP_PAYPAL_CLIENT_ID: string;
    }
}

This declares the environment variables for the project which includes the name and type of each variable.

If you are using Docker remember to rebuild the image before running the containers. You should now see a PayPal button when the frontend opens in the browser.

Payments Component

Since we're selling access to a digital product lets make the page look like we're actually selling something. We'll create a React component to contain all the logic for selling the once-off payment product.

Inside the src folder create a components folder and create a file Payment.tsx inside of it. Add the following to it:

import { PayPalButtons } from "@paypal/react-paypal-js";

export function Payment() {
    return (
        <div className="card">
            <img src="https://images.unsplash.com/photo-1594498257673-9f36b767286c?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1350&q=80" alt="Airtable product" style={{ width: '100%' }} />
            <div className="card-details">
                <h1>Airtable Product</h1>
                <p className="price">$10.00</p>
                <p>Some information about the product</p>
                <PayPalButtons style={{ layout: "horizontal" }} />
            </div>
        </div>
    )
}

Replace the contents of index.css with the following:

body {
  background-color: #fdf1ec;
} 

h1, img {
  margin: 0;
  padding: 0;
}

.card {
  box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
  max-width: 300px;
  margin: auto;
  text-align: center;
  font-family: arial;
}

.card h1, p {
  color: #474747;
}

.card-details {
  background-color: #ffffff;
  padding-bottom: 10px;
  padding-left: 5px;
  padding-right: 5px;
}

.price {
  color: grey;
  font-size: 22px;
}

Delete App.css and logo.svg as we no longer need them.

Now inside App.tsx use the new component:

import { PayPalScriptProvider } from "@paypal/react-paypal-js";
import { Payment } from './components/Payment'

export default function App() {
  return (
    <PayPalScriptProvider options={{ "client-id": process.env.REACT_APP_PAYPAL_CLIENT_ID }}>
      <Payment />
    </PayPalScriptProvider>
  );
}

You should now see the following:

PayPalButtons Props

The PayPalButtons component takes a lot of props. You can find the props inside node_modules/@paypal/paypal-js/types/components/buttons.d.ts or by using CMD + click on the component to take you directly to its props.

The props are as follows:

export interface PayPalButtonsComponentOptions {
    /**
     * Called on button click. Often used for [Braintree vault integrations](https://developers.braintreepayments.com/guides/paypal/vault/javascript/v3).
     */
    createBillingAgreement?: () => Promise<string>;
    /**
     * Called on button click to set up a one-time payment. [createOrder docs](https://developer.paypal.com/docs/business/javascript-sdk/javascript-sdk-reference/#createorder).
     */
    createOrder?: (
        data: UnknownObject,
        actions: CreateOrderActions
    ) => Promise<string>;
    /**
     * Called on button click to set up a recurring payment. [createSubscription docs](https://developer.paypal.com/docs/business/javascript-sdk/javascript-sdk-reference/#createsubscription).
     */
    createSubscription?: (
        data: UnknownObject,
        actions: CreateSubscriptionActions
    ) => Promise<string>;
    /**
     * Used for defining a standalone button.
     * Learn more about [configuring the funding source for standalone buttons](https://developer.paypal.com/docs/business/checkout/configure-payments/standalone-buttons/#4-funding-sources).
     */
    fundingSource?: string;
    /**
     * Called when finalizing the transaction. Often used to inform the buyer that the transaction is complete. [onApprove docs](https://developer.paypal.com/docs/business/javascript-sdk/javascript-sdk-reference/#onapprove).
     */
    onApprove?: (
        data: OnApproveData,
        actions: OnApproveActions
    ) => Promise<void>;
    /**
     * Called when the buyer cancels the transaction.
     * Often used to show the buyer a [cancellation page](https://developer.paypal.com/docs/business/checkout/add-capabilities/buyer-experience/#3-show-cancellation-page).
     */
    onCancel?: (data: UnknownObject, actions: OnCancelledActions) => void;
    /**
     * Called when the button is clicked. Often used for [validation](https://developer.paypal.com/docs/checkout/integration-features/validation/).
     */
    onClick?: (
        data: UnknownObject,
        actions: OnClickActions
    ) => Promise<void> | void;
    /**
     * Catch all for errors preventing buyer checkout.
     * Often used to show the buyer an [error page](https://developer.paypal.com/docs/checkout/integration-features/handle-errors/).
     */
    onError?: (err: UnknownObject) => void;
    /**
     * Called when the buttons are initialized. The component is initialized after the iframe has successfully loaded.
     */
    onInit?: (data: UnknownObject, actions: OnInitActions) => void;
    /**
     * Called when the buyer changes their shipping address on PayPal.
     */
    onShippingChange?: () => void;
    /**
     * [Styling options](https://developer.paypal.com/docs/business/checkout/reference/style-guide/#customize-the-payment-buttons) for customizing the button appearance.
     */
    style?: {
        color?: "gold" | "blue" | "silver" | "white" | "black";
        height?: number;
        label?:
            | "paypal"
            | "checkout"
            | "buynow"
            | "pay"
            | "installment"
            | "subscribe"
            | "donate";
        layout?: "vertical" | "horizontal";
        shape?: "rect" | "pill";
        tagline?: boolean;
    };
}

You can go through each of the links to understand more about what each function does and when it is called during the payment process. For one-time payments we are interested in the createOrder function.

You can also take a look at all of their Storybook examples to get an idea for how to use the component.

In Payment.tsx update the PaymentButtons component to look like this:

<PayPalButtons
       style={{ layout: "horizontal" }}
       createOrder={(data, actions) => {
           return actions.order.create({
               purchase_units: [
                   {
                       amount: {
                           value: "10.00",
                       },
                   },
               ],
           });
       }}
   />

This will use the createOrder function to pass in an amount of $10.00 as the one-time payment amount.

Try to Pay

Try clicking on the PayPal button. It should start the payment process by opening a modal to login to PayPal. Remember to login using your personal sandbox account. Once logged in you will choose how to pay the $10.00. After payment the modal will close.

Cancelled Payments

We are going to use the React Hot Toast package to display success and error messages based on the status of the payment. We'll do this by using some of the methods on the  PayPalButtons component like onError , onApprove and onCancel.

Install the package with:

npm i react-hot-toast

Inside App.tsx add the Toaster component:

import { PayPalScriptProvider } from "@paypal/react-paypal-js";
import { Toaster } from "react-hot-toast";
import { Payment } from './components/Payment'

export default function App() {
  return (
    <PayPalScriptProvider options={{ "client-id": process.env.REACT_APP_PAYPAL_CLIENT_ID }}>
      <Toaster position="top-center" />
      <Payment />
    </PayPalScriptProvider>
  );
}

Now inside Payment.tsx we will add the onCancel function to the PayPalButtons component:

onCancel={() => toast(
    "You cancelled the payment. Try again by clicking the PayPal button", 
    {
        duration: 6000
    }
)}

Add the toast import at the top:

import toast from "react-hot-toast";

To test the notification click the PayPal button and then cancel the payment. You should see the notification at the top of the page.

Payment Errors

For the onError function we will add a callback function:

onError={(err) => {
    toast.error("There was an error processing your payment. If this error please contact support.", { duration: 6000 });
    }
}

Errors occurring at this point are unexpected and usually just result in a generic error message or error page.

Successful Payments

After a payment it's good to show some sort of success message to the user. This could be a notification popup on the page or a redirect to a /success URL.

We will make use of the onApprove callback which is normally used to show a message to the buyer to indicate the payment was successful.

Add the onApprove callback function:

onApprove={(data, actions) => {
    return actions.order.capture().then(function (details) {
        toast.success('Payment completed. Thank you, ' + details.payer.name.given_name)
    });
}}

Go through the payment process again and you should see the success message.

Webhooks

Now that we are accepting payments we need to give access to the content that the user purchased. This is where webhooks come in. A webhook is an event sent from PayPal to the Django server. It is the most reliable way to know that a payment was successful.

We are going to write a view that will handle the webhook event and send an email to the customer that purchased the content.

Tunnelling localhost to https

PayPal will only accept an HTTPS URL as the endpoint to send events to. A service like ngrok provides secure URLs to your localhost. Create an account and follow the steps to setup ngrok.

Once downloaded you can use ngrok with the Django server by running this command inside a terminal:

./ngrok http 8000
Make sure the ngrok executable is available in your folder

In the terminal ngrok will output the HTTPS URL for your session. You will need to add the URL to your ALLOWED_HOSTS in settings/local.py. For example if your ngrok URL is https://12345.ngrok.io then add 12345.ngrok.io to the ALLOWED_HOSTS.

Configuring Webhooks in PayPal

In the DASHBOARD menu select My Apps and Credentials. Select your Default Application. At the bottom of the page under Sandbox Webhooks you can add a new webhook and select all of the events to be sent. For the Webhook URL use your ngrok endpoint with a unique path at the end.

For example, if your ngrok endpoint is https://12345.ngrok.io the webhook URL could be something like https://12345.ngrok.io/webhooks/paypal/ . We will then create a Django view to handle requests on /webhooks/paypal/.

Each created webhook will show its Webhook ID so take note of it because Django will need it.

At the bottom of settings/base.py add your PayPal credentials:

PAYPAL_CLIENT_ID = env("PAYPAL_CLIENT_ID")
PAYPAL_CLIENT_SECRET = env("PAYPAL_CLIENT_SECRET")
PAYPAL_WEBHOOK_ID = env("PAYPAL_WEBHOOK_ID")

Remember to add these credentials to your environment variables as well.

Django Payments App

Start by creating an app to contain all of the payments logic. If you're using Docker you will need to run the following command:

docker-compose -f local.yml run --rm django python manage.py startapp payments

Move the payments folder inside django_react_paypal so it is alongside the other apps. Inside settings/base.py add the app to the LOCAL_APPS:

LOCAL_APPS = [
    "django_react_paypal.users.apps.UsersConfig",
    "django_react_paypal.payment.apps.PaymentConfig",
]

Now change the PaymentsConfig class in payments/apps.py to look like this:

from django.apps import AppConfig


class PaymentsConfig(AppConfig):
    name = "django_react_paypal.payments"
We are changing the name of the app to be consistent with the other apps

Webhook View

The paypalrestsdk package can be used to interact with PayPal. Although the package has been deprecated you can continue to use it. We are going to use this package to help verify webhooks come from PayPal.

Add the package to requirements/base.txt:

paypalrestsdk==1.13.1  # https://github.com/paypal/PayPal-Python-SDK

In payments/views.py add the following:

import json

from django.conf import settings
from django.http import HttpResponse, HttpResponseBadRequest
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View

from paypalrestsdk import notifications


@method_decorator(csrf_exempt, name="dispatch")
class ProcessWebhookView(View):
    def post(self, request):
        if "HTTP_PAYPAL_TRANSMISSION_ID" not in request.META:
            return HttpResponseBadRequest()

        auth_algo = request.META['HTTP_PAYPAL_AUTH_ALGO']
        cert_url = request.META['HTTP_PAYPAL_CERT_URL']
        transmission_id = request.META['HTTP_PAYPAL_TRANSMISSION_ID']
        transmission_sig = request.META['HTTP_PAYPAL_TRANSMISSION_SIG']
        transmission_time = request.META['HTTP_PAYPAL_TRANSMISSION_TIME']
        webhook_id = settings.PAYPAL_WEBHOOK_ID
        event_body = request.body.decode(request.encoding or "utf-8")

        valid = notifications.WebhookEvent.verify(
            transmission_id=transmission_id,
            timestamp=transmission_time,
            webhook_id=webhook_id,
            event_body=event_body,
            cert_url=cert_url,
            actual_sig=transmission_sig,
            auth_algo=auth_algo,
        )

        if not valid:
            return HttpResponseBadRequest()

        webhook_event = json.loads(event_body)

        event_type = webhook_event["event_type"]

        print(event_type)

        return HttpResponse()

This view starts by checking for the HTTP_PAYPAL_TRANSMISSION_ID value in the request.META. This is a header in the request and is always included when coming from PayPal.

We then use the header values, our PAYPAL_WEBHOOK_ID and the decoded request body as arguments to the WebhookEvent.verify function. This calls the PayPal API to verify that the webhook came from PayPal. If the webhook is valid we then load the request body into a json object and print the type of event.

In config/urls.py add the view to the path you set for the webhook URL:

...
from django_paypal_react.payments.views import ProcessWebhookView


urlpatterns = [
    ...
    path('webhooks/paypal/', ProcessWebhookView.as_view())
]

Complete a payment on the React frontend and you should see CHECKOUT.ORDER.APPROVED printed in your terminal. This is the type of event sent by PayPal when a Checkout is completed.

You can also explore the webhook event by printing the entire event and looking at all of the values included:

from pprint import pprint
pprint(webhook_event)
pprint prints objects with prettified format - it's very helpful for large data structures like webhook events

In the event you can access the email address of the customer with:

webhook_event["resource"]["payer"]["email_address"]

Now we can send an email to the customer to provide them with access to the content. Add the following code to the ProcessWebhookView

CHECKOUT_ORDER_APPROVED = "CHECKOUT.ORDER.APPROVED"

if event_type == CHECKOUT_ORDER_APPROVED:
    customer_email = webhook_event["resource"]["payer"]["email_address"]
    product_link = "https://learn.justdjango.com"
    send_mail(
        subject="Your access",
        message=f"Thank you for purchasing my product. Here is the link: {product_link}",
        from_email="your@email.com",
        recipient_list=[customer_email]
    )
Try test another payment and should see the email being printed in the console.

Dealing with Multiple Products

If you are only selling one product then it is easy to know which product the user purchased. Once you start to sell more than one product, the Django backend will need to identify the product that was purchased when receiving a webhook event. For subscription payments this is much easier but for once-off payments we will need to provide some extra information to the PayPalButtons component.

Change the purchase units to look like this:

purchase_units: [
    {
        amount: {
     	    value: "10.00"
        },
        custom_id: "e-book-1234"  // the name or slug of the thing you're selling
    },
],

The custom_id property will be sent in the webhook. We can access it like this:

webhook_event["resource"]["purchase_units"][0]["custom_id"]  # 'e-book-1234'

Conclusion

PayPal is one of the simplest ways to handle payments. You only have to write a little bit of code, but with that code you can easily sell your own digital products like you would on a platform like Gumroad.

You can find the code to this project on GitHub

This post is sponsored by JustDjango Learn, a set of Django courses that teach you the skills needed to become a professional Django developer.

Use the coupon code PAYPALREACT2021 to get $50 off the Pro lifetime package.