Django & Paypal Webhooks

To avoid creating a project from scratch, let's continue with the basic Django project in my GitHub:
https://github.com/38130/mysite

Througout this post, please assume the following project:

mysite/
polls/
templates/
polls/
index.html
views.py
urls.py

 


This post focus in the PayPal logic.
The ecommerce logic is intentionally hardcoded.

 

 

Credentials & PayPal Configuration

Signup or Login into PayPal Developers platform: https://developer.paypal.com/home/

  1. Select Sandbox
  2. Select API Credentials
  3. Create App
  4. Copy/paste Client ID and Secret into mysite/polls/views.py
  5. Scroll down and click Add Webhook
  6. Choose a name
  7. Choose a URL
     

    Ngrok

    Because PayPal can't reach us in localhost, we create a bridge from localhost to the internet by using Ngrok.

     

    Download: https://ngrok.com/download

    After the download unzip ngrok.exe

    Doubleclick it

    Signup/Login : https://ngrok.com

    In https://dashboard.ngrok.com/get-started/setup, copy ngrok config add-authtoken

    ngrok config add-authtoken **************************************************

    Execute it in ngrok prompt

    ngrok http 80

    Copy the link next to «Forwarding» (Right click and select mark if you can't select the text)
     

    ALLOWED_HOSTS = ['localhost', '7b86-159-255-57-146.ngrok-free.app']


    The local project is now accessible externaly through:
    https://7b86-159-255-57-146.ngrok-free.app



    Add the WebHook URL:
    https://7b86-159-255-57-146.ngrok-free.app/webhook
    (We will create this url later in Django)


     
  8. Select All events
  9. Save
  10. Copy the Webhook ID into Django

 

clientID = "***********"
secret = "***********"
webhookID = "***********"







The PayPal button


Optionally we can define the client ID in the backend.

class index(TemplateView):

    template_name = 'polls/index.html'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        context['price'] = 0.005
        context['quantity'] = 2
        context['clientID'] = clientID


        return context



The button is customizable in different ways. Design and styling, credit card, shipping, currency, success/cancel links are some of the customizations we can make.


    <div id="paypal-button-container"></div>

    <script src="https://www.paypal.com/sdk/js?client-id={{clientID}}&currency=EUR&disable-funding=card"></script>

    <script type="text/javascript">

        var price = "{{price}}";
        var quantity = "{{quantity}}";
        var total = parseFloat(price) * parseFloat(quantity);

        var orderData = {
            user: '{{user.pk}}',
            order: '123456789',
            quantity: '{{quantity}}',
        };

        paypal.Buttons({
            style: {
                shape: 'pill',
                color: 'gold',
                layout: 'vertical',
                label: 'pay'
            },
            createOrder: function(data, actions) {
                return actions.order.create({
                    intent: 'CAPTURE',
                    purchase_units: [{
                        amount: { value: total },
                        custom_id: JSON.stringify(orderData),
                    }],
                    application_context: { shipping_preference: 'NO_SHIPPING' }
                });
            },
            onApprove: function(data, actions) {
                return actions.order.capture().then(function(details) {
                    window.location.href = '/';
                });
            },
            onCancel: function(data) {
                window.location.href = '/';
            },
            commit: true
        }).render('#paypal-button-container');

    </script>




Allowing Django to open popups
 

...
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin-allow-popups"





 

Understanding the WebHook event flow




What will trigger the PayPal payment is a button in the webpage.
This button knows how to handle the payment because
it is associated with the client ID information and the amount of money to collect.

Upon triggering the button, PayPal gets notified with a request.
Its action is now to respond with a webhook to the webhook URL we have previously configured.

And here's something to keep in mind:
The response contains many information but it's not a payment confirmation.


The PayPal response still needs to be validated with PayPal itself.
To do this we need to communicate to the PayPal API through a Token.
We get this token from them. These tokens are valid for 9 hours and we could store it.
To keep things simple for the tutorial, we will request always a new token.


With this said, we create a verification request, and ask PayPal to confirm it.
Only afterwards we know the payment is valid or not.


This prevents people from simulating payments by simply accessing the webhook URL.



 




Handling the Webhook

from django.urls import path
from Webhooks.views import *

urlpatterns = [
    ...
    path('webhook/', webhook.as_view(), name="webhook"),

]

 

from django.views.generic import View
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.http import HttpResponse
import json
import requests


#paypal_url = "https://api.paypal.com"          #Live
paypal_url = "https://api.sandbox.paypal.com"   #SandBox


...


@method_decorator(csrf_exempt, name='dispatch')
class webhook(View):

    def post(self, request, *args, **kwargs):

        try:
            res = json.loads(request.body)

            # Getting the Token
            token_url = paypal_url + '/v1/oauth2/token'
            headers = {
                'Accept': 'application/json',
                'Accept-Language': 'en_US',
            }
            data = {'grant_type': 'client_credentials'}

            token_res = requests.post(token_url, headers=headers, data=data, auth=(clientID, secret))
            access_token = token_res.json().get('access_token')

            if not access_token:
                return HttpResponse(status=401)

            # Webhook signature verification
            verify_headers = {
                "Authorization": f"Bearer {access_token}",
                "Content-Type": "application/json"
            }
            verify_data = {
                "auth_algo": request.headers.get("Paypal-Auth-Algo"),
                "cert_url": request.headers.get("Paypal-Cert-Url"),
                "transmission_sig": request.headers.get("Paypal-Transmission-Sig"),
                "transmission_id": request.headers.get("Paypal-Transmission-Id"),
                "transmission_time": request.headers.get("Paypal-Transmission-Time"),
                "webhook_id": webhookID,
                "webhook_event": res
            }

            verify_res = requests.post(
                paypal_url + "/v1/notifications/verify-webhook-signature",
                json=verify_data,
                headers=verify_headers
            )

            
            if verify_res.status_code != 200 or verify_res.json().get("verification_status") != "SUCCESS":
                return HttpResponse(status=400)  # not valid signature
            


            # Payment Confirmation
            event_type = res.get('event_type')
            resource = res.get('resource', {})

            custom_id = resource.get('custom_id')
            event_code = None

            if event_type == "PAYMENT.CAPTURE.COMPLETED":
                event_code = "C"
            elif event_type == "PAYMENT.CAPTURE.DENIED":
                event_code = "D"

            print("Custom ID:", custom_id)
            print("Event code:", event_code)

        except Exception:
            return HttpResponse(status=500)

        return HttpResponse(status=200)

 

 

Custom ID

When we receive a payment confirmation, we can use the custom ID to reference the order and a specific user within our system.

 

SandBox
 

In PayPal developers:

  • Go to SandBox accounts
  • Click the email with your personal account
  • Use these credentials to login while simulating payments



Live
 

Going Live and accepting real payments, it's now a matter of changing:

  • Changing the paypal_url
  • Selecting Live
  • Repeating the process to create an App this time in Live
  • Repeating the process to create a WebHook this time in Live
  • Adding the URL of your website to the webhook instead of the Ngrok
  • Replacing the Client ID, secret and webhook ID in views.py



Keep in mind that
 

You can't be a client of your own PayPal Business account.
This means that in order to test, you need to use a different account than the one that is receiving the payments.

 


Tested with:
Django 5.2


07 Jan. 2026 | Last Updated: 08 Jan. 2026 | jaimedcsilva

Related
  • Using ngrok with Django
  • Opening a Django project through a .exe file
  • Creating an online store with Django
  • CRUD
  • Creating a Basic Django Project Automatically
  • Filter Horizontal in a custom template
  • GeoIP tracking with IpInfo and Django
  • GeoIP tracking with MaxMind and Django
  • Django User Agents
  • Generating Temporary Download Links
  • Cython - Hiding the Code of a Django Project
  • Quick & Easy Django Deployment on PythonAnywhere
  • A Brief History of Django
  • Django & Paypal Webhooks
  • Generating QR Codes with Django

  • Buy Me a Coffee