Bonaventure OgetoBy Bonaventure Ogeto|

Handle Paystack Webhooks in Next.js App Router

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.

Why the App Router Works Well for Webhooks

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.

Project Setup and Prerequisites

Before you start, make sure you have:

  • A Next.js project using the App Router (the app/ directory)
  • Your Paystack secret key stored in an environment variable called PAYSTACK_SECRET_KEY
  • Node.js 18 or later (for native crypto 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.

Create the Route Handler

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.
  • The function is async because request.text() returns a Promise.
  • We only export a POST function. GET, PUT, and DELETE requests to this route will automatically return 405 Method Not Allowed.

Understanding the Signature Verification

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.

Routing Events to Handler Functions

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.

Idempotency: Handling Duplicate Events

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.

Move Heavy Work to a Background Queue

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:

  • Database-backed queue: Write the raw event to a table and have a separate worker process pick it up. Simple and works with any database.
  • BullMQ with Redis: If you already have Redis, BullMQ gives you retries, delays, and rate limiting out of the box.
  • Cloud queues: AWS SQS, Google Cloud Tasks, or similar managed services.
  • Inngest or Trigger.dev: Serverless-friendly job queues that work well with Next.js deployments on Vercel.

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.

Next.js App Router Gotchas

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.

Testing Your Webhook Handler

You can test your webhook handler locally using ngrok and the Paystack test environment.

  1. Start your Next.js dev server: npm run dev
  2. Start ngrok: ngrok http 3000
  3. Copy the HTTPS URL from ngrok (something like https://abc123.ngrok.io)
  4. Go to Paystack Dashboard > Settings > API Keys & Webhooks
  5. Paste the URL with your route appended: https://abc123.ngrok.io/api/webhooks/paystack
  6. Make a test payment using a Paystack test card
  7. Watch your terminal for the webhook event log

You 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.

Key Takeaways

  • Create a Route Handler at app/api/webhooks/paystack/route.ts to receive Paystack POST requests.
  • Use request.text() to read the raw body before any JSON parsing, which is critical for signature verification.
  • Compute HMAC SHA512 with the Node.js crypto module and compare the hex digest to the x-paystack-signature header.
  • Return a new Response with status 200 immediately. Do heavy processing in a background queue.
  • Route Handlers in the App Router do not have CSRF protection, so no exemption configuration is needed.
  • Use route segment config to disable body parsing if you run into body-parsing middleware issues.
  • Always verify the event domain field to distinguish test from live webhooks in production.

Frequently Asked Questions

Do I need to disable CSRF protection for Paystack webhooks in Next.js App Router?
No. Route Handlers in the App Router do not have CSRF protection by default. Paystack webhook requests will reach your handler without any CSRF configuration. This is different from the Pages Router, where you may need to work around CSRF middleware.
Can I use the Edge Runtime for Paystack webhook handlers in Next.js?
You can, but it adds complexity. The Edge Runtime does not support the Node.js crypto module. You would need to use the Web Crypto API instead, which has a different syntax for HMAC operations. Unless you need edge-level latency for your webhook endpoint (which is unlikely), stick with the default Node.js runtime.
Why does my Paystack webhook signature verification fail in Next.js App Router?
The most common cause is reading the body with request.json() before computing the HMAC. Once you call request.json(), the body stream is consumed and you cannot get the raw string. Always call request.text() first, compute the HMAC over that raw string, and then JSON.parse() it after verification passes.
How do I handle Paystack webhooks on Vercel with the App Router?
The code is the same as local development. The main thing to watch is function timeouts. Return 200 immediately and push heavy work to a background queue or use Vercel waitUntil to extend execution. On the Hobby plan, functions time out after 10 seconds.
Can I have multiple webhook endpoints in Next.js for different Paystack events?
Paystack only sends events to one URL per account. You cannot register separate URLs for different event types. Instead, receive all events at a single Route Handler and route them internally using a switch statement or handler map based on the event.event field.

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