Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Webhooks and CSRF Protection in Django

Django returns 403 Forbidden for Paystack webhooks because its CSRF middleware rejects POST requests that do not include a CSRF token. Paystack cannot send a CSRF token because it is not a browser submitting a form. The fix is to decorate your webhook view with @csrf_exempt, which disables CSRF checking for that specific view. This is safe because you verify the request using Paystack HMAC signature verification instead of CSRF tokens.

Why Django Returns 403 for Paystack Webhooks

When you first set up a Paystack webhook endpoint in Django, you will get a 403 Forbidden response. Every time, without exception. This confuses developers because the view code looks correct, the URL is mapped properly, and Paystack is sending a valid request. The 403 comes from Django's CSRF middleware, and it has nothing to do with your view code.

Django's CSRF (Cross-Site Request Forgery) protection works by requiring every POST request to include a secret token that proves the request originated from your own site. When a user submits a form on your site, Django embeds a CSRF token in the form. When the form is submitted, Django checks that the token matches. If it does not, the request is rejected with a 403.

Paystack webhooks are server-to-server POST requests. Paystack's server is not browsing your site, it has no session, and it has no way to obtain a CSRF token. So every Paystack webhook hits the CSRF middleware and gets rejected.

The fix is simple: exempt your webhook view from CSRF checking. But it is worth understanding why this is safe, because "disable security feature" sounds alarming when you are handling real money.

CSRF protection guards against a specific attack: a malicious website tricks a user's browser into submitting a form to your site using the user's existing session cookies. This attack requires a browser with cookies. Paystack webhooks do not involve browsers or cookies. The protection against unauthorized webhook requests is HMAC signature verification, which is a stronger mechanism than CSRF tokens. You are not weakening security by exempting the webhook view; you are replacing one authentication mechanism with a better one.

Using @csrf_exempt on Your Webhook View

The @csrf_exempt decorator tells Django to skip CSRF checking for a specific view function. Import it from django.views.decorators.csrf and apply it to your webhook view:

# views.py
import json
import hashlib
import hmac

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


@csrf_exempt
@require_POST
def paystack_webhook(request):
    # Get the webhook secret
    secret = settings.PAYSTACK_WEBHOOK_SECRET
    if not secret:
        return HttpResponse(status=500)

    # Get the signature header
    signature = request.META.get('HTTP_X_PAYSTACK_SIGNATURE', '')
    if not signature:
        return JsonResponse({'error': 'Missing signature'}, status=400)

    # Get the raw body
    raw_body = request.body

    # Verify the HMAC-SHA512 signature
    expected = hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha512
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        return JsonResponse({'error': 'Invalid signature'}, status=401)

    # Parse the verified payload
    payload = json.loads(raw_body)
    event_type = payload.get('event')
    data = payload.get('data', {})

    # Process the event
    if event_type == 'charge.success':
        handle_charge_success(data)
    elif event_type == 'transfer.success':
        handle_transfer_success(data)
    elif event_type == 'transfer.failed':
        handle_transfer_failed(data)

    return JsonResponse({'received': True}, status=200)


def handle_charge_success(data):
    reference = data.get('reference', '')
    amount = data.get('amount', 0)
    # Your business logic here
    print('Payment successful: ' + reference)


def handle_transfer_success(data):
    transfer_code = data.get('transfer_code', '')
    print('Transfer completed: ' + transfer_code)


def handle_transfer_failed(data):
    transfer_code = data.get('transfer_code', '')
    print('Transfer failed: ' + transfer_code)

A few notes on the code:

  • @require_POST rejects non-POST requests with a 405. This is a defense-in-depth measure so GET, PUT, and DELETE requests do not reach your handler.
  • request.META.get('HTTP_X_PAYSTACK_SIGNATURE'): Django stores HTTP headers in request.META with the prefix HTTP_, uppercased, and hyphens replaced with underscores.
  • request.body: This is the raw request body as bytes. It has not been parsed by any middleware. This is what you need for HMAC verification.
  • hmac.compare_digest: This does a constant-time comparison, preventing timing attacks. Always use this instead of == for comparing signatures.

