Handle Paystack Webhooks in Next.js Pages Router
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.
API Routes in the Pages Router
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.
Disable the Body Parser
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.
Read the Raw Body from the Request Stream
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.
Verify the HMAC SHA512 Signature
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.
Route Events to Handler Functions
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.
Middleware and CSRF Considerations
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.
Deployment and Production Notes
When deploying to Vercel, your API Route becomes a serverless function. A few things to keep in mind:
- Function timeout: Hobby plan gives you 10 seconds. Pro gives 60 seconds. Return 200 quickly and do not block on heavy operations.
- Cold starts: The first request after a period of inactivity may take longer. This is usually not a problem for webhooks because Paystack waits several seconds before considering a delivery failed.
- Environment variables: Set
PAYSTACK_SECRET_KEYin your Vercel project settings under Environment Variables. Make sure it is available in the Production environment. - Logs: Use
console.logfor 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.
Key Takeaways
- ✓Create an API Route at pages/api/webhooks/paystack.ts for the webhook endpoint.
- ✓Disable the built-in body parser by exporting config = { api: { bodyParser: false } } so you can read the raw body.
- ✓Read the raw body from the request stream using a buffer helper function.
- ✓Compute HMAC SHA512 with the Node.js crypto module and compare to x-paystack-signature.
- ✓Return res.status(200).json({ received: true }) immediately before any heavy processing.
- ✓No CSRF exemption is needed for API Routes in Next.js Pages Router.
Frequently Asked Questions
- Why do I need to disable bodyParser for Paystack webhooks in Next.js Pages Router?
- The default body parser converts the raw JSON bytes into a JavaScript object. When you try to re-stringify that object for HMAC computation, the whitespace or key ordering may differ from the original. This causes the signature check to fail. Disabling bodyParser lets you read the exact bytes Paystack sent.
- Can I use the same webhook handler for both Pages Router and App Router?
- No. Pages Router uses NextApiRequest and NextApiResponse objects, while App Router uses the standard Web Request and Response APIs. The signature verification logic is the same, but the code for reading the raw body and returning responses is different. Pick the one that matches your project setup.
- What happens if my Next.js webhook handler returns a non-200 status?
- Paystack treats any non-2xx response as a failed delivery and will retry the webhook. It retries several times with increasing intervals. If all retries fail, the event is dropped and you can find it in your Paystack Dashboard webhook logs.
- Do I need to handle GET requests on my webhook endpoint?
- No. Paystack only sends POST requests to your webhook URL. You should reject GET requests with a 405 Method Not Allowed response. In the Pages Router, check req.method at the top of your handler.
- Should I use Pages Router or App Router for new Paystack integrations?
- If you are starting a new project, use the App Router. Route Handlers are simpler for webhooks because they use the standard Web Request API, which gives you request.text() for raw body access without any configuration. The Pages Router approach works fine for existing projects.
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