The Raw Body Problem: Why Your Paystack Signature Check Fails
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().
The Symptom
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.
Why It Happens
Here is what happens step by step:
- Paystack sends a POST request with a JSON body. The raw bytes look exactly like this:
{"event":"charge.success","data":{"id":123456,"reference":"abc","amount":50000}} - Paystack computes HMAC SHA512 over those exact bytes and puts the result in the
x-paystack-signatureheader. - The request arrives at your server. Before your handler runs, middleware (like
express.json()) parses the JSON into a JavaScript object. - Your handler receives
req.bodyas a JavaScript object:{ event: 'charge.success', data: { id: 123456, reference: 'abc', amount: 50000 } } - You call
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}} - This looks identical. But it might not be. JSON serialization is not deterministic. Differences can include:
- Whitespace. Paystack might include spaces after colons or newlines.
JSON.stringifydoes not add any by default. Or your serializer might add them when Paystack did not. - Key order. JavaScript objects do not guarantee key ordering in all contexts. Most engines preserve insertion order, but some serializers reorder keys alphabetically.
- Unicode escaping. The character "e" with an accent could be represented as the literal character or as a
\u00e9escape. Different serializers choose differently. - Number formatting. The number
50000could be serialized as50000,5e4, or50000.0. Unlikely withJSON.stringify, but possible with other serializers. - Trailing data. Some frameworks strip trailing whitespace or newlines from the body. If Paystack sent a trailing newline and your framework removed it, the bytes differ.
Even one byte of difference produces a completely different SHA512 hash. The comparison fails. Your handler rejects a legitimate webhook.
The Fix: Express.js
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.
The Fix: NestJS
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.
The Fix: Fastify
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.
The Fix: Django and Django REST Framework
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.
The Fix: Laravel (PHP)
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:
- CSRF protection. The
VerifyCsrfTokenmiddleware rejects the webhook POST before your handler runs. Add the webhook route to the$exceptarray. See webhooks and CSRF in Laravel. - Middleware that reads the body. If you have custom middleware that calls
$request->all()or$request->input()before the webhook handler, the raw body may be consumed. Check your middleware stack. - Nginx or Apache rewrites. If your web server modifies the request body (gzip decompression, re-encoding), the bytes your PHP code receives differ from what Paystack sent. See webhooks behind Nginx and a reverse proxy.
How to Confirm It Is the Raw Body Problem
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.
Key Takeaways
- ✓The most common Paystack webhook failure is signature verification failing because the framework parsed the body before you could hash it.
- ✓JSON parsing and re-serialization changes whitespace, key order, Unicode escaping, and number formatting. The re-serialized bytes do not match the original bytes Paystack signed.
- ✓In Express, use express.raw({ type: "application/json" }) on the webhook route to receive the body as a Buffer.
- ✓In NestJS, use a RawBody middleware or configure the raw body option on the specific route.
- ✓In Django, request.body gives you raw bytes by default. The problem appears only with Django REST Framework parsers.
- ✓In Laravel, use $request->getContent() or file_get_contents("php://input") to read the raw body.
- ✓Always configure raw body access on just the webhook route, not globally. Other routes still need parsed JSON.
Frequently Asked Questions
- Why does JSON.stringify not reproduce the exact same bytes Paystack sent?
- JSON serialization is not deterministic. Different serializers handle whitespace, key ordering, Unicode escaping, and number formatting differently. Even within the same language, JSON.stringify in Node.js may produce different output than json.dumps in Python for the same data structure. The HMAC hash is computed over the exact bytes, so even a single different character produces a completely different hash.
- Can I use body-parser instead of express.raw() for Paystack webhooks?
- The body-parser package (which express.json() wraps) parses JSON into a JavaScript object. That is exactly the problem. If you want to use body-parser globally, configure it with a verify callback that saves the raw body to a custom property like req.rawBody. Then use that raw body for HMAC verification instead of re-serializing req.body.
- Does this problem affect serverless platforms like Vercel or AWS Lambda?
- It depends on the platform. Vercel with Next.js API routes gives you the raw body through the request stream, but you may need to disable the built-in body parser on the webhook route. AWS Lambda with API Gateway receives the body as a string (not a parsed object) by default, which usually works. Check the platform-specific webhook guides for exact configuration.
- My signature check works locally but fails in production. Why?
- The most likely cause is a reverse proxy (Nginx, Apache, or a CDN) modifying the request body in production. Common modifications include gzip decompression, character set conversion, and stripping or adding whitespace. Another possibility is that your production environment uses a different middleware configuration than your development environment. Check your proxy config and middleware stack.
- How do I handle raw body access if I use TypeScript with strict types?
- In Express with TypeScript, the Request type does not include a rawBody property by default. You can extend the Request interface with a declaration merge or cast the request object. In NestJS, use the RawBodyRequest generic type from @nestjs/common. The key point is that the type system is a separate concern from the runtime behavior. Make sure the raw body is actually available at runtime first, then fix the types.
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