URL Configuration

Wire up the view in your urls.py:

# urls.py (app-level)
from django.urls import path
from . import views

urlpatterns = [
    path('webhooks/paystack/', views.paystack_webhook, name='paystack-webhook'),
]
# urls.py (project-level)
from django.urls import path, include

urlpatterns = [
    path('api/', include('your_app.urls')),
    # ...
]

The webhook URL will be https://yourdomain.com/api/webhooks/paystack/. Register this URL in your Paystack dashboard.

One Django-specific detail: make sure the URL ends with a trailing slash if your APPEND_SLASH setting is True (which is the default). If Paystack sends a request to /api/webhooks/paystack without the trailing slash, Django will redirect it to /api/webhooks/paystack/ with a 301. This redirect is a GET request, which your @require_POST view will reject. Either include the trailing slash in your Paystack dashboard URL, or set the URL pattern without a trailing slash and turn off APPEND_SLASH for that path.

The simplest fix: always include the trailing slash in the URL you register with Paystack.

Django Settings for the Webhook Secret

Store the webhook secret in your Django settings, loaded from an environment variable:

# settings.py
import os

PAYSTACK_WEBHOOK_SECRET = os.environ.get('PAYSTACK_WEBHOOK_SECRET', '')

# For production, you might also want:
PAYSTACK_SECRET_KEY = os.environ.get('PAYSTACK_SECRET_KEY', '')
PAYSTACK_PUBLIC_KEY = os.environ.get('PAYSTACK_PUBLIC_KEY', '')

Set the environment variable in your production environment. If you are using a .env file with django-environ or python-dotenv:

# .env
PAYSTACK_WEBHOOK_SECRET=your_webhook_secret_here

Important: the Paystack webhook secret is different from your Paystack secret key. The webhook secret is specifically for verifying webhook signatures. You find it in your Paystack dashboard under Settings > API Keys & Webhooks. Do not confuse it with the sk_live or sk_test secret key that you use for making API calls.

Use different webhook secrets for test and live modes if you have separate webhook URLs. When you switch from test to live, update the environment variable accordingly.

Class-Based View Approach

If you prefer Django's class-based views, you can use the method_decorator to apply @csrf_exempt to a class:

import json
import hashlib
import hmac

from django.conf import settings
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt


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

    def post(self, request):
        secret = settings.PAYSTACK_WEBHOOK_SECRET
        signature = request.META.get('HTTP_X_PAYSTACK_SIGNATURE', '')

        if not signature:
            return JsonResponse(
                {'error': 'Missing signature'}, status=400
            )

        raw_body = request.body

        expected = hmac.new(
            secret.encode('utf-8'),
            raw_body,
            hashlib.sha512
        ).hexdigest()

        if not hmac.compare_digest(expected, signature):
            return JsonResponse(
                {'error': 'Invalid signature'}, status=401
            )

        payload = json.loads(raw_body)
        self.process_event(payload)

        return JsonResponse({'received': True}, status=200)

    def process_event(self, payload):
        event_type = payload.get('event')
        data = payload.get('data', {})

        handler_map = {
            'charge.success': self.handle_charge_success,
            'transfer.success': self.handle_transfer_success,
            'transfer.failed': self.handle_transfer_failed,
        }

        handler = handler_map.get(event_type)
        if handler:
            handler(data)

    def handle_charge_success(self, data):
        reference = data.get('reference', '')
        print('Payment: ' + reference)

    def handle_transfer_success(self, data):
        print('Transfer: ' + data.get('transfer_code', ''))

    def handle_transfer_failed(self, data):
        print('Failed: ' + data.get('transfer_code', ''))

