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.
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.
Django's CSRF protection is designed for browser-based form submissions. Here is how it works:
csrftoken cookie on GET requests{% csrf_token %}X-CSRFToken header matches the cookieWebhooks 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.
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'),
]
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.
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.request.body for HMAC verification, not request.data. DRF's request.data is the parsed body and will produce a different hash.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.
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.
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.
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