This guide receives Paystack webhooks in a Next.js App Router app and settles payments from them safely. You’ll build:
lib/paystack.ts: the signature check and the verify call,lib/payments.ts: settle a payment from Paystack’s answer, once,app/api/webhooks/paystack/route.ts: the route handler,- tests that post signed and forged webhooks to the handler without calling Paystack.
Every code block was run in a fresh Next.js 16 app (create-next-app with TypeScript and the App Router): the tests pass and next build succeeds. Paystack facts are from its webhooks and verify documentation, checked in November 2026. The full charge flow, for mobile money in Ghana and Kenya, is in the Paystack mobile money guide.
Three things that break Paystack webhooks in Next.js
- Parsing before checking. The
x-paystack-signatureheader is an HMAC SHA512 of the raw body, keyed with your secret key.await request.json()throws those bytes away, andJSON.stringifyrarely gives them back exactly. Read the body once as bytes, check it, then parse. - Trusting the body. A valid signature proves Paystack sent it, but the order is only paid when
GET /transaction/verify/:referencesayssuccessand the amount and currency match. The body only tells you which reference to look up. - Something answering before your handler. Middleware that redirects unauthenticated requests to a login page, or a
trailingSlashredirect, means your handler never runs. Exclude the webhook path from auth middleware, and give Paystack the exact URL.
There’s no bodyParser: false to set: that was the Pages Router. Route handlers get a standard Request and leave the body alone.
Setup
Add your test secret key to .env.local (Next.js keeps it out of git by default). Paystack signs webhooks with the same secret key; there’s no separate webhook secret. Set the webhook URL under Settings → API Keys & Webhooks, for example https://your-app.com/api/webhooks/paystack.
PAYSTACK_SECRET_KEY=sk_test_your_secret_key
The Paystack helpers
// lib/paystack.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export class PaystackError extends Error {}
/** x-paystack-signature: an HMAC SHA512 of the raw body, keyed with your secret key. */
export function signatureOk(raw: Buffer, signature: string | null, secret = process.env.PAYSTACK_SECRET_KEY ?? ''): boolean {
if (!secret || !signature) return false; // an empty key would make every signature easy to forge
const expected = Buffer.from(createHmac('sha512', secret).update(raw).digest('hex'));
const given = Buffer.from(signature);
return given.length === expected.length && timingSafeEqual(given, expected);
}
export interface Transaction {
reference: string;
status: string; // success, failed, abandoned, reversed, ongoing, pending...
amount: number; // in the subunit: pesewas for GHS, cents for KES
currency: string;
}
/** The source of truth for a payment, or null if Paystack has no transaction with that reference. */
export async function verifyTransaction(reference: string): Promise<Transaction | null> {
let res: Response;
try {
res = await fetch(`https://api.paystack.co/transaction/verify/${encodeURIComponent(reference)}`, {
headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` },
cache: 'no-store',
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
throw new PaystackError(`Paystack unreachable: ${(err as Error).message}`);
}
const body = await res.json().catch(() => null);
if (res.ok && body?.status === true && body.data) return body.data as Transaction;
if (res.status === 400 || res.status === 404) return null;
throw new PaystackError(`Verify failed: HTTP ${res.status}`);
}
Settle a payment once
Your database goes here. This file keeps payments in a Map so the guide runs on its own: replace the three store functions with your database, keeping the rule in update: change a row only if it is still pending, in one statement, for example UPDATE payments SET status = $2 WHERE reference = $1 AND status = 'pending'. Then a repeated webhook can never fulfil an order twice.
// lib/payments.ts
import type { Transaction } from './paystack';
export interface Payment {
reference: string; // yours, sent when you started the charge
amount: number; // subunits
currency: 'GHS' | 'KES';
status: 'pending' | 'paid' | 'failed' | 'review';
note?: string;
}
// ── Replace with your database ──────────────────────────────────────────────
const rows = new Map<string, Payment>();
export async function insertPayment(p: Payment): Promise<void> {
rows.set(p.reference, { ...p });
}
export async function findPayment(reference: string): Promise<Payment | null> {
const p = rows.get(reference);
return p ? { ...p } : null;
}
/** Changes a payment only while it is still pending. True if this call changed it. */
async function update(reference: string, fields: Partial<Payment>): Promise<boolean> {
const p = rows.get(reference);
if (!p || p.status !== 'pending') return false;
rows.set(reference, { ...p, ...fields });
return true;
}
// ────────────────────────────────────────────────────────────────────────────
/** Applies Paystack's verified answer. Returns the change made, or null if there was nothing to do. */
export async function settle(payment: Payment, tx: Transaction): Promise<Payment['status'] | null> {
if (tx.reference !== payment.reference || payment.status !== 'pending') return null;
const status = String(tx.status).toLowerCase();
if (status === 'success') {
const exact = tx.amount === payment.amount && tx.currency === payment.currency;
const to = exact ? 'paid' : 'review'; // paid a different amount: a person must look
return (await update(payment.reference, { status: to, note: exact ? undefined : `PAID ${tx.amount} ${tx.currency}` })) ? to : null;
}
if (['failed', 'abandoned', 'reversed'].includes(status)) {
return (await update(payment.reference, { status: 'failed', note: status })) ? 'failed' : null;
}
return null; // still in progress: a later webhook or your reconcile job settles it
}
Fulfil the order (send the email, ship the goods) only when settle() returns 'paid'. It returns null for a repeat, so a webhook that arrives twice, or races your reconcile job, fulfils nothing the second time.
The route handler
// app/api/webhooks/paystack/route.ts
import { PaystackError, signatureOk, verifyTransaction } from '@/lib/paystack';
import { findPayment, settle } from '@/lib/payments';
export async function POST(request: Request): Promise<Response> {
const raw = Buffer.from(await request.arrayBuffer()); // the exact bytes Paystack signed
if (!signatureOk(raw, request.headers.get('x-paystack-signature'))) {
return new Response('Invalid signature', { status: 401 });
}
let event: { event?: string; data?: { reference?: unknown } };
try {
event = JSON.parse(raw.toString('utf8'));
} catch {
return new Response('ok'); // signed but not JSON: nothing to do
}
const reference = event.data?.reference;
if (!String(event.event ?? '').startsWith('charge.') || typeof reference !== 'string') {
return new Response('ok'); // transfers and other events: handle them in their own code
}
const payment = await findPayment(reference);
if (!payment) return new Response('ok'); // not ours
try {
const tx = await verifyTransaction(reference); // never the body: ask Paystack
const changed = tx ? await settle(payment, tx) : null;
if (changed === 'paid') {
// Fulfil the order here, or queue a job that does. This runs once per payment.
console.info('Paystack payment confirmed', reference);
}
} catch (err) {
if (err instanceof PaystackError) return new Response('Try again', { status: 500 }); // Paystack retries
throw err;
}
return new Response('ok');
}
The handler answers 200 for anything it chooses to ignore, so Paystack doesn’t keep retrying it, and 500 only when it couldn’t ask Paystack, so the retry is useful. Paystack sends charge webhooks for successful payments only: a customer who declines on their phone never produces one. Run a scheduled job that verifies payments still pending after a few minutes; the mobile money guide has one.
Paystack sends every event for your account to this one URL, including transfer.* events if you send payouts. Handle those in the same route, after the same signature check; the Paystack payouts guide shows how.
Test it without calling Paystack
The test calls the exported POST directly with a standard Request, signs bodies with a test key, and replaces fetch with a fake Paystack. Install a TypeScript test runner that understands the @/ path alias:
npm install --save-dev tsx
npx tsx --test tests/paystack-webhook.test.ts
// tests/paystack-webhook.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { POST } from '@/app/api/webhooks/paystack/route';
import { findPayment, insertPayment, type Payment } from '@/lib/payments';
const KEY = 'sk_test_webhook_secret';
process.env.PAYSTACK_SECRET_KEY = KEY;
let paystack: Record<string, { status: string; amount: number; currency: string }> = {};
let verifyCalls = 0;
let down = false;
globalThis.fetch = (async (url: string | URL) => {
verifyCalls += 1;
if (down) return new Response('Bad gateway', { status: 502 });
const reference = decodeURIComponent(String(url).split('/').pop()!);
const tx = paystack[reference];
if (!tx) return Response.json({ status: false, message: 'Transaction reference not found' }, { status: 400 });
return Response.json({ status: true, data: { reference, ...tx } });
}) as typeof fetch;
function webhook(body: unknown, key = KEY, raw = JSON.stringify(body)) {
const signature = createHmac('sha512', key).update(raw).digest('hex');
return POST(new Request('http://localhost/api/webhooks/paystack', { method: 'POST', body: raw, headers: { 'x-paystack-signature': signature } }));
}
let n = 0;
async function payment(amount = 2550): Promise<Payment> {
const p: Payment = { reference: `order-${++n}-${Date.now()}`, amount, currency: 'GHS', status: 'pending' };
await insertPayment(p);
return p;
}
beforeEach(() => {
paystack = {};
verifyCalls = 0;
down = false;
});
test('a signed charge.success is paid only after verify says so', async () => {
const p = await payment();
paystack[p.reference] = { status: 'success', amount: 2550, currency: 'GHS' };
const res = await webhook({ event: 'charge.success', data: { reference: p.reference, amount: 2550 } });
assert.equal(res.status, 200);
assert.equal((await findPayment(p.reference))?.status, 'paid');
});
test('the signature covers the raw bytes, not re-serialised JSON', async () => {
const p = await payment();
paystack[p.reference] = { status: 'success', amount: 2550, currency: 'GHS' };
// Paystack's bytes, with spacing JSON.stringify would never produce:
const raw = `{ "event": "charge.success", "data": { "reference": "${p.reference}" } }`;
assert.equal((await webhook(null, KEY, raw)).status, 200);
assert.equal((await findPayment(p.reference))?.status, 'paid');
});
test('forged, unsigned or wrongly keyed webhooks change nothing', async () => {
const p = await payment();
paystack[p.reference] = { status: 'success', amount: 2550, currency: 'GHS' };
const body = { event: 'charge.success', data: { reference: p.reference } };
assert.equal((await webhook(body, 'sk_test_someone_else')).status, 401);
const unsigned = await POST(new Request('http://localhost/api/webhooks/paystack', { method: 'POST', body: JSON.stringify(body) }));
assert.equal(unsigned.status, 401);
process.env.PAYSTACK_SECRET_KEY = '';
assert.equal((await webhook(body, '')).status, 401); // no key configured: refuse everything
process.env.PAYSTACK_SECRET_KEY = KEY;
assert.equal(verifyCalls, 0);
assert.equal((await findPayment(p.reference))?.status, 'pending');
});
test('the body is never trusted: verify decides', async () => {
const p = await payment();
paystack[p.reference] = { status: 'ongoing', amount: 2550, currency: 'GHS' };
await webhook({ event: 'charge.success', data: { reference: p.reference, status: 'success' } });
assert.equal((await findPayment(p.reference))?.status, 'pending');
paystack[p.reference].status = 'abandoned';
await webhook({ event: 'charge.success', data: { reference: p.reference } });
assert.equal((await findPayment(p.reference))?.status, 'failed');
});
test('a different amount or currency goes to review, and final means final', async () => {
const p = await payment();
paystack[p.reference] = { status: 'success', amount: 255, currency: 'GHS' };
await webhook({ event: 'charge.success', data: { reference: p.reference } });
assert.deepEqual([(await findPayment(p.reference))?.status, (await findPayment(p.reference))?.note], ['review', 'PAID 255 GHS']);
const q = await payment();
paystack[q.reference] = { status: 'success', amount: 2550, currency: 'KES' };
await webhook({ event: 'charge.success', data: { reference: q.reference } });
assert.equal((await findPayment(q.reference))?.status, 'review');
paystack[q.reference].currency = 'GHS'; // a later, different answer doesn't undo it
await webhook({ event: 'charge.success', data: { reference: q.reference } });
assert.equal((await findPayment(q.reference))?.status, 'review');
});
test('a repeated webhook fulfils once', async () => {
const p = await payment();
paystack[p.reference] = { status: 'success', amount: 2550, currency: 'GHS' };
const logged: unknown[] = [];
const info = console.info;
console.info = (...args: unknown[]) => void logged.push(args);
try {
await Promise.all([1, 2, 3].map(() => webhook({ event: 'charge.success', data: { reference: p.reference } })));
} finally {
console.info = info;
}
assert.equal(logged.length, 1);
});
test('ignored events answer 200; an unreachable Paystack answers 500 so it retries', async () => {
const p = await payment();
assert.equal((await webhook({ event: 'transfer.success', data: { reference: p.reference } })).status, 200);
assert.equal((await webhook({ event: 'charge.success', data: { reference: 'not-ours' } })).status, 200);
assert.equal((await webhook(null, KEY, 'not json')).status, 200);
assert.equal(verifyCalls, 0);
down = true;
assert.equal((await webhook({ event: 'charge.success', data: { reference: p.reference } })).status, 500);
assert.equal((await findPayment(p.reference))?.status, 'pending');
});
Common errors and fixes
Every Paystack status and error type is explained in the Paystack error reference.
| Symptom | Likely cause | Fix |
|---|---|---|
Every webhook gets 401 |
The body was parsed before the check, or the key is from the other mode | Hash request.arrayBuffer(); use the secret key of the same mode (test or live) as the webhook |
401 only in production |
PAYSTACK_SECRET_KEY isn’t set in the deployed environment |
Set it in your host’s environment variables and redeploy |
Paystack shows 307 or 308 |
Auth middleware or a trailing-slash redirect | Exclude /api/webhooks from the middleware matcher; use the exact URL |
Module not found: node:crypto |
The route was switched to the Edge runtime | Remove the runtime setting: route handlers use Node.js by default |
Route segment config "runtime" is not compatible with nextConfig.cacheComponents |
export const runtime in a Next.js 16 app with Cache Components |
Delete the line; the default Node.js runtime is what you want |
| Orders fulfilled twice | Fulfilment ran on every webhook | Fulfil only when settle() returns 'paid' |
| Declined payments stay pending | Paystack sends no webhook for them | Verify pending payments from a scheduled job |
Going to production
- Switch to your live secret key in the production environment only. Live webhooks are signed with it.
- Point Paystack at the production URL, and send one real small payment end to end.
- Replace the
Mapinlib/payments.tswith your database, keeping the conditional update. - Log every webhook’s reference and outcome, so you can answer “did they pay?” from your own records.
Using Flutterwave too? See Flutterwave webhooks in Next.js. Want the whole integration (charges, webhooks, reconcile job, payouts and an admin view) done for you? The Next.js starter kit includes it.
Frequently asked questions
Why does my Paystack signature check fail in Next.js?
Usually because the body was parsed before the check. x-paystack-signature is an HMAC SHA512 of the exact bytes Paystack sent. Read them with await request.arrayBuffer() (or request.text()), check the signature, and only then JSON.parse. Never JSON.stringify(await request.json()) and hash that.
Do I need to disable the body parser for a Paystack webhook in the App Router?
No. That was the Pages Router (export const config = { api: { bodyParser: false } }). App Router route handlers get a standard Request and don't parse the body for you: read it raw with request.arrayBuffer().
Can the Paystack webhook route run on the Edge runtime?
Not this one: it uses node:crypto, which the Edge runtime doesn't have. Route handlers use the Node.js runtime by default, so leave out any runtime setting. In a new Next.js 16 app (cacheComponents on), the build even refuses export const runtime in a route.
Should I mark the order paid from the webhook body?
No. Use the body only to know which reference to check. Call GET /transaction/verify/:reference and mark the order paid only if the status is success and the amount and currency match your order.