To receive Paystack webhooks on Vercel, create an API route in your Next.js or plain Vercel project, disable automatic body parsing so you can access the raw request body for HMAC signature verification, verify the X-Paystack-Signature header using crypto.createHmac with your secret key, then return a 200 status immediately before doing any heavy processing.
If your application is already deployed on Vercel, using its serverless functions as your webhook endpoint is the simplest path. You do not need to spin up a separate Express server, manage a VPS, or configure a reverse proxy. You write a function, push to Git, and Vercel deploys it to a globally distributed edge network with HTTPS baked in.
Paystack does not care what kind of server receives its webhook. It sends an HTTP POST request with a JSON body and an X-Paystack-Signature header. As long as your endpoint can receive that POST, read the raw body, verify the signature, and return a 200, you are good. Vercel functions can do all of that.
There are a few things to watch out for, though. Vercel's automatic body parsing can break signature verification. The function timeout on the Hobby plan is 10 seconds (60 seconds on Pro). And the Edge runtime does not have Node.js built-in crypto. Each of these has a straightforward fix, which we will walk through in order.
If you want to understand the full webhook architecture before diving into Vercel specifics, start with the complete webhook engineering guide.
Vercel gives you two runtimes for API routes: the Node.js runtime and the Edge runtime. For Paystack webhooks, use the Node.js runtime. Here is why.
The Edge runtime is fast and runs closer to users globally, but it runs on a restricted V8 environment. It does not have access to Node.js built-in modules like crypto, buffer, or stream. Paystack signature verification requires HMAC-SHA512, which you would need to implement using the Web Crypto API on Edge. That is doable but adds unnecessary complexity when the Node.js runtime hands you crypto.createHmac out of the box.
The Edge runtime also has a smaller memory limit (128 MB vs up to 1024 MB on Node.js) and cannot use npm packages that depend on native Node.js APIs. If your webhook handler needs to write to a database using a driver like pg or mysql2, those will not work on Edge.
The only scenario where Edge makes sense is if you need sub-millisecond cold starts and you are comfortable using the Web Crypto API for HMAC. For most Paystack integrations, the Node.js runtime is the right call. The cold start difference (roughly 50ms vs 250ms) does not matter for a webhook that Paystack will retry if it does not hear back in time.
To explicitly set the Node.js runtime in a Next.js API route:
// Force Node.js runtime (not Edge)
export const runtime = 'nodejs';
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
This is where most developers get stuck. Vercel (through Next.js) automatically parses incoming JSON request bodies before your handler function sees them. By the time you call req.body, the JSON has already been parsed into a JavaScript object. If you then call JSON.stringify(req.body) to get a string for signature verification, the output may differ from the original payload. Whitespace, key ordering, and encoding differences mean your HMAC hash will not match Paystack's signature.
The fix is to disable automatic body parsing and read the raw bytes yourself. In a Next.js Pages Router API route, you add a config export:
// pages/api/webhooks/paystack.ts
export const config = {
api: {
bodyParser: false,
},
};
Then you read the raw body from the request stream:
import { IncomingMessage } from 'http';
function getRawBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
In the Next.js App Router (route handlers), the approach is different. The request object is a standard Web API Request, so you call await request.text() to get the raw body as a string. No config needed:
// app/api/webhooks/paystack/route.ts
export async function POST(request: Request) {
const rawBody = await request.text();
// rawBody is the exact string Paystack sent
}
Whichever router you use, the principle is the same: get the untouched bytes that Paystack sent, hash them, and compare. For a deeper dive into this problem, see The Raw Body Problem.
Once you have the raw body, signature verification is straightforward. Paystack signs every webhook payload with HMAC-SHA512 using your webhook secret key (found in your Paystack dashboard under Settings > API Keys & Webhooks). It sends the resulting hash in the X-Paystack-Signature header.
Your job is to compute the same hash on your end and compare:
import crypto from 'crypto';
function verifyPaystackSignature(
rawBody: string,
signature: string,
secret: string
): boolean {
const hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
return hash === signature;
}
A few important notes:
crypto.timingSafeEqual to prevent timing attacks, though for webhook verification the risk is low.Here is a timing-safe version of the comparison:
function verifySignatureTimingSafe(
rawBody: string,
signature: string,
secret: string
): boolean {
const hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
const hashBuffer = Buffer.from(hash);
const sigBuffer = Buffer.from(signature);
if (hashBuffer.length !== sigBuffer.length) return false;
return crypto.timingSafeEqual(hashBuffer, sigBuffer);
}
For the full details on signature verification across frameworks, see Verifying the X-Paystack-Signature Header Correctly.
Here is a production-ready Paystack webhook handler for the Next.js Pages Router on Vercel. Copy it, set your environment variable, and deploy:
// pages/api/webhooks/paystack.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';
export const config = {
api: {
bodyParser: false,
},
};
function getRawBody(req: NextApiRequest): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
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_WEBHOOK_SECRET;
if (!secret) {
console.error('PAYSTACK_WEBHOOK_SECRET is not set');
return res.status(500).json({ error: 'Server misconfigured' });
}
const signature = req.headers['x-paystack-signature'] as string;
if (!signature) {
return res.status(400).json({ error: 'Missing signature' });
}
const rawBody = await getRawBody(req);
const hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
if (hash !== signature) {
console.warn('Invalid Paystack webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// Signature verified. Parse the event.
const event = JSON.parse(rawBody);
// Return 200 immediately
res.status(200).json({ received: true });
// Process the event after responding.
// On Vercel, code after res.json() may still execute
// within the function timeout window.
try {
switch (event.event) {
case 'charge.success':
// Handle successful payment
console.log(
'Payment successful: ' + event.data.reference
);
break;
case 'transfer.success':
console.log(
'Transfer successful: ' + event.data.transfer_code
);
break;
default:
console.log('Unhandled event: ' + event.event);
}
} catch (err) {
console.error('Error processing webhook:', err);
}
}
Set the environment variable in your Vercel project settings:
PAYSTACK_WEBHOOK_SECRET=your_webhook_secret_from_paystack_dashboard
After deploying, register the webhook URL in your Paystack dashboard. The URL will look like https://your-app.vercel.app/api/webhooks/paystack.
If you are using the App Router (which is the default in Next.js 13.4+), the handler looks a bit different. The App Router uses the standard Web API Request and Response objects instead of the Node.js-specific NextApiRequest:
// app/api/webhooks/paystack/route.ts
import crypto from 'crypto';
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const secret = process.env.PAYSTACK_WEBHOOK_SECRET;
if (!secret) {
console.error('PAYSTACK_WEBHOOK_SECRET is not set');
return NextResponse.json(
{ error: 'Server misconfigured' },
{ status: 500 }
);
}
const signature = request.headers.get('x-paystack-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 400 }
);
}
const rawBody = await request.text();
const hash = crypto
.createHmac('sha512', secret)
.update(rawBody)
.digest('hex');
if (hash !== signature) {
console.warn('Invalid Paystack webhook signature');
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
const event = JSON.parse(rawBody);
// Process the event
switch (event.event) {
case 'charge.success':
console.log(
'Payment successful: ' + event.data.reference
);
// Your business logic here
break;
case 'transfer.success':
console.log(
'Transfer successful: ' + event.data.transfer_code
);
break;
default:
console.log('Unhandled event: ' + event.event);
}
return NextResponse.json({ received: true }, { status: 200 });
}
Notice the differences from the Pages Router version. The App Router handler automatically gets the raw body through request.text() because the Request object does not auto-parse JSON. No special config needed. The export const runtime = 'nodejs' line ensures you get the Node.js runtime with full crypto support.
The App Router approach is cleaner for new projects. If you are starting fresh on Next.js 14 or 15, use this version.
Vercel serverless functions have hard timeout limits. On the Hobby plan, a function can run for at most 10 seconds. On Pro, that goes up to 60 seconds. On Enterprise, up to 900 seconds. If your webhook handler does not return a response within that window, Vercel kills the function and Paystack sees a timeout. Paystack will retry, and if retries keep timing out, it will eventually disable your webhook URL.
The golden rule: return 200 as fast as possible. Do signature verification, parse the event, and respond. Push everything else to a background job.
You have several options for background processing on Vercel:
waitUntil API lets you run a promise after the response has been sent. But it only works on Edge, and as we discussed, Edge has limitations for webhook handlers.res.status(200).json() will still execute until the function times out. This works for quick operations (a single database write) but is not reliable for long chains of work.For most African SaaS products processing Paystack payments, a simple database write followed by a cron job is the most cost-effective pattern. You do not need Kafka or RabbitMQ when you are handling hundreds of transactions a day. A webhook_events table and a cron that runs every minute will serve you well until you reach serious scale.
Before deploying your webhook handler to Vercel, walk through this checklist:
https://your-app.vercel.app/api/webhooks/paystack). Paystack will send a test event to verify the URL is reachable.A note on Preview deployments: every pull request on Vercel gets a unique URL. If you have set up your webhook URL pointing to your production domain, Preview deployments will not receive webhooks. This is usually what you want. For testing webhooks on Preview, use Paystack's test mode and register a temporary webhook URL pointing to the Preview deployment.
Also make sure your Vercel project does not have any middleware that blocks POST requests to your webhook route. Authentication middleware, rate limiters, or CORS checks can all silently reject Paystack's incoming requests. Exclude your webhook path from any middleware that was designed for browser traffic.
When your webhook is not working on Vercel, the problem is almost always one of these:
Signature verification fails on every request. This means you are not reading the raw body correctly. If you are on the Pages Router, make sure you exported the config with bodyParser: false. If you are on the App Router, make sure you are calling request.text() and not request.json(). Double-check that your PAYSTACK_WEBHOOK_SECRET environment variable matches the secret shown in your Paystack dashboard exactly, with no trailing whitespace or newline characters.
Function times out before responding. Check your Vercel function logs for the execution time. If your handler is doing database operations or API calls before returning 200, move that work after the response (or to a queue). A webhook handler should return 200 within 1-2 seconds at most.
Paystack says the webhook URL is unreachable. Verify that your API route is deployed and accessible. Try hitting the URL with a curl POST from your terminal. Check that your route file is in the correct directory (pages/api/ for Pages Router, app/api/ for App Router). Vercel only deploys files that are in the right location in your project structure.
Events arrive in Vercel logs but your database is not updated. This usually means your after-response processing is failing silently. The function may be timing out before the database write completes. Check for unhandled promise rejections. Add explicit error logging around every database operation in your webhook handler.
Vercel function logs are your best friend here. Every invocation is logged with its duration, status code, and any console output. Use console.log liberally in your webhook handler during development. You can always remove verbose logging later.
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