Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Webhook Returning 403 in Django

Add the @csrf_exempt decorator to your Paystack webhook view. Import it from django.views.decorators.csrf and apply it above your view function. If you are using Django REST Framework, also set authentication_classes = [] and permission_classes = [] on your webhook view class so DRF does not try to authenticate the request. Then verify the webhook using the x-paystack-signature HMAC header instead of CSRF.

What the Error Looks Like

You create a Django view to receive Paystack webhooks. You configure the URL in the Paystack dashboard. A transaction triggers a webhook. Your Django app returns:

HTTP/1.1 403 Forbidden
Content-Type: text/html

Forbidden (CSRF cookie not set.): /webhooks/paystack/

Or in your Django logs:

WARNING django.security.csrf Forbidden (CSRF cookie not set.): /webhooks/paystack/

Paystack sees the 403, marks the delivery as failed, and retries. Every retry also fails with 403. Eventually Paystack stops trying.

The cause: Django's CsrfViewMiddleware is in your MIDDLEWARE setting and it checks every POST request for a CSRF token. Paystack does not send one.

Why Django CSRF Blocks Webhooks

Django's CSRF protection is designed for browser-based form submissions. Here is how it works:

  1. Django sets a csrftoken cookie on GET requests
  2. Your template includes the token in forms with {% csrf_token %}
  3. On POST, Django's CsrfViewMiddleware checks that the token in the request body or the X-CSRFToken header matches the cookie
  4. If it matches, the request proceeds. If not, Django returns 403.

Webhooks bypass all of this. Paystack's server never visits your page, never receives a CSRF cookie, and cannot include a CSRF token. The middleware sees a POST request with no token and rejects it.

This is the correct behavior for browser requests. For server-to-server webhooks, you need a different authentication mechanism: the HMAC signature.

Fix for Function-Based Views

Apply the @csrf_exempt decorator to your webhook view:

import hashlib
import hmac
import json
from django.http import HttpResponse, HttpResponseNotAllowed
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.conf import settings


@csrf_exempt
@require_POST
def paystack_webhook(request):
    # Step 1: Get the signature header
    signature = request.headers.get('X-Paystack-Signature', '')

    if not signature:
        return HttpResponse('No signature', status=401)

    # Step 2: Verify the signature using raw body
    secret = settings.PAYSTACK_SECRET_KEY.encode('utf-8')
    computed = hmac.new(
        secret,
        msg=request.body,  # raw bytes, not parsed data
        digestmod=hashlib.sha512,
    ).hexdigest()

    if not hmac.compare_digest(computed, signature):
        return HttpResponse('Invalid signature', status=401)

    # Step 3: Parse the verified body
    event = json.loads(request.body)

    # Step 4: Handle the event
    event_type = event.get('event')

    if event_type == 'charge.success':
        handle_charge_success(event['data'])
    elif event_type == 'transfer.success':
        handle_transfer_success(event['data'])
    elif event_type == 'transfer.failed':
        handle_transfer_failed(event['data'])
    else:
        print(f"Unhandled event type: {event_type}")

    # Step 5: Return 200
    return HttpResponse('OK', status=200)


def handle_charge_success(data):
    print(f"Processing charge.success: {data.get('reference')}")
    # Update your database, grant access, etc.


def handle_transfer_success(data):
    print(f"Processing transfer.success: {data.get('reference')}")


def handle_transfer_failed(data):
    print(f"Processing transfer.failed: {data.get('reference')}")

The @csrf_exempt must be the outermost decorator (listed first, applied last). The @require_POST decorator ensures only POST requests reach the view.

URL configuration (urls.py):

from django.urls import path
from . import views

urlpatterns = [
    path('webhooks/paystack/', views.paystack_webhook, name='paystack_webhook'),
]

Fix for Class-Based Views

For Django class-based views, apply @csrf_exempt using @method_decorator:

import hashlib
import hmac
import json
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings


@method_decorator(csrf_exempt, name='dispatch')
class PaystackWebhookView(View):
    def post(self, request):
        signature = request.headers.get('X-Paystack-Signature', '')

        if not signature:
            return HttpResponse('No signature', status=401)

        secret = settings.PAYSTACK_SECRET_KEY.encode('utf-8')
        computed = hmac.new(
            secret,
            msg=request.body,
            digestmod=hashlib.sha512,
        ).hexdigest()

        if not hmac.compare_digest(computed, signature):
            return HttpResponse('Invalid signature', status=401)

        event = json.loads(request.body)
        event_type = event.get('event')

        handler = getattr(self, f"handle_{event_type.replace('.', '_')}", None)
        if handler:
            handler(event['data'])

        return HttpResponse('OK', status=200)

    def handle_charge_success(self, data):
        print(f"Charge success: {data.get('reference')}")

    def handle_transfer_success(self, data):
        print(f"Transfer success: {data.get('reference')}")

    def handle_transfer_failed(self, data):
        print(f"Transfer failed: {data.get('reference')}")

