PayPal Integration with Django

Blog / Django · January 4, 2021 · Updated June 10, 2026 · 8 min read
PayPal Integration with Django

Integrating PayPal into a Django app used to mean wiring up Express Checkout (the NVP/SOAP Classic API) or an IPN (Instant Payment Notification) listener, often through the django-paypal package. Those flows are now legacy. PayPal steers every new integration toward the PayPal Checkout experience: the JavaScript SDK on the front end and the REST Orders API v2 on the back end, with webhooks replacing IPN for asynchronous confirmation.

This 2026 guide rebuilds the integration the modern way. You will add Smart Payment Buttons to your storefront, create and capture orders from Django, and verify webhooks so fulfilment happens only after PayPal confirms the money actually moved.

How the modern PayPal flow works

The secure pattern keeps every money decision on your server and never trusts amounts sent from the browser:

  1. Create the order (server). Django calls POST /v2/checkout/orders with an amount it computed itself, and returns the PayPal order id to the browser.
  2. Approve (browser). The JavaScript SDK renders the PayPal buttons; the buyer logs in and approves on PayPal's hosted UI.
  3. Capture the order (server). Django calls POST /v2/checkout/orders/{id}/capture to actually take the money.
  4. Confirm via webhook (server). PayPal posts a PAYMENT.CAPTURE.COMPLETED event; Django verifies the signature and fulfils the order.

Because PayPal hosts the card and login UI, sensitive card data never touches your servers, which keeps your PCI DSS obligations to the lightest SAQ A tier.

What you need

  • A PayPal Business account and a REST app in the PayPal Developer Dashboard to get a client id and secret.
  • Two environments: sandbox for testing and live for production. Each has its own client id, secret, and base URL (https://api-m.sandbox.paypal.com versus https://api-m.paypal.com).

1. Store credentials in settings (never hardcode)

Keep keys in environment variables and read them in settings.py. Never commit secrets or paste them into client-side code.

# settings.py
import os

PAYPAL_CLIENT_ID = os.environ["PAYPAL_CLIENT_ID"]
PAYPAL_CLIENT_SECRET = os.environ["PAYPAL_CLIENT_SECRET"]
PAYPAL_WEBHOOK_ID = os.environ["PAYPAL_WEBHOOK_ID"]  # from the webhook you create in the dashboard

# Switch base URL by environment: sandbox while testing, live in production.
PAYPAL_MODE = os.environ.get("PAYPAL_MODE", "sandbox")
PAYPAL_API_BASE = (
    "https://api-m.paypal.com" if PAYPAL_MODE == "live" else "https://api-m.sandbox.paypal.com"
)

2. Get an OAuth access token

Every REST call needs a short-lived OAuth 2.0 access token. You exchange your client id and secret (sent as HTTP Basic auth) for one using the client_credentials grant. Cache the token until it is close to expiry instead of fetching a new one on every request.

# paypal_client.py
import time

import requests
from django.conf import settings

_token_cache = {"access_token": None, "expires_at": 0}


def get_access_token():
    """Return a cached PayPal OAuth access token, refreshing it when expired."""
    if _token_cache["access_token"] and time.time() < _token_cache["expires_at"]:
        return _token_cache["access_token"]

    resp = requests.post(
        f"{settings.PAYPAL_API_BASE}/v1/oauth2/token",
        auth=(settings.PAYPAL_CLIENT_ID, settings.PAYPAL_CLIENT_SECRET),
        data={"grant_type": "client_credentials"},
        timeout=10,
    )
    resp.raise_for_status()
    data = resp.json()
    _token_cache["access_token"] = data["access_token"]
    # Refresh 60s early to avoid edge-of-expiry failures.
    _token_cache["expires_at"] = time.time() + data["expires_in"] - 60
    return _token_cache["access_token"]

3. Persist orders in your database

Track every order so you can reconcile captures and webhooks later. The paypal_order_id is your link back to PayPal.

# models.py
from django.db import models


class Order(models.Model):
    STATUS_CHOICES = [
        ("CREATED", "Created"),
        ("APPROVED", "Approved"),
        ("COMPLETED", "Completed"),
        ("FAILED", "Failed"),
    ]

    paypal_order_id = models.CharField(max_length=64, unique=True, db_index=True)
    capture_id = models.CharField(max_length=64, blank=True, default="")
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    currency = models.CharField(max_length=3, default="USD")
    status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="CREATED")
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

4. Create-order and capture-order views

