McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Paystack Webhook Returning 403 in Django
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

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.

In this article

  1. 1. What the Error Looks Like
  2. 2. Why Django CSRF Blocks Webhooks
  3. 3. Fix for Function-Based Views
  4. 4. Fix for Class-Based Views
  5. 5. Fix for Django REST Framework
  6. 6. Django Settings Check
  7. 7. Verification and Testing
  8. 8. Common Mistakes

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.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

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.

Learn Paystack Integration

KES 2,999

The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.

Start Paystack Course

Not ready? Create Free Account

Keep reading

Paystack Errors and Troubleshooting: The Complete Reference

Every Paystack error you will hit in production, what causes it, and how to fix it. Authentication failures, webhook signature problems, transaction errors, transfer issues, and frontend bugs. Code-first solutions for each one.

Read more

Paystack "Declined by Financial Institution" Error

The "Declined by financial institution" response means the customer's bank rejected the transaction. This is not a Paystack error or a bug in your code. The bank made the decision, and neither you nor Paystack can override it.

Read more

Paystack Transfer OTP Required Error

Your transfer call returns an OTP required error because Paystack protects every transfer with a one-time password by default. To automate transfers, you must call the disable_otp endpoint, confirm via the finalize_disable_otp endpoint, and accept the security trade-off. This guide walks through every step with code.

Read more

Paystack Rate Limit and 429 Handling

Paystack returns 429 Too Many Requests when you exceed their API rate limit. This guide covers the actual limits, how to implement exponential backoff, when to cache responses, and how to batch operations to avoid hitting the limit.

Read more

Paystack Webhooks on AWS Lambda and API Gateway

A complete guide to handling Paystack webhooks on AWS Lambda behind API Gateway. Covers the critical binary media type configuration for raw body passthrough, Lambda handler structure, HMAC-SHA512 signature verification, and production patterns for reliable payment event processing.

Read more

Mocking the Paystack API in PHPUnit

PHPUnit tests for Paystack integrations in Laravel or PHP can run without any network calls using Laravel's Http::fake() or Mockery. This guide shows how to mock Paystack responses, test webhook handlers, and verify your payment logic in isolation.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.