URL configuration:

from django.urls import path
from .views import PaystackWebhookView

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

The @method_decorator(csrf_exempt, name='dispatch') applies the exemption to the dispatch method, which handles all HTTP methods for the view.

Fix for Django REST Framework

If you use Django REST Framework (DRF), you need to handle both CSRF and DRF authentication. DRF's session authentication includes its own CSRF check, and DRF's permission classes may also block unauthenticated requests.

import hashlib
import hmac
import json
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([])   # No authentication required
@permission_classes([])        # No permissions required
def paystack_webhook(request):
    # DRF may have already parsed the body into request.data
    # Use request.body (raw bytes) for signature verification
    signature = request.headers.get('X-Paystack-Signature', '')

    if not signature:
        return Response({'error': 'No signature'}, status=401)

    secret = settings.PAYSTACK_SECRET_KEY.encode('utf-8')
    computed = hmac.new(
        secret,
        msg=request.body,  # raw bytes, NOT request.data
        digestmod=hashlib.sha512,
    ).hexdigest()

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

    event = json.loads(request.body)

    # Handle the event
    event_type = event.get('event')
    print(f"Received webhook: {event_type}")

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

For class-based DRF views:

from rest_framework.views import APIView
from rest_framework.response import Response


class PaystackWebhookView(APIView):
    authentication_classes = []  # disable authentication
    permission_classes = []      # disable permissions

    def post(self, request):
        signature = request.headers.get('X-Paystack-Signature', '')

        if not signature:
            return Response({'error': 'No signature'}, status=401)

        secret = settings.PAYSTACK_SECRET_KEY.encode('utf-8')
        computed = hmac.new(
            secret,
            msg=request.body,
            digestmod=hashlib.sha512,
        ).hexdigest()

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

        event = json.loads(request.body)
        print(f"Webhook event: {event.get('event')}")

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

Key points for DRF:

  • authentication_classes = [] disables DRF authentication. Without this, DRF's SessionAuthentication enforces CSRF even if you used @csrf_exempt.
  • permission_classes = [] allows unauthenticated requests. Without this, DRF's default IsAuthenticated permission blocks Paystack.
  • Use request.body for HMAC verification, not request.data. DRF's request.data is the parsed body and will produce a different hash.

Django Settings Check

Make sure your Django settings are configured correctly:

# settings.py

# CSRF middleware must be in MIDDLEWARE for other views to be protected
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',  # keep this for other views
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

# Your Paystack secret key
PAYSTACK_SECRET_KEY = os.environ.get('PAYSTACK_SECRET_KEY', '')

# Do NOT remove CsrfViewMiddleware from MIDDLEWARE globally.
# That would disable CSRF for your entire application.
# Use @csrf_exempt on individual views that need it.

Never remove CsrfViewMiddleware from your MIDDLEWARE list to fix webhook issues. That disables CSRF protection for every view in your app, including login forms and admin pages. The @csrf_exempt decorator targets only the views that need the exemption.

Verification and Testing

After applying the fix, test it:

# Start Django dev server
python manage.py runserver

# In another terminal, send a test webhook
SECRET="sk_test_your_test_key"
BODY='{"event":"charge.success","data":{"id":12345,"reference":"test_ref","amount":50000,"currency":"NGN","status":"success"}}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')

curl -X POST http://localhost:8000/webhooks/paystack/ \
  -H "Content-Type: application/json" \
  -H "X-Paystack-Signature: $SIGNATURE" \
  -d "$BODY"

# Expected: 200 with body {"received": true} or "OK"
# If you get 403, the @csrf_exempt is not applied correctly

Write a Django test to prevent regressions:

# tests.py
import hashlib
import hmac
import json
from django.test import TestCase, override_settings


