Your signature check fails because you are hashing a parsed-and-re-serialized JSON body instead of the raw bytes Paystack sent. Paystack computes the HMAC-SHA512 hash over the exact raw request body. If your framework (Express, Django, Laravel, Next.js) parses the body into an object before your verification code runs, and you then JSON.stringify that object, the resulting string may differ from the original. Whitespace, key order, and Unicode escaping can all change. The fix: capture the raw, unparsed request body and hash that. In Express, use express.raw() or a verify callback on express.json(). In Django, use request.body. In Laravel, use file_get_contents("php://input"). In Next.js App Router, use request.text().
You set up your Paystack webhook endpoint. You compute the HMAC hash. You compare it to the x-paystack-signature header. The comparison fails. Every single time.
Your logs show something like this:
// Your computed hash
Expected: a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0...
// Paystack's signature header
Received: 7d1e9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0...
Signature verification FAILED
The two hex strings do not match. Your webhook handler rejects the request, or worse, you skip verification and process unverified webhooks.
This is one of the most common Paystack integration issues. It is almost always caused by the same root problem: you are not hashing the raw request body.
When Paystack sends a webhook to your server, it does two things:
x-paystack-signature headerYour job is to do the same computation on your end and check that the results match:
const crypto = require('crypto');
// This is the CORRECT approach (conceptual)
const rawBody = getTheExactBytesPaystackSent(); // <-- this is where things go wrong
const secret = process.env.PAYSTACK_SECRET_KEY;
const hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
const signature = request.headers['x-paystack-signature'];
// Compare using constant-time function
const isValid = crypto.timingSafeEqual(
Buffer.from(hash),
Buffer.from(signature)
);
The algorithm is simple. The hard part is "get the exact bytes Paystack sent." That is where every framework trips you up.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Here is what happens in a typical Express app:
{"event":"charge.success","data":{"id":123}}express.json() or body-parser) intercepts the request{ event: "charge.success", data: { id: 123 } }req.body as this parsed objectJSON.stringify(req.body) to get a string for hashing{"event":"charge.success","data":{"id":123}}Step 6 looks the same. But it is not always the same. Here is why:
{"data":{"id":123},"event":"charge.success"} is valid JSON with the same data, but produces a completely different hash.{"event": "charge.success"} with a space after the colon. JSON.stringify omits those spaces by default.é can be represented as the literal UTF-8 byte or as \u00e9. Parse-then-serialize may change one to the other.100.0 might become 100 after parsing and re-serializing.Any of these differences means a different hash. The fix is to never parse the body before hashing it.
You have two options in Express. Both work. Pick one.
Option 1: Use express.raw() on the webhook route only
const express = require('express');
const crypto = require('crypto');
const app = express();
// Use express.json() for all OTHER routes
app.use((req, res, next) => {
if (req.path === '/webhooks/paystack') {
return next(); // skip JSON parsing for webhook route
}
express.json()(req, res, next);
});
// Webhook route gets raw buffer
app.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
if (!signature) {
return res.status(401).send('No signature');
}
// req.body is a Buffer here, not a parsed object
const hash = crypto
.createHmac('sha512', secret)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature))) {
return res.status(401).send('Invalid signature');
}
// Now parse the verified raw body
const event = JSON.parse(req.body.toString());
// Process the event
console.log('Verified event:', event.event);
res.sendStatus(200);
}
);
Option 2: Use the verify callback on express.json()
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(
express.json({
verify: (req, res, buf) => {
// Store the raw buffer on the request object
req.rawBody = buf;
},
})
);
app.post('/webhooks/paystack', (req, res) => {
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
if (!signature) {
return res.status(401).send('No signature');
}
// Use the stored raw buffer, not JSON.stringify(req.body)
const hash = crypto
.createHmac('sha512', secret)
.update(req.rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature))) {
return res.status(401).send('Invalid signature');
}
// req.body is already parsed, safe to use
const event = req.body;
console.log('Verified event:', event.event);
res.sendStatus(200);
});
Option 1 is cleaner because the webhook route explicitly gets raw bytes. Option 2 is convenient if you already have express.json() applied globally and cannot easily change the middleware order.
Django gives you the raw body through request.body. This is the raw bytes before any processing. Do not use request.data from Django REST Framework, that is already parsed.
import hashlib
import hmac
import json
from django.http import HttpResponse
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):
secret = settings.PAYSTACK_SECRET_KEY.encode('utf-8')
signature = request.headers.get('X-Paystack-Signature', '')
if not signature:
return HttpResponse('No signature', status=401)
# request.body is the raw bytes. This is what you hash.
computed = hmac.new(
secret,
msg=request.body,
digestmod=hashlib.sha512,
).hexdigest()
if not hmac.compare_digest(computed, signature):
return HttpResponse('Invalid signature', status=401)
# Now parse the verified body
event = json.loads(request.body)
# Process the event
print(f"Verified event: {event['event']}")
return HttpResponse(status=200)
Key points for Django:
request.body is raw bytes. Always use this for hashing.request.data (DRF) is parsed. Never use this for hashing.hmac.compare_digest() does constant-time comparison. Do not use ==.@csrf_exempt decorator is required because Paystack cannot send a CSRF token. See Paystack Webhook Returning 403 in Django for details.Laravel's middleware processes the request body before your controller sees it. Use file_get_contents('php://input') to read the raw body directly from PHP's input stream.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class PaystackWebhookController extends Controller
{
public function handle(Request $request)
{
$secret = config('services.paystack.secret_key');
$signature = $request->header('X-Paystack-Signature');
if (!$signature) {
return response('No signature', 401);
}
// Read the raw body directly from PHP input
$rawBody = file_get_contents('php://input');
$computed = hash_hmac('sha512', $rawBody, $secret);
if (!hash_equals($computed, $signature)) {
return response('Invalid signature', 401);
}
// Parse the verified raw body
$event = json_decode($rawBody, true);
// Process the event
logger()->info('Verified event: ' . $event['event']);
return response('OK', 200);
}
}
Important notes for Laravel:
$request->all() or $request->getContent() after middleware has touched the request. file_get_contents('php://input') reads the raw stream directly.hash_equals() is PHP's constant-time string comparison. Do not use ===.If you have confirmed you are hashing the raw body and the signature still fails, check these:
1. Wrong secret key
You are using your test secret key but the webhook came from live mode (or vice versa). Paystack uses the secret key of the environment that triggered the event. If a live transaction fires a webhook, you must verify with your live secret key. Check your environment variables.
2. Secret key has extra whitespace
// WRONG: trailing newline from .env
PAYSTACK_SECRET_KEY="sk_live_abc123
"
// RIGHT: no trailing whitespace
PAYSTACK_SECRET_KEY=sk_live_abc123
Copy-pasting keys from the dashboard sometimes adds trailing whitespace or newline characters. Trim your key: process.env.PAYSTACK_SECRET_KEY.trim().
3. Reverse proxy modifying the body
If you have Nginx, Apache, or a load balancer in front of your app, check that it is not modifying, decompressing, or re-encoding the request body. Gzip decompression at the proxy level and then re-compression can change the bytes.
4. Encoding mismatch
Paystack sends UTF-8. If your server interprets the body as a different encoding (like Latin-1) and then re-encodes it, the bytes will differ. Make sure your server reads the body as UTF-8.
5. Request body being read twice
In some frameworks, the request body is a stream that can only be read once. If middleware reads it first, your verification code gets an empty body. Make sure no middleware consumes the body before your handler runs, or store the raw bytes on the first read.
After implementing the fix, verify it works with this test:
const crypto = require('crypto');
// Simulate what Paystack does
function simulatePaystackWebhook(secret) {
const body = JSON.stringify({
event: 'charge.success',
data: {
id: 123456789,
reference: 'test_ref_001',
amount: 50000,
currency: 'NGN',
status: 'success',
},
});
const signature = crypto
.createHmac('sha512', secret)
.update(body)
.digest('hex');
return { body, signature };
}
// Test your verification function
function testVerification() {
const secret = 'sk_test_xxxxx'; // use your test key
const { body, signature } = simulatePaystackWebhook(secret);
// Your verification logic
const hash = crypto
.createHmac('sha512', secret)
.update(body) // must be the exact same string
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(hash),
Buffer.from(signature)
);
console.log('Signature valid:', isValid); // should be true
// Now test with parsed-and-re-serialized body (should fail)
const parsed = JSON.parse(body);
const reserialized = JSON.stringify(parsed);
const wrongHash = crypto
.createHmac('sha512', secret)
.update(reserialized)
.digest('hex');
console.log('Same after re-serialize:', body === reserialized);
console.log('Hash matches with re-serialized:', wrongHash === hash);
// These may or may not match. That is the problem.
}
testVerification();
You can also test with a real webhook. Set up your webhook URL using ngrok or Cloudflare Tunnel, add logging to your handler that prints both the computed hash and the received signature, and trigger a test transaction from the Paystack dashboard. If both hashes match, your fix is working.
For the complete list of Paystack errors, see Paystack Errors and Troubleshooting: The Complete Reference. For webhook setup patterns beyond signature verification, see Verifying the x-paystack-signature Header Correctly.
Set up your webhook route correctly from the start with these rules:
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