Two endpoints do the work. The browser calls create-order to start checkout and capture-order after the buyer approves. Two rules matter:

  • Set the amount on the server. Look up the price from your own database or catalogue. Never accept an amount posted from the browser, or a buyer could pay one cent for anything.
  • Stay idempotent. Send a PayPal-Request-Id header on create so retries do not create duplicate orders, and check your stored status before capturing again.

These are plain Django views protected by Django's CSRF, so the front end sends an X-CSRFToken header. (A Django REST Framework APIView works the same way if you prefer DRF.)

# views.py
import json
import uuid
from decimal import Decimal

import requests
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.http import require_POST

from .models import Order
from .paypal_client import get_access_token

# Server-side price book. The browser only sends an item id, never a price.
CATALOG = {"tshirt-001": Decimal("100.00")}


@require_POST
def create_order(request):
    payload = json.loads(request.body or "{}")
    product_id = payload.get("product_id")
    price = CATALOG.get(product_id)
    if price is None:
        return JsonResponse({"error": "Unknown product"}, status=400)

    resp = requests.post(
        f"{settings.PAYPAL_API_BASE}/v2/checkout/orders",
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {get_access_token()}",
            # Idempotency key: a retry with the same id will not create a duplicate order.
            "PayPal-Request-Id": str(uuid.uuid4()),
        },
        json={
            "intent": "CAPTURE",
            "purchase_units": [
                {
                    "reference_id": product_id,
                    "amount": {"currency_code": "USD", "value": str(price)},
                }
            ],
        },
        timeout=10,
    )
    resp.raise_for_status()
    data = resp.json()

    Order.objects.create(
        paypal_order_id=data["id"], amount=price, currency="USD", status="CREATED"
    )
    return JsonResponse({"id": data["id"]})


@require_POST
def capture_order(request, order_id):
    order = Order.objects.filter(paypal_order_id=order_id).first()
    if order is None:
        return JsonResponse({"error": "Unknown order"}, status=404)
    if order.status == "COMPLETED":  # already captured: stay idempotent
        return JsonResponse({"status": "COMPLETED"})

    resp = requests.post(
        f"{settings.PAYPAL_API_BASE}/v2/checkout/orders/{order_id}/capture",
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {get_access_token()}",
        },
        timeout=10,
    )
    resp.raise_for_status()
    data = resp.json()

    capture = data["purchase_units"][0]["payments"]["captures"][0]
    order.capture_id = capture["id"]
    order.status = "COMPLETED" if capture["status"] == "COMPLETED" else "FAILED"
    order.save(update_fields=["capture_id", "status", "updated_at"])
    return JsonResponse({"status": order.status})

Wire the views into your URLconf:

# urls.py
from django.urls import path

from . import views

urlpatterns = [
    path("api/paypal/create-order/", views.create_order, name="paypal-create-order"),
    path("api/paypal/capture-order/<str:order_id>/", views.capture_order, name="paypal-capture-order"),
    path("api/paypal/webhook/", views.paypal_webhook, name="paypal-webhook"),
]

5. Render the Smart Payment Buttons

Load the JavaScript SDK with your client id (this one is public, so it is safe in the browser) and render the buttons. createOrder calls your Django create-order endpoint; onApprove calls capture-order. The code below is plain JavaScript with JSDoc, no TypeScript required.

<!-- checkout.html -->
<div id="paypal-button-container"></div>

<script src="https://www.paypal.com/sdk/js?client-id=YOUR_CLIENT_ID&currency=USD"></script>
<script>
  /**
   * Read a cookie value by name (used for Django's CSRF token).
   * @param {string} name
   * @returns {string}
   */
  function getCookie(name) {
    const cookies = document.cookie ? document.cookie.split('; ') : [];
    for (const c of cookies) {
      const [key, value] = c.split('=');
      if (key === name) return decodeURIComponent(value);
    }
    return '';
  }

  const csrftoken = getCookie('csrftoken');

  paypal
    .Buttons({
      // 1. Ask Django to create the order and return its id.
      createOrder: function () {
        return fetch('/api/paypal/create-order/', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken },
          body: JSON.stringify({ product_id: 'tshirt-001' }),
        })
          .then((res) => res.json())
          .then((data) => data.id);
      },
      // 2. After the buyer approves, ask Django to capture the order.
      onApprove: function (data) {
        return fetch('/api/paypal/capture-order/' + data.orderID + '/', {
          method: 'POST',
          headers: { 'X-CSRFToken': csrftoken },
        })
          .then((res) => res.json())
          .then((result) => {
            if (result.status === 'COMPLETED') {
              window.location.href = '/checkout/thank-you/';
            } else {
              alert('Payment could not be completed. Please try again.');
            }
          });
      },
      onError: function (err) {
        console.error('PayPal error', err);
      },
    })
    .render('#paypal-button-container');