@override_settings(PAYSTACK_SECRET_KEY='sk_test_secret')
class PaystackWebhookTest(TestCase):
    def test_webhook_does_not_return_403(self):
        """Webhook route should not return 403 CSRF error."""
        body = json.dumps({
            'event': 'charge.success',
            'data': {'reference': 'test_001'},
        })

        response = self.client.post(
            '/webhooks/paystack/',
            data=body,
            content_type='application/json',
        )

        # Should be 401 (no valid signature) but NOT 403 (CSRF)
        self.assertNotEqual(response.status_code, 403)

    def test_webhook_accepts_valid_signature(self):
        """Webhook should return 200 with valid signature."""
        secret = 'sk_test_secret'
        body = json.dumps({
            'event': 'charge.success',
            'data': {'reference': 'test_001'},
        })
        signature = hmac.new(
            secret.encode('utf-8'),
            msg=body.encode('utf-8'),
            digestmod=hashlib.sha512,
        ).hexdigest()

        response = self.client.post(
            '/webhooks/paystack/',
            data=body,
            content_type='application/json',
            HTTP_X_PAYSTACK_SIGNATURE=signature,
        )

        self.assertEqual(response.status_code, 200)

For the complete error reference, see Paystack Errors and Troubleshooting: The Complete Reference. For more on Django webhook patterns, see Paystack Webhooks and CSRF Protection in Django.

Common Mistakes

1. Removing CsrfViewMiddleware from MIDDLEWARE

Some developers remove the entire CSRF middleware from settings.py. This fixes the webhook but disables CSRF for your entire application, including admin, login, and all forms. Never do this. Use @csrf_exempt on specific views.

2. Wrong decorator order

# WRONG: @require_POST runs before @csrf_exempt
@require_POST
@csrf_exempt
def paystack_webhook(request):
    ...

# RIGHT: @csrf_exempt is outermost (listed first)
@csrf_exempt
@require_POST
def paystack_webhook(request):
    ...

Decorator order matters. The outermost decorator (listed first) is applied last, meaning it wraps the inner decorators. @csrf_exempt must wrap everything.

3. Using request.data instead of request.body for signature verification

In DRF, request.data is the parsed body (a Python dict). Hashing a dict or its re-serialized JSON will not match the Paystack signature. Always use request.body for raw bytes.

4. Forgetting authentication_classes in DRF

DRF's SessionAuthentication includes its own CSRF check. Even with @csrf_exempt on the view, if SessionAuthentication is in DEFAULT_AUTHENTICATION_CLASSES, DRF enforces CSRF. Set authentication_classes = [] on the webhook view.

Key Takeaways

  • Django CSRF middleware rejects POST requests without a valid CSRF token by returning 403 Forbidden. External webhooks like Paystack cannot provide CSRF tokens.
  • The @csrf_exempt decorator from django.views.decorators.csrf skips CSRF verification for a specific view. Apply it to your webhook view function.
  • For Django REST Framework views, set authentication_classes = [] and permission_classes = [] to prevent DRF from requiring authentication on the webhook endpoint.
  • Excluding a view from CSRF is safe as long as you verify the Paystack HMAC signature. The signature provides stronger authentication than CSRF for server-to-server requests.
  • Use request.body (raw bytes) for signature verification, not request.data (parsed dict). The HMAC must be computed over the exact bytes Paystack sent.
  • Always use hmac.compare_digest() for signature comparison, not the == operator. This prevents timing attacks.

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 page tricks a user's browser into making requests. Webhooks are server-to-server requests that cannot include CSRF tokens by design. You protect webhook endpoints with HMAC signature verification instead, which is actually stronger for this use case.
Do I need @csrf_exempt if my webhook route is in a DRF router?
If you set authentication_classes = [] on your DRF view, the CSRF check from SessionAuthentication is skipped. However, Django's CsrfViewMiddleware still runs before DRF. To be safe, use both @csrf_exempt (or method_decorator on class-based views) and authentication_classes = [].
Can I use request.data instead of request.body in DRF?
Not for signature verification. request.data is the parsed Python dictionary. When you serialize it back to JSON for hashing, the output may differ from the original payload due to key ordering, whitespace, or encoding differences. Always use request.body (raw bytes) for HMAC computation.
What if I have CSRF_COOKIE_SECURE = True in production?
CSRF_COOKIE_SECURE only affects how the CSRF cookie is set on browser responses. It does not change how CsrfViewMiddleware validates POST requests. You still need @csrf_exempt on your webhook view regardless of cookie security settings.
Should I add rate limiting to my webhook endpoint?
Be cautious with rate limiting on webhook endpoints. Paystack may send bursts of webhooks if many transactions complete at once. If your rate limiter blocks Paystack, you lose events. If you must rate-limit, set a generous threshold and make sure Paystack's retry mechanism can recover from temporary throttling.

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