In Next.js App Router, you handle Paystack webhooks by creating a Route Handler in app/api/webhooks/paystack/route.ts. Read the raw request body with request.text(), compute an HMAC SHA512 hash using your secret key, compare it to the x-paystack-signature header, and return a 200 Response immediately. No CSRF exemption is needed because Route Handlers do not use CSRF protection by default.
The Next.js App Router introduced Route Handlers, which are server-side functions that handle HTTP requests directly. Unlike API Routes in the Pages Router, Route Handlers give you full control over the request and response objects using the standard Web Request and Response APIs. That makes them a natural fit for webhook endpoints.
For Paystack webhooks, the key advantage is that Route Handlers do not automatically parse the request body. You can read the raw body as a string with request.text(), which is exactly what you need for HMAC signature verification. No middleware configuration, no workarounds. It just works.
Route Handlers also do not apply CSRF protection by default. In the Pages Router, you sometimes had to fight middleware or custom headers. In the App Router, a POST request from Paystack's servers arrives and gets processed without any token checks getting in the way.
This guide assumes you are using Next.js 13.4 or later with the App Router. If you are on the Pages Router, see Handle Paystack Webhooks in Next.js Pages Router instead.
Before you start, make sure you have:
app/ directory)PAYSTACK_SECRET_KEYcrypto support)Your .env.local file should contain:
PAYSTACK_SECRET_KEY=sk_test_your_key_here
Never commit this key to version control. Add .env.local to your .gitignore file if it is not already there.
For local development, you will need a tunnel like ngrok to expose your local server to the internet so Paystack can reach your webhook endpoint. Run ngrok http 3000 and paste the HTTPS URL into your Paystack Dashboard under Settings > API Keys & Webhooks.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Create the file app/api/webhooks/paystack/route.ts. This path means your webhook URL will be https://yourdomain.com/api/webhooks/paystack.
import crypto from 'crypto';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const secret = process.env.PAYSTACK_SECRET_KEY;
if (!secret) {
console.error('PAYSTACK_SECRET_KEY is not set');
return NextResponse.json(
{ error: 'Server configuration error' },
{ status: 500 }
);
}
// 1. Read the raw body as a string
const rawBody = await request.text();
// 2. Get the signature from the header
const signature = request.headers.get('x-paystack-signature');
if (!signature) {
return NextResponse.json(
{ error: 'No signature provided' },
{ status: 400 }
);
}
// 3. Compute HMAC SHA512
const hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
// 4. Compare
if (hash !== signature) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
// 5. Parse the event
const event = JSON.parse(rawBody);
// 6. Route the event
switch (event.event) {
case 'charge.success':
// Handle successful payment
// await handleChargeSuccess(event.data);
break;
case 'transfer.success':
// Handle successful transfer
break;
case 'transfer.failed':
// Handle failed transfer
break;
default:
console.log('Unhandled event type: ' + event.event);
}
// 7. Return 200 immediately
return NextResponse.json({ received: true }, { status: 200 });
}
A few things to note about this code:
request.text() gives you the raw body as a string. This is the value you pass to the HMAC function. If you used request.json() first, the parsed-and-re-stringified body might not match the original bytes, and the signature check would fail.async because request.text() returns a Promise.POST function. GET, PUT, and DELETE requests to this route will automatically return 405 Method Not Allowed.Paystack signs every webhook request by computing an HMAC SHA512 hash of the raw JSON body using your secret key. The resulting hex string is sent in the x-paystack-signature header.
Your job is to compute the same hash on your end and compare. If they match, the request is genuine. If they do not, someone is trying to send you fake events, or the body was modified in transit.
The most common cause of signature verification failures in Next.js is body parsing. If any middleware reads and parses the body before your Route Handler runs, the raw bytes are consumed. In the App Router, this is not usually a problem because Route Handlers get the raw Request object. But if you have custom middleware that reads the body, you will run into issues.
If you need to be extra safe, you can add a route segment config to ensure no body parsing happens:
// app/api/webhooks/paystack/route.ts
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// The rest of your handler...
The force-dynamic config ensures the route is never statically optimized, which is what you want for a webhook endpoint that must process every incoming request.
A production webhook handler needs to route different event types to different processing functions. Here is a clean pattern using a handler map:
type EventHandler = (data: Record<string, unknown>) => Promise<void>;
const eventHandlers: Record<string, EventHandler> = {
'charge.success': handleChargeSuccess,
'transfer.success': handleTransferSuccess,
'transfer.failed': handleTransferFailed,
'subscription.create': handleSubscriptionCreate,
'subscription.disable': handleSubscriptionDisable,
};
async function routeEvent(eventType: string, data: Record<string, unknown>) {
const handler = eventHandlers[eventType];
if (handler) {
await handler(data);
} else {
console.log('No handler registered for event: ' + eventType);
}
}
async function handleChargeSuccess(data: Record<string, unknown>) {
const reference = data.reference as string;
const amount = data.amount as number;
const currency = data.currency as string;
// Check idempotency: have we already processed this reference?
// If yes, return early.
// If no, grant value (update order, credit account, etc.)
console.log(
'Processing charge.success for reference: ' + reference +
', amount: ' + String(amount) +
' ' + currency
);
}
async function handleTransferSuccess(data: Record<string, unknown>) {
// Mark the transfer as completed in your database
}
async function handleTransferFailed(data: Record<string, unknown>) {
// Mark the transfer as failed, possibly retry or alert
}
async function handleSubscriptionCreate(data: Record<string, unknown>) {
// Activate the subscription in your system
}
async function handleSubscriptionDisable(data: Record<string, unknown>) {
// Deactivate the subscription
}
Then in your Route Handler, replace the switch statement with:
await routeEvent(event.event, event.data);
This approach keeps your handler clean and makes it easy to add new event types without modifying the main handler function.
Paystack can send the same event more than once. Network retries, timeouts, or internal recovery processes can all cause duplicates. If your handler credits a customer's account every time it receives a charge.success for the same reference, you will give away money.
The fix: deduplicate by the transaction reference before granting value.
import { db } from '@/lib/db'; // your database client
async function handleChargeSuccess(data: Record<string, unknown>) {
const reference = data.reference as string;
// Check if already processed
const existing = await db.query(
'SELECT id FROM processed_webhooks WHERE reference = $1',
[reference]
);
if (existing.rows.length > 0) {
console.log('Already processed reference: ' + reference);
return;
}
// Process the payment inside a transaction
await db.query('BEGIN');
try {
await db.query(
'INSERT INTO processed_webhooks (reference, event_type, processed_at) VALUES ($1, $2, NOW())',
[reference, 'charge.success']
);
await db.query(
'UPDATE orders SET status = $1, paid_at = NOW() WHERE payment_reference = $2',
['paid', reference]
);
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
}
The deduplication record and the business logic update happen in the same database transaction. If the order update fails, the deduplication record rolls back too, so a retry will correctly try again. For more on this pattern, see idempotent Paystack webhook handlers.
Paystack expects a response within a few seconds. If your handler sends emails, calls external APIs, or runs heavy database operations before responding, you risk a timeout. Paystack will then retry, and you will process the same event multiple times.
The production pattern is to return 200 immediately and push the event to a queue for background processing. In a Next.js project, you have several options:
For serverless deployments on Vercel, be aware that the function execution ends when you return the response. If you try to do work after returning 200, it may be cut off. Use Vercel's waitUntil if you need to extend execution, or push to an external queue.
// Using Vercel's waitUntil for lightweight post-response work
import { waitUntil } from '@vercel/functions';
export async function POST(request: Request) {
// ... signature verification ...
const event = JSON.parse(rawBody);
// Return 200 immediately
const response = NextResponse.json({ received: true }, { status: 200 });
// Schedule background work that continues after response
waitUntil(processEvent(event));
return response;
}
async function processEvent(event: { event: string; data: Record<string, unknown> }) {
await routeEvent(event.event, event.data);
}
For a deeper look at queuing webhook work, see queueing Paystack webhook work with BullMQ.
A few things that trip people up when building Paystack webhook handlers in the App Router:
1. Do not use request.json() before signature verification. Once you call request.json(), the body stream is consumed. You cannot then call request.text() to get the raw body. Always call request.text() first, verify the signature, then JSON.parse() the string.
2. Edge Runtime limitations. If you set export const runtime = 'edge', you cannot use the Node.js crypto module. You would need to use the Web Crypto API instead, which has a different syntax for HMAC operations. Stick with the Node.js runtime for webhook handlers unless you have a specific reason to use Edge.
3. Middleware interference. If you have a middleware.ts file that applies to all routes, make sure it does not modify or consume the request body for your webhook route. You can exclude the webhook path in your middleware matcher:
// middleware.ts
export const config = {
matcher: [
// Match all routes except the webhook endpoint
'/((?!api/webhooks).*)',
],
};
4. Vercel function timeout. On Vercel's Hobby plan, serverless functions time out after 10 seconds. On Pro, it is 60 seconds. If your webhook processing is complex, this is another reason to push work to a queue and respond immediately.
5. Test vs. live events. Both test and live events go to the same webhook URL. Check the data.domain field. If it is "test", the payment happened in test mode. If it is "live", it is real money. In production, you probably want to ignore test events.
You can test your webhook handler locally using ngrok and the Paystack test environment.
npm run devngrok http 3000https://abc123.ngrok.io)https://abc123.ngrok.io/api/webhooks/paystackYou can also send a manual test request with curl:
curl -X POST http://localhost:3000/api/webhooks/paystack -H "Content-Type: application/json" -H "x-paystack-signature: your_computed_hash_here" -d '{"event":"charge.success","data":{"reference":"test-ref-123","amount":50000,"currency":"NGN"}}'
To generate the correct signature for testing, compute the HMAC SHA512 of your JSON body using your test secret key. You can do this with a quick Node.js script or use an online HMAC calculator.
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