The @method_decorator(csrf_exempt, name='dispatch') applies the CSRF exemption to the dispatch method, which is the method that routes incoming requests to get(), post(), etc. Applying it to dispatch ensures all HTTP methods are CSRF-exempt, though only post() will return a meaningful response.

Wire it up in urls.py:

from django.urls import path
from .views import PaystackWebhookView

urlpatterns = [
    path('webhooks/paystack/', PaystackWebhookView.as_view(), name='paystack-webhook'),
]

Django REST Framework Approach

If your Django project uses Django REST Framework (DRF), there is an additional layer of authentication and permission checking that can block webhook requests. DRF views enforce both Django CSRF protection (for session authentication) and DRF's own authentication backends.

To create a webhook endpoint in DRF, use the @api_view decorator with explicit empty authentication and permission classes:

import json
import hashlib
import hmac

from django.conf import settings
from rest_framework.decorators import (
    api_view,
    authentication_classes,
    permission_classes,
)
from rest_framework.response import Response


@api_view(['POST'])
@authentication_classes([])
@permission_classes([])
def paystack_webhook(request):
    secret = settings.PAYSTACK_WEBHOOK_SECRET
    signature = request.META.get('HTTP_X_PAYSTACK_SIGNATURE', '')

    if not signature:
        return Response(
            {'error': 'Missing signature'}, status=400
        )

    # DRF parses the body automatically.
    # We need the raw body for signature verification.
    raw_body = request.body

    expected = hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha512
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        return Response(
            {'error': 'Invalid signature'}, status=401
        )

    payload = json.loads(raw_body)
    event_type = payload.get('event')
    data = payload.get('data', {})

    if event_type == 'charge.success':
        reference = data.get('reference', '')
        amount = data.get('amount', 0)
        print('Payment: ' + reference + ' Amount: ' + str(amount))

    return Response({'received': True}, status=200)

The key is @authentication_classes([]) and @permission_classes([]). These tell DRF to skip authentication and permission checks for this view. Without these, DRF would reject the request because Paystack does not send an authentication token that DRF recognizes.

Setting authentication_classes to an empty list also disables CSRF checking for the view, because CSRF is only enforced by DRF when using SessionAuthentication. With no authentication classes, no CSRF check happens.

One subtle point: even though DRF automatically parses the JSON body and makes it available as request.data, you should use request.body (the raw bytes) for HMAC verification. request.data is a parsed Python dictionary, and if you re-serialize it with json.dumps, the output may differ from the original payload (different key ordering, whitespace, etc.), causing the hash to not match.

Async Processing with Celery

Django views are synchronous by default (unless you are using Django 4.1+ async views with an ASGI server). If your webhook handler needs to send emails, update multiple database tables, or call external APIs, it can take several seconds to complete. This delays your 200 response to Paystack.

The standard Django solution is to offload work to Celery. Your webhook view verifies the signature, pushes the event to a Celery task, and returns 200 immediately:

# tasks.py
from celery import shared_task


@shared_task
def process_paystack_event(event_type, data):
    if event_type == 'charge.success':
        reference = data.get('reference', '')
        # Update order status
        # Send receipt email
        # Notify external systems
        print('Processing payment: ' + reference)

    elif event_type == 'transfer.success':
        print('Processing transfer: ' + data.get('transfer_code', ''))


# views.py
@csrf_exempt
@require_POST
def paystack_webhook(request):
    # ... signature verification code ...

    payload = json.loads(request.body)
    event_type = payload.get('event')
    data = payload.get('data', {})

    # Push to Celery and return immediately
    process_paystack_event.delay(event_type, data)

    return JsonResponse({'received': True}, status=200)

Celery tasks run in a separate worker process, so the webhook view returns 200 within milliseconds. The actual processing happens asynchronously, with Celery's built-in retry mechanism handling transient failures.

For a deeper dive into queueing Paystack webhook work, see Queueing Paystack Webhook Work with Celery.

