Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Webhooks on Vercel Serverless Functions

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.

Why Vercel Works for Paystack Webhooks

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.

Edge Runtime vs Node.js Runtime: Which One for Webhooks

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';

The Raw Body Problem on Vercel

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.

Verifying the Paystack Signature

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:

  • Use the raw body string, not a re-serialized object. This is why disabling body parsing matters.
  • The comparison should ideally use crypto.timingSafeEqual to prevent timing attacks, though for webhook verification the risk is low.
  • Your webhook secret is different from your Paystack secret key. Do not confuse the two.
  • Test mode and live mode can have different webhook secrets. Make sure you are using the right one for your environment.

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.

Complete Handler: Next.js Pages Router

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.

Complete Handler: Next.js App Router

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.

Handling Vercel Timeouts and Heavy Processing

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:

  • Vercel Functions with waitUntil (Edge runtime only): The 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.
  • Push to a queue: Send the event data to a message queue like Upstash Redis, AWS SQS, or a BullMQ queue. A separate worker processes the events. This is the most reliable approach for production.
  • Vercel Cron + Database: Write the raw event to a database table, return 200, then have a Vercel Cron job process unhandled events every minute.
  • After-response processing: In the Node.js runtime, code that runs after 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.

Environment Variables and Deployment Checklist

Before deploying your webhook handler to Vercel, walk through this checklist:

  1. Set PAYSTACK_WEBHOOK_SECRET in Vercel. Go to your project settings, then Environment Variables. Add the secret for all environments (Production, Preview, Development). Use your test mode secret for Preview and Development, and your live mode secret for Production.
  2. Deploy to Vercel. Push your code to Git. Vercel builds and deploys automatically.
  3. Register the webhook URL in Paystack. Go to your Paystack dashboard, Settings, then API Keys & Webhooks. Enter your webhook URL (e.g., https://your-app.vercel.app/api/webhooks/paystack). Paystack will send a test event to verify the URL is reachable.
  4. Test with a real transaction. Make a test-mode payment through your Paystack integration. Check your Vercel function logs to confirm the webhook arrived, the signature was verified, and your handler processed the event.
  5. Monitor the logs. Vercel's function logs show every invocation. Watch for signature verification failures, timeouts, or unhandled exceptions in the first few days after deployment.

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.

Debugging Common Issues on Vercel

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.

Key Takeaways

  • Vercel API routes work perfectly as Paystack webhook endpoints. You do not need a separate server or VPS to receive payment events.
  • You must disable automatic body parsing in your API route config to access the raw request body. Without the raw body, HMAC signature verification will always fail.
  • The Node.js runtime is the safer choice for webhook handlers because it gives you access to the built-in crypto module without workarounds.
  • Always return a 200 response immediately after signature verification, before processing the event. Vercel functions have a 10-second timeout on the Hobby plan.
  • Store your Paystack webhook secret in Vercel environment variables, never in your source code. Use different secrets for test and live mode.
  • For heavy processing (sending emails, updating databases), push the event to a queue or use Vercel background functions rather than blocking the webhook response.

Frequently Asked Questions

Can I use Vercel Edge Functions for Paystack webhooks?
Technically yes, but the Node.js runtime is a better choice. The Edge runtime does not have access to the Node.js crypto module, so you would need to use the Web Crypto API for HMAC-SHA512 verification. The Web Crypto API is asynchronous and more verbose. Unless you have a specific reason to use Edge (like needing sub-millisecond cold starts), stick with the Node.js runtime.
Does Vercel support receiving webhooks on the free Hobby plan?
Yes. The Hobby plan supports API routes and serverless functions. The main limitation is a 10-second function timeout. As long as your webhook handler returns 200 within 10 seconds (which it should if you follow the respond-first-process-later pattern), the Hobby plan works fine for Paystack webhooks.
How do I test Paystack webhooks locally with Vercel?
Run your Next.js app locally with "npm run dev" or "vercel dev". This starts a local server, usually on port 3000. Then use a tunneling tool like ngrok or Cloudflare Tunnel to expose your local server to the internet. Register the tunnel URL as your webhook endpoint in the Paystack dashboard (test mode). Paystack can now reach your local handler through the tunnel.
Why does my webhook handler work locally but fail on Vercel?
The most common cause is a missing environment variable. Vercel does not automatically use your local .env file. You need to add PAYSTACK_WEBHOOK_SECRET in your Vercel project settings under Environment Variables. Another common cause is middleware (authentication, CORS) that blocks the incoming POST request on the deployed version but not locally.
Can I have multiple webhook handlers on different routes in the same Vercel project?
Yes. You can create as many API routes as you want. However, Paystack only lets you register one webhook URL per account. If you need to route different events to different handlers, create a single webhook endpoint that receives all events, then dispatches them internally based on the event type.

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