In Next.js App Router, use request.text() to get the raw body string for signature verification. Do not use request.json() before verifying the signature, because that consumes the stream and you lose the raw bytes needed for HMAC comparison. Read the body once with request.text(), verify the HMAC against that raw string, then JSON.parse() it after verification passes.
You create a route handler at app/api/webhooks/paystack/route.ts. You deploy to Vercel or run locally. Paystack sends a webhook. Your handler returns 400 Bad Request or your signature verification fails silently.
In your Vercel logs or terminal, you see one of these:
// Error: body used already for...
// Or: SyntaxError: Unexpected end of JSON input
// Or: Signature verification failed
// Or: Empty body received
The core problem is how the App Router handles request bodies. It uses the Web Fetch API, which is fundamentally different from Express or the Pages Router API routes.
In the Pages Router, API routes receive req as a Node.js IncomingMessage with automatic body parsing. Next.js parses the JSON body and gives you req.body as an object.
In the App Router, route handlers receive a standard Web Request object. The body is a ReadableStream. Key differences:
request.json() or request.text() yourself.request.json(), the stream is consumed. Calling request.text() afterward returns empty.req.rawBody like some Express setups provide. You must handle raw body access yourself.For webhook signature verification, you need the raw body string. The pattern is: read with request.text() first, verify the signature, then JSON.parse() the verified string.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Create this file at app/api/webhooks/paystack/route.ts:
import crypto from 'crypto';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
try {
// Step 1: Get the raw body as a string (not JSON)
const rawBody = await request.text();
if (!rawBody) {
return NextResponse.json(
{ error: 'Empty body' },
{ status: 400 }
);
}
// Step 2: Get the signature header
const signature = request.headers.get('x-paystack-signature');
if (!signature) {
return NextResponse.json(
{ error: 'No signature' },
{ status: 401 }
);
}
// Step 3: Verify the signature using the raw body
const secret = process.env.PAYSTACK_SECRET_KEY!;
const hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
if (
!crypto.timingSafeEqual(
Buffer.from(hash),
Buffer.from(signature)
)
) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// Step 4: Parse the verified body
const event = JSON.parse(rawBody);
// Step 5: Handle the event
switch (event.event) {
case 'charge.success':
// Handle successful charge
await handleChargeSuccess(event.data);
break;
case 'transfer.success':
// Handle successful transfer
await handleTransferSuccess(event.data);
break;
default:
console.log('Unhandled event type:', event.event);
}
// Step 6: Return 200
return NextResponse.json({ received: true }, { status: 200 });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 500 }
);
}
}
async function handleChargeSuccess(data: any) {
// Your business logic here
console.log('Charge successful:', data.reference);
}
async function handleTransferSuccess(data: any) {
// Your business logic here
console.log('Transfer successful:', data.reference);
}
This is the complete pattern. The critical part is step 1: await request.text(). This reads the raw body as a string, preserving the exact bytes for signature verification.
Mistake 1: Calling request.json() before request.text()
// WRONG: This consumes the stream
export async function POST(request: Request) {
const body = await request.json(); // stream consumed here
const rawBody = await request.text(); // returns "" - stream already consumed
// Signature verification fails because rawBody is empty
const hash = crypto.createHmac('sha512', secret).update(rawBody).digest('hex');
// hash is wrong
}
Mistake 2: Using JSON.stringify on the parsed body
// WRONG: Re-serialization may produce different bytes
export async function POST(request: Request) {
const body = await request.json();
const rawBody = JSON.stringify(body); // may differ from original
// Signature may or may not match depending on key order, whitespace
}
Mistake 3: Not exporting a named POST function
// WRONG: Default export does not work for specific HTTP methods
export default async function handler(request: Request) {
// This will not receive POST requests in App Router
}
// RIGHT: Named export for POST
export async function POST(request: Request) {
// This receives POST requests
}
Mistake 4: Using the wrong file path
// WRONG paths:
// app/api/webhooks/paystack.ts (needs route.ts)
// app/webhooks/paystack/route.ts (missing /api prefix, but this works if you want /webhooks/paystack)
// pages/api/webhooks/paystack.ts (this is Pages Router, not App Router)
// RIGHT for /api/webhooks/paystack endpoint:
// app/api/webhooks/paystack/route.ts
If you are using the Pages Router instead of the App Router, the pattern is different. Pages Router API routes receive a Node.js-style req object with automatic body parsing.
File: pages/api/webhooks/paystack.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';
// Disable the default body parser to get raw body
export const config = {
api: {
bodyParser: false,
},
};
async function getRawBody(req: NextApiRequest): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk) => {
data += chunk;
});
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const rawBody = await getRawBody(req);
const signature = req.headers['x-paystack-signature'] as string;
if (!signature) {
return res.status(401).json({ error: 'No signature' });
}
const secret = process.env.PAYSTACK_SECRET_KEY!;
const hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature))) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
// Handle the event...
console.log('Event:', event.event);
return res.status(200).json({ received: true });
}
The key difference: in the Pages Router, you must set bodyParser: false in the config export and manually read the raw body from the request stream. In the App Router, you simply call request.text().
If you deploy to Vercel, make sure PAYSTACK_SECRET_KEY is set in your Vercel project's environment variables, not just in your local .env.local file.
PAYSTACK_SECRET_KEY with your live secret key for productionA missing secret key causes crypto.createHmac('sha512', undefined), which throws an error and returns 500. If your Vercel Function Logs show a crash on the webhook route, check the environment variable first.
To test your App Router webhook handler locally:
# 1. Start your Next.js dev server
npm run dev
# 2. In another terminal, simulate a Paystack webhook
SECRET="sk_test_your_test_key"
BODY='{"event":"charge.success","data":{"id":12345,"reference":"test_ref","amount":50000,"currency":"NGN","status":"success"}}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/api/webhooks/paystack -H "Content-Type: application/json" -H "x-paystack-signature: $SIGNATURE" -d "$BODY"
# Expected response: {"received":true}
This generates a valid HMAC-SHA512 signature using your test secret key and sends it with the request. Your handler should verify the signature and return 200.
For testing with real Paystack events, use ngrok to expose your local server and set the ngrok URL as your webhook URL in the Paystack test dashboard.
For the complete error reference, see Paystack Errors and Troubleshooting: The Complete Reference. For Vercel-specific webhook patterns, see Paystack Webhooks on Vercel Serverless Functions.
After implementing your webhook handler, verify these items:
app/api/webhooks/paystack/route.ts. The URL will be /api/webhooks/paystack.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