Middleware Ordering and Potential Conflicts

Django's CSRF middleware is just one of several middleware classes that can interfere with webhook requests. Check your MIDDLEWARE setting for anything that might block or modify incoming requests:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',        # This blocks webhooks
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

The @csrf_exempt decorator handles CsrfViewMiddleware. But watch out for:

  • Custom authentication middleware: If you have middleware that requires authentication for all requests, it will block Paystack webhooks. Add an exception for your webhook URL path.
  • Rate limiting middleware: Middleware like django-ratelimit might throttle webhook requests if they come in quickly. Exclude the webhook path.
  • Request body parsing middleware: If you have middleware that reads and parses request.body before your view runs, the stream might be consumed and request.body in your view will be empty. This is rare in standard Django but can happen with custom middleware.

The general principle: your webhook endpoint should be as "naked" as possible. It needs the raw request body and headers. Everything else (authentication, rate limiting, CSRF) is handled by Paystack's HMAC signature, not by Django's middleware.

Key Takeaways

  • Django CSRF middleware blocks all POST requests that do not include a valid CSRF token. Since Paystack is a server sending a POST, not a browser submitting a form, it cannot provide a CSRF token.
  • The @csrf_exempt decorator disables CSRF checking for a specific view. Apply it only to your webhook endpoint, not to your entire application.
  • Disabling CSRF on the webhook view is safe because you replace CSRF protection with HMAC signature verification, which is a stronger authentication mechanism.
  • In Django REST Framework, use the @api_view decorator with authentication_classes=[] and permission_classes=[] to bypass both CSRF and DRF authentication on the webhook endpoint.
  • Read the raw request body with request.body (a bytes object) before any middleware or parser processes it. This is the body you hash for signature verification.
  • Always verify the Paystack signature before processing the event. @csrf_exempt opens the door to any POST request; signature verification is what keeps out unwanted traffic.

Frequently Asked Questions

Is it safe to use @csrf_exempt on a webhook endpoint?
Yes. CSRF protection guards against browser-based attacks where a malicious site tricks a user into submitting a form using their session cookies. Paystack webhooks are server-to-server requests with no browser and no cookies involved. You replace CSRF protection with HMAC signature verification, which is a stronger authentication mechanism. The @csrf_exempt decorator should only be applied to the webhook view, not to your entire application.
Why does Django use HTTP_X_PAYSTACK_SIGNATURE instead of X-Paystack-Signature?
Django stores HTTP request headers in request.META using a specific naming convention: the header name is uppercased, hyphens are replaced with underscores, and the prefix HTTP_ is added. So the header X-Paystack-Signature becomes HTTP_X_PAYSTACK_SIGNATURE in request.META. This is a Django convention, not a change to the actual header value.
Can I use Django async views for the webhook handler?
Yes, if you are using Django 4.1 or later with an ASGI server like Uvicorn or Daphne. Async views let you use await for database operations and external API calls without blocking the thread. The @csrf_exempt decorator works with async views. However, for a webhook handler that should return 200 quickly and offload processing to Celery, the sync vs async distinction does not matter much.
Does request.body work if Django middleware has already read the body?
Django caches request.body on first access, so subsequent reads return the same content. However, if custom middleware reads from request (the raw WSGI input stream) directly without using request.body, the stream may be consumed and request.body in your view will be empty. This is uncommon with standard Django middleware but can happen with custom middleware that reads the raw input. If you suspect this, check your middleware for any direct stream reads.
How do I handle the APPEND_SLASH redirect issue with Paystack webhooks?
Django APPEND_SLASH setting redirects URLs without a trailing slash to the version with a trailing slash using a 301 redirect. This redirect is a GET request, which your POST-only webhook view will reject. The simplest fix is to include the trailing slash in the URL you register with Paystack (e.g., /api/webhooks/paystack/ instead of /api/webhooks/paystack). Alternatively, define your URL pattern without a trailing slash.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon