Body parsing middleware like express.json() or body-parser converts the raw request bytes into a JavaScript object before your webhook handler runs. When you re-serialize that object to verify the HMAC signature, the output differs from the original bytes Paystack signed. The fix is to ensure your webhook route receives the raw body. In Express, use express.raw({ type: "application/json" }) on the webhook route instead of express.json().
You followed the Paystack documentation. You compute HMAC SHA512 of the request body using your secret key. You compare it to the x-paystack-signature header. And it never matches.
You double-check your secret key. Correct. You try test mode and live mode. Both fail. You copy the example code from the Paystack docs character by character. Still fails.
You start to wonder if Paystack is sending the wrong signature. They are not. The problem is between Paystack and your verification code: your framework's body parser is silently rewriting the request body before you get to see it.
Here is what happens step by step:
{"event":"charge.success","data":{"id":123456,"reference":"abc","amount":50000}}
x-paystack-signature header.express.json()) parses the JSON into a JavaScript object.req.body as a JavaScript object: { event: 'charge.success', data: { id: 123456, reference: 'abc', amount: 50000 } }JSON.stringify(req.body) to get a string for the HMAC computation. The output might be:
{"event":"charge.success","data":{"id":123456,"reference":"abc","amount":50000}}
JSON.stringify does not add any by default. Or your serializer might add them when Paystack did not.\u00e9 escape. Different serializers choose differently.50000 could be serialized as 50000, 5e4, or 50000.0. Unlikely with JSON.stringify, but possible with other serializers.Even one byte of difference produces a completely different SHA512 hash. The comparison fails. Your handler rejects a legitimate webhook.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
The fix is to tell Express not to parse the body on the webhook route. Instead of receiving a JavaScript object, you receive a Buffer containing the raw bytes.
const crypto = require('crypto');
const express = require('express');
const app = express();
// Apply JSON parsing to all other routes
app.use((req, res, next) => {
if (req.path === '/webhooks/paystack') {
// Skip JSON parsing on the webhook route
return next();
}
express.json()(req, res, next);
});
// Webhook route uses express.raw() to get the 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'];
// req.body is a Buffer, not a parsed object
const hash = crypto
.createHmac('sha512', secret)
.update(req.body)
.digest('hex');
if (hash !== signature) {
return res.status(400).send('Invalid signature');
}
// Now parse the body manually
const event = JSON.parse(req.body.toString());
console.log('Event:', event.event);
res.status(200).send('OK');
}
);
There is a simpler pattern if you want to keep express.json() as global middleware. Configure it to save the raw body on a custom property:
const app = express();
// Global JSON parsing that also saves the raw body
app.use(
express.json({
verify: (req, res, buf) => {
// Save the raw body buffer for webhook verification
req.rawBody = buf;
},
})
);
app.post('/webhooks/paystack', (req, res) => {
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
// Use the saved raw body, not the parsed req.body
const hash = crypto
.createHmac('sha512', secret)
.update(req.rawBody)
.digest('hex');
if (hash !== signature) {
return res.status(400).send('Invalid signature');
}
// req.body is already parsed (by express.json)
const event = req.body;
console.log('Event:', event.event);
res.status(200).send('OK');
});
Both approaches work. The first is cleaner because the webhook route is completely isolated from JSON parsing middleware. The second is convenient if you have many routes and do not want to restructure your middleware stack.
NestJS uses Express under the hood, but wraps it in a module system that makes raw body access less obvious.
Option 1: Enable rawBody globally in NestJS.
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true, // Enables raw body access on all routes
});
await app.listen(3000);
}
bootstrap();
Then in your controller, access the raw body through the request object:
import { Controller, Post, Req, Res, RawBodyRequest } from '@nestjs/common';
import { Request, Response } from 'express';
import * as crypto from 'crypto';
@Controller('webhooks')
export class WebhookController {
@Post('paystack')
handlePaystackWebhook(
@Req() req: RawBodyRequest<Request>,
@Res() res: Response,
) {
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'] as string;
const hash = crypto
.createHmac('sha512', secret)
.update(req.rawBody) // rawBody is available because of the flag
.digest('hex');
if (hash !== signature) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.rawBody.toString());
res.status(200).send('OK');
}
}
Option 2: Use a middleware that saves the raw body. This is the same verify callback approach as Express, applied through a NestJS middleware class. See the NestJS docs on raw body for details specific to your version.
Fastify parses JSON bodies by default, but it stores the raw body if you enable the rawBody option:
const fastify = require('fastify')({
// This makes request.rawBody available on all routes
// that have the rawBody option set in the route config
});
// Register the raw body plugin
fastify.register(require('fastify-raw-body'), {
field: 'rawBody',
global: false, // Only enable where needed
runFirst: true,
});
fastify.post(
'/webhooks/paystack',
{ config: { rawBody: true } },
async (request, reply) => {
const crypto = require('crypto');
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = request.headers['x-paystack-signature'];
const hash = crypto
.createHmac('sha512', secret)
.update(request.rawBody)
.digest('hex');
if (hash !== signature) {
return reply.code(400).send('Invalid signature');
}
const event = request.body; // Already parsed by Fastify
console.log('Event:', event.event);
return reply.code(200).send('OK');
}
);
If you are not using the fastify-raw-body plugin, you can add a preParsing hook to capture the raw bytes before Fastify's content type parser runs.
Plain Django gives you raw bytes by default. request.body returns the raw bytes of the request body, untouched. This is one area where Django makes things easy.
import hmac
import hashlib
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def paystack_webhook(request):
secret = os.environ['PAYSTACK_SECRET_KEY'].encode('utf-8')
signature = request.headers.get('X-Paystack-Signature', '')
# request.body is raw bytes. No parsing has happened.
computed = hmac.new(
secret,
msg=request.body,
digestmod=hashlib.sha512
).hexdigest()
if not hmac.compare_digest(computed, signature):
return HttpResponse('Invalid signature', status=400)
event = json.loads(request.body)
return HttpResponse('OK', status=200)
The problem appears if you use Django REST Framework (DRF) and route the webhook through a DRF view or viewset. DRF's parsers consume the body before your view runs. By the time you access request.body, it may have been read by a parser and is no longer the raw bytes.
The fix: use a plain Django view (not a DRF view) for your webhook endpoint, or configure the DRF view to use no parser and access the raw body through request.stream.
Laravel's $request->getContent() returns the raw body as a string. This usually works fine because PHP does not destructively parse the body the way Express does.
public function handleWebhook(Request $request)
{
$secret = env('PAYSTACK_SECRET_KEY');
$signature = $request->header('X-Paystack-Signature');
$rawBody = $request->getContent();
$computed = hash_hmac('sha512', $rawBody, $secret);
if (!hash_equals($computed, $signature)) {
return response('Invalid signature', 400);
}
$event = json_decode($rawBody, true);
return response('OK', 200);
}
Where Laravel can trip you up:
VerifyCsrfToken middleware rejects the webhook POST before your handler runs. Add the webhook route to the $except array. See webhooks and CSRF in Laravel.$request->all() or $request->input() before the webhook handler, the raw body may be consumed. Check your middleware stack.Before restructuring your middleware, confirm that the raw body is actually the issue. Add temporary logging:
app.post('/webhooks/paystack', (req, res) => {
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
// Log what you are hashing
const bodyString = typeof req.body === 'string'
? req.body
: JSON.stringify(req.body);
console.log('Body type:', typeof req.body);
console.log('Body is Buffer:', Buffer.isBuffer(req.body));
console.log('Body length:', bodyString.length);
console.log('Body first 200 chars:', bodyString.substring(0, 200));
const hash = crypto
.createHmac('sha512', secret)
.update(bodyString)
.digest('hex');
console.log('Computed hash:', hash);
console.log('Received signature:', signature);
console.log('Match:', hash === signature);
res.status(200).send('OK');
});
If Body type is object and Body is Buffer is false, your framework parsed the body. That is the problem. Switch to express.raw() on this route.
If Body type is string or Body is Buffer is true, and the hash still does not match, the issue is elsewhere: wrong secret key, a proxy modifying the body, or a header being stripped. Check the signature verification guide for the full debugging checklist.
This article is part of the Paystack webhooks engineering guide.
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