</script>

6. Webhooks replace IPN

Capturing inside the browser flow is convenient, but a dropped connection can leave you unsure whether the money moved. Webhooks are the source of truth. Create a webhook in the dashboard pointing at /api/paypal/webhook/, subscribe to PAYMENT.CAPTURE.COMPLETED (and PAYMENT.CAPTURE.DENIED), and verify every event's signature before acting on it.

Verify by posting the transmission headers plus the raw, unmodified body back to PayPal's verify-webhook-signature endpoint. Re-serialising the JSON can change the bytes and fail verification, so pass the body through exactly as received.

# views.py (continued)
import json

import requests
from django.conf import settings
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from .models import Order
from .paypal_client import get_access_token


@csrf_exempt  # PayPal cannot send a Django CSRF token; we authenticate via the signature instead.
@require_POST
def paypal_webhook(request):
    body = request.body  # keep the raw bytes for verification
    verify = requests.post(
        f"{settings.PAYPAL_API_BASE}/v1/notifications/verify-webhook-signature",
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {get_access_token()}",
        },
        json={
            "transmission_id": request.headers.get("Paypal-Transmission-Id"),
            "transmission_time": request.headers.get("Paypal-Transmission-Time"),
            "cert_url": request.headers.get("Paypal-Cert-Url"),
            "auth_algo": request.headers.get("Paypal-Auth-Algo"),
            "transmission_sig": request.headers.get("Paypal-Transmission-Sig"),
            "webhook_id": settings.PAYPAL_WEBHOOK_ID,
            "webhook_event": json.loads(body),
        },
        timeout=10,
    )
    if verify.json().get("verification_status") != "SUCCESS":
        return HttpResponseBadRequest("Invalid signature")

    event = json.loads(body)
    if event.get("event_type") == "PAYMENT.CAPTURE.COMPLETED":
        resource = event["resource"]
        order_id = resource["supplementary_data"]["related_ids"]["order_id"]
        Order.objects.filter(paypal_order_id=order_id).update(
            status="COMPLETED", capture_id=resource["id"]
        )
        # ...now fulfil the order: send the receipt, grant access, or ship the goods.

    return HttpResponse(status=200)

Security, refunds, and going live

  • Keep secrets server-side. Only the client id belongs in the browser; the secret, webhook id, and access tokens stay on the server.
  • Always serve over HTTPS so tokens and webhook payloads cannot be intercepted.
  • Refunds use POST /v2/payments/captures/{capture_id}/refund, the modern replacement for the old RefundTransaction NVP call. Storing the capture_id (as shown above) is what makes a later refund a one-line call.
  • Recurring billing is no longer done with CreateRecurringPaymentsProfile. Use the Subscriptions API with products and billing plans instead.
  • Go live by swapping the sandbox client id, secret, and base URL for live credentials and creating a live webhook. Always exercise the full create to approve to capture to webhook loop in sandbox first, using PayPal's sandbox buyer accounts.

Related guides

Comparing gateways or building a full store? These walkthroughs pair well with this one:

Need a payment integration shipped and audited properly? Our Django development services team builds PayPal, Stripe, and multi-gateway checkouts end to end.

Frequently Asked Questions

Is django-paypal still the right way to integrate PayPal?

Not for new projects. The django-paypal package centres on the legacy IPN and Payments Standard flow, which PayPal has deprecated. Use the JavaScript SDK with the REST Orders API v2 and webhooks, as shown in this guide.

Do I still need IPN?

No. IPN is legacy. Webhooks are the modern asynchronous notification mechanism. Subscribe to events such as PAYMENT.CAPTURE.COMPLETED and verify every signature before acting.

Should I set the payment amount in JavaScript?

Never. Compute the amount on the Django server from your own catalogue or cart. If the browser supplies the price, a tampered request could pay far less than the real total.

What is the difference between authorize and capture?

With intent: "CAPTURE" the money moves as soon as you capture the order. Use intent: "AUTHORIZE" to place a hold and capture it later (for example, when you ship) through the Payments API.

How do I test without real money?

Use sandbox credentials together with the test buyer and business accounts in the PayPal Developer Dashboard. Switch to your live client id, secret, base URL, and a live webhook only when you are ready for production.

How do I issue a refund?

Call POST /v2/payments/captures/{capture_id}/refund with an access token. Because you stored each capture_id at capture time, a full or partial refund is a single API call later.

Share this article