In Next.js Pages Router, you handle Paystack webhooks by creating an API Route at pages/api/webhooks/paystack.ts. You must disable the built-in body parser with a custom config export, read the raw body manually from the request stream, compute an HMAC SHA512 hash with your secret key, and compare it to the x-paystack-signature header. Return a 200 status immediately after verification.
The Next.js Pages Router provides API Routes for building server-side endpoints. Any file inside pages/api/ becomes an API endpoint. These handlers receive Node.js IncomingMessage (req) and ServerResponse (res) objects, which are familiar if you have used Express.
The main challenge for Paystack webhooks is that Next.js API Routes parse the request body automatically by default. The built-in bodyParser middleware converts the raw JSON bytes into a JavaScript object before your handler runs. Once that happens, you cannot compute the HMAC signature over the original raw bytes, and verification fails.
The fix is straightforward: disable the body parser for your webhook route and read the raw body yourself. If you are using the App Router instead, see Handle Paystack Webhooks in Next.js App Router.
Create the file pages/api/webhooks/paystack.ts. The first thing you need is a config export that turns off the body parser:
import type { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';
// Disable the default body parser so we can read the raw body
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).json({ error: 'Method not allowed' });
}
// Handler code goes here...
}
With bodyParser: false, the req.body property will be undefined. You need to read the raw bytes from the request stream yourself. This is the only way to get the exact bytes that Paystack signed.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Since the body parser is disabled, you need a helper function that reads the request stream into a Buffer. Here is a straightforward implementation:
function getRawBody(req: NextApiRequest): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
req.on('end', () => {
resolve(Buffer.concat(chunks));
});
req.on('error', (err) => {
reject(err);
});
});
}
Alternatively, you can install the raw-body npm package, which handles edge cases like content-length limits and encoding:
npm install raw-body
import getRawBody from 'raw-body';
// Inside your handler:
const rawBody = await getRawBody(req);
Either approach gives you a Buffer containing the exact bytes Paystack sent. This Buffer is what you pass to the HMAC function.
With the raw body in hand, you can now verify the Paystack signature. Here is the complete handler:
import type { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';
export const config = {
api: {
bodyParser: false,
},
};
function getRawBody(req: NextApiRequest): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).json({ error: 'Method not allowed' });
}
const secret = process.env.PAYSTACK_SECRET_KEY;
if (!secret) {
console.error('PAYSTACK_SECRET_KEY is not set');
return res.status(500).json({ error: 'Server config error' });
}
// Read raw body
const rawBody = await getRawBody(req);
// Get signature header
const signature = req.headers['x-paystack-signature'] as string;
if (!signature) {
return res.status(400).json({ error: 'No signature' });
}
// Compute HMAC SHA512
const hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
if (hash !== signature) {
return res.status(400).json({ error: 'Invalid signature' });
}
// Parse the verified payload
const event = JSON.parse(rawBody.toString('utf-8'));
// Return 200 immediately
res.status(200).json({ received: true });
// Process the event (ideally in a background queue)
// await processEvent(event);
}
The critical line is .update(rawBody). You are passing the Buffer directly, not a re-serialized string. This guarantees the bytes match what Paystack signed.
Once the signature is verified and you have parsed the JSON, route the event by type. A clean approach is a handler map:
interface PaystackEvent {
event: string;
data: Record<string, unknown>;
}
const handlers: Record<string, (data: Record<string, unknown>) => Promise<void>> = {
'charge.success': async (data) => {
const reference = data.reference as string;
const amount = data.amount as number;
console.log('Payment received: ' + reference + ' for ' + String(amount));
// Update order status, credit user account, etc.
},
'transfer.success': async (data) => {
console.log('Transfer completed: ' + String(data.reference));
},
'transfer.failed': async (data) => {
console.log('Transfer failed: ' + String(data.reference));
},
'subscription.create': async (data) => {
console.log('Subscription created: ' + String(data.subscription_code));
},
};
async function processEvent(event: PaystackEvent) {
const handler = handlers[event.event];
if (handler) {
await handler(event.data);
} else {
console.log('Unhandled event type: ' + event.event);
}
}
In production, replace the console.log calls with actual database operations, queue pushes, or whatever your application needs. The key rule remains: keep the webhook handler itself fast and push heavy work to a background process.
Next.js API Routes in the Pages Router do not have built-in CSRF protection, so you do not need to exempt your webhook route from any CSRF middleware. However, if you have installed a custom CSRF package or authentication middleware that applies to all API routes, you need to skip it for the webhook endpoint.
If you use next-connect or a similar middleware library, make sure your webhook route does not pass through authentication or session middleware. Paystack's servers do not send cookies or auth tokens. They send the x-paystack-signature header, which is your only form of authentication.
If you have a global middleware file (middleware.ts) that checks authentication, exclude the webhook path:
// middleware.ts
export const config = {
matcher: [
'/((?!api/webhooks).*)',
],
};
This matcher pattern tells Next.js to run middleware on all routes except those starting with api/webhooks.
When deploying to Vercel, your API Route becomes a serverless function. A few things to keep in mind:
PAYSTACK_SECRET_KEY in your Vercel project settings under Environment Variables. Make sure it is available in the Production environment.console.log for webhook events. You can view these in the Vercel Functions logs in real time.For self-hosted Next.js deployments (running next start behind nginx or similar), make sure your reverse proxy passes the raw body through without modification. Some proxy configurations strip or re-encode the body, which will break signature verification.
This guide is part of the Paystack integration guides by language and framework series.
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