Paystack Webhook Returning 400 in Next.js App Router
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.
What the Error Looks Like
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.
Why the App Router Is Different
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:
- No automatic parsing. The body is not pre-parsed. You must call
request.json()orrequest.text()yourself. - Stream consumed once. The body is a stream that can only be read once. If you call
request.json(), the stream is consumed. Callingrequest.text()afterward returns empty. - No rawBody property. There is no
req.rawBodylike 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.
The Complete Working Handler
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.
Common Mistakes That Cause 400 Errors
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
Pages Router Comparison
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().
Environment Variables on Vercel
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.
- Go to your Vercel project dashboard
- Navigate to Settings, then Environment Variables
- Add
PAYSTACK_SECRET_KEYwith your live secret key for production - Optionally add it for Preview and Development environments with the test key
- Redeploy after adding the variable
A 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.
Testing Locally
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.
Verification Checklist
After implementing your webhook handler, verify these items:
- File location is correct. For App Router:
app/api/webhooks/paystack/route.ts. The URL will be/api/webhooks/paystack. - POST function is exported as a named export. Not a default export.
- Body is read with request.text(). Not request.json().
- Signature is verified before parsing. JSON.parse happens after HMAC verification passes.
- PAYSTACK_SECRET_KEY is set. Both locally in .env.local and on Vercel in Environment Variables.
- Response is 200. Not 201, not 204, exactly 200.
- Local curl test passes. Use the curl command above to test with a computed signature.
- Vercel deployment test passes. Trigger a test transaction from the Paystack dashboard and check your Vercel Function Logs.
Key Takeaways
- ✓Next.js App Router route handlers use the Web Fetch API Request object, not the Node.js IncomingMessage. The body is a ReadableStream, not a pre-parsed object.
- ✓Use request.text() to read the raw body as a string. This preserves the exact bytes for HMAC signature verification. Do not use request.json() first.
- ✓The request body stream can only be consumed once. If you call request.json() first, calling request.text() afterward will return an empty string. Read with text() first, then JSON.parse() after verification.
- ✓App Router route handlers do not have Express-style middleware. There is no body-parser to fight. This actually makes webhook handling cleaner once you know the pattern.
- ✓Your route handler file must export a POST function. If you only export GET or a default function, POST requests will return 405.
- ✓Return NextResponse.json() with status 200 to tell Paystack the webhook was received. Paystack specifically expects a 200 response.
Frequently Asked Questions
- Can I use request.json() at all in a Paystack webhook handler?
- Not before signature verification. If you call request.json() first, the stream is consumed and you cannot get the raw body for HMAC verification. The correct pattern is: read with request.text(), verify the HMAC, then JSON.parse() the verified string.
- Does the App Router automatically parse JSON bodies for route handlers?
- No. Unlike the Pages Router (which auto-parses when bodyParser is enabled), the App Router route handlers give you a raw Web Request object. You must call request.json() or request.text() explicitly. For webhooks, always use request.text().
- Do I need to disable body parsing in the App Router like I do in the Pages Router?
- No. The App Router does not have a bodyParser config option because it does not auto-parse. The body is a ReadableStream by default. This actually makes webhook handling simpler in the App Router.
- Will this work on Vercel Edge Functions?
- Yes, with one caveat. Edge Functions use the Web Crypto API instead of Node.js crypto. You would need to use crypto.subtle.importKey and crypto.subtle.sign instead of crypto.createHmac. For simplicity, use the Node.js runtime (the default) for webhook handlers.
- My webhook works locally but returns 400 on Vercel. What is different?
- Check three things. First, is PAYSTACK_SECRET_KEY set in your Vercel environment variables? A missing key causes a crash. Second, did you redeploy after adding the environment variable? Vercel does not pick up new env vars until the next deployment. Third, check Vercel Function Logs for the actual error message.
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