Skip to content

Flutterwave · Next.js

Flutterwave Webhooks in Next.js App Router: verif-hash and Verify

Receive Flutterwave v3 webhooks in a Next.js App Router route handler: check verif-hash, verify the transaction by id, and settle each payment once. Tested.

Published

Skip the boilerplate → Paste a webhook into the Flutterwave Webhook Checker to check its verif-hash and get the verify request.

This guide receives Flutterwave v3 webhooks in a Next.js App Router app and settles payments from them safely. You’ll build:

  • lib/flutterwave.ts: the verif-hash check and the verify call,
  • lib/payments.ts: settle a payment from Flutterwave’s answer, once,
  • app/api/webhooks/flutterwave/route.ts: the route handler,
  • tests that post real-looking and forged webhooks to the handler without calling Flutterwave.

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. Flutterwave facts come from its public v3 documentation, checked in November 2026. The full charge flow, for mobile money in Uganda, is in the Flutterwave mobile money guide.

How Flutterwave webhooks differ from Paystack’s

Flutterwave v3 doesn’t sign the body. You choose a secret hash in the dashboard, and Flutterwave sends it back in the verif-hash header. So:

  • Reading the body with request.json() is fine: nothing is computed over the raw bytes. (For Paystack it isn’t; see Paystack webhooks in Next.js.)
  • The hash proves the caller knows your secret, not that the body is genuine. Take only data.id from it, and settle from GET /v3/transactions/{id}/verify.
  • Flutterwave’s own checks for a verified transaction: the tx_ref is yours, status is successful, the currency matches, and the amount is at least what you expected.

Setup

In the Flutterwave dashboard, set the webhook URL (for example https://your-app.com/api/webhooks/flutterwave) and a long random secret hash, and turn on webhook retries. Put both secrets in .env.local:

FLW_SECRET_KEY=your-test-secret-key
FLW_SECRET_HASH=the-same-32-plus-character-secret-hash-as-your-dashboard

If auth middleware protects your app, exclude /api/webhooks from its matcher: a redirect to a login page means the handler never runs.

The Flutterwave helpers

// lib/flutterwave.ts
import { timingSafeEqual } from 'node:crypto';

export class FlutterwaveError extends Error {}

/** v3 webhooks: verif-hash must equal the secret hash you set in the dashboard. */
export function hashOk(given: string | null, secretHash = process.env.FLW_SECRET_HASH ?? ''): boolean {
  if (secretHash.length < 32 || !given) return false; // a short or missing secret is easy to guess
  const a = Buffer.from(given);
  const b = Buffer.from(secretHash);
  return a.length === b.length && timingSafeEqual(a, b);
}

export interface Transaction {
  id: number;
  tx_ref: string;
  status: string; // successful, failed, pending...
  amount: number;
  currency: string;
  processor_response?: string;
}

/** The source of truth, by Flutterwave's transaction id, or null if Flutterwave has no such transaction. */
export async function verifyTransaction(id: number | string): Promise<Transaction | null> {
  let res: Response;
  try {
    res = await fetch(`https://api.flutterwave.com/v3/transactions/${encodeURIComponent(String(id))}/verify`, {
      headers: { Authorization: `Bearer ${process.env.FLW_SECRET_KEY}` },
      cache: 'no-store',
      signal: AbortSignal.timeout(30_000),
    });
  } catch (err) {
    throw new FlutterwaveError(`Flutterwave unreachable: ${(err as Error).message}`);
  }
  const body = await res.json().catch(() => null);
  if (res.ok && body?.status === 'success' && body.data) return body.data as Transaction;
  if (res.status === 400 || res.status === 404 || (body && body.data === null)) return null; // "No transaction was found for this id"
  throw new FlutterwaveError(`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 tx_ref = $1 AND status = 'pending'. Then a repeated webhook can never fulfil an order twice.

// lib/payments.ts
import type { Transaction } from './flutterwave';

export interface Payment {
  txRef: string; // yours, sent as tx_ref when you started the charge
  amount: number; // whole shillings
  currency: 'UGX';
  status: 'pending' | 'paid' | 'failed' | 'review';
  flwId?: number;
  note?: string;
}

// ── Replace with your database ──────────────────────────────────────────────
const rows = new Map<string, Payment>();

export async function insertPayment(p: Payment): Promise<void> {
  rows.set(p.txRef, { ...p });
}

export async function findPayment(txRef: string): Promise<Payment | null> {
  const p = rows.get(txRef);
  return p ? { ...p } : null;
}

/** Changes a payment only while it is still pending. True if this call changed it. */
async function update(txRef: string, fields: Partial<Payment>): Promise<boolean> {
  const p = rows.get(txRef);
  if (!p || p.status !== 'pending') return false;
  rows.set(txRef, { ...p, ...fields });
  return true;
}
// ────────────────────────────────────────────────────────────────────────────

/** Applies Flutterwave'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.tx_ref !== payment.txRef || payment.status !== 'pending') return null;
  const status = String(tx.status).toLowerCase();
  if (status === 'successful') {
    const enough = tx.currency === payment.currency && Number(tx.amount) >= payment.amount;
    const to = enough ? 'paid' : 'review'; // short, or another currency: a person must look
    return (await update(payment.txRef, { status: to, flwId: tx.id, note: enough ? undefined : `PAID ${tx.amount} ${tx.currency}` })) ? to : null;
  }
  if (status === 'failed') {
    return (await update(payment.txRef, { status: 'failed', flwId: tx.id, note: tx.processor_response ?? 'failed' })) ? 'failed' : null;
  }
  return null; // still pending: a later webhook or your reconcile job settles it
}

Fulfil the order 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/flutterwave/route.ts
import { FlutterwaveError, hashOk, verifyTransaction } from '@/lib/flutterwave';
import { findPayment, settle } from '@/lib/payments';

export async function POST(request: Request): Promise<Response> {
  if (!hashOk(request.headers.get('verif-hash'))) {
    return new Response('Invalid hash', { status: 401 });
  }

  let event: { event?: string; data?: { id?: unknown } };
  try {
    event = await request.json();
  } catch {
    return new Response('ok'); // not JSON: nothing to do
  }

  const id = event?.data?.id;
  if (event?.event !== 'charge.completed' || (typeof id !== 'number' && typeof id !== 'string') || id === '') {
    return new Response('ok'); // transfer.completed and other events: handle them in their own code
  }

  try {
    const tx = await verifyTransaction(id); // only data.id comes from the body
    if (!tx) return new Response('ok');
    const payment = await findPayment(tx.tx_ref); // found by the VERIFIED tx_ref
    const changed = payment ? 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('Flutterwave payment confirmed', tx.tx_ref);
    }
  } catch (err) {
    if (err instanceof FlutterwaveError) return new Response('Try again', { status: 500 }); // retried if retries are on
    throw err;
  }
  return new Response('ok');
}

The handler answers 200 for anything it ignores and 500 only when it couldn’t ask Flutterwave, so a retry is useful. With retries turned on, Flutterwave retries a failed delivery 3 times, 30 minutes apart, so also run a scheduled job that verifies payments still pending by your tx_ref; the mobile money guide has one.

transfer.completed events for your payouts arrive at the same URL. Handle them after the same hash check; the Flutterwave payouts guide shows how.

Test it without calling Flutterwave

The test calls the exported POST directly with a standard Request and replaces fetch with a fake Flutterwave. Install a TypeScript test runner that understands the @/ path alias:

npm install --save-dev tsx
npx tsx --test tests/flutterwave-webhook.test.ts
// tests/flutterwave-webhook.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { POST } from '@/app/api/webhooks/flutterwave/route';
import { findPayment, insertPayment, settle, type Payment } from '@/lib/payments';

const HASH = 'test-secret-hash-of-at-least-32-characters';
process.env.FLW_SECRET_HASH = HASH;
process.env.FLW_SECRET_KEY = 'FLWSECK_TEST-x';

let flw: Record<string, { tx_ref: 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(null, { status: 503 });
  const id = String(url).split('/').at(-2)!;
  const tx = flw[id];
  if (!tx) return Response.json({ status: 'error', message: 'No transaction was found for this id', data: null }, { status: 400 });
  return Response.json({ status: 'success', message: 'Transaction fetched successfully', data: { id: Number(id), ...tx } });
}) as typeof fetch;

function webhook(body: unknown, hash: string | null = HASH, raw = JSON.stringify(body)) {
  const headers: Record<string, string> = { 'content-type': 'application/json' };
  if (hash !== null) headers['verif-hash'] = hash;
  return POST(new Request('http://localhost/api/webhooks/flutterwave', { method: 'POST', body: raw, headers }));
}

let n = 0;
async function payment(amount = 15000): Promise<Payment> {
  const p: Payment = { txRef: `order-${++n}-${Date.now()}`, amount, currency: 'UGX', status: 'pending' };
  await insertPayment(p);
  return p;
}
const charge = (id: number, extra: object = {}) => ({ event: 'charge.completed', data: { id, ...extra } });

beforeEach(() => {
  flw = {};
  verifyCalls = 0;
  down = false;
});

test('a charge.completed is paid only after verify says successful', async () => {
  const p = await payment();
  flw['4975363'] = { tx_ref: p.txRef, status: 'successful', amount: 15000, currency: 'UGX' };
  assert.equal((await webhook(charge(4975363))).status, 200);
  assert.deepEqual([(await findPayment(p.txRef))?.status, (await findPayment(p.txRef))?.flwId], ['paid', 4975363]);
});

test('a wrong, missing or weak hash changes nothing', async () => {
  const p = await payment();
  flw['1'] = { tx_ref: p.txRef, status: 'successful', amount: 15000, currency: 'UGX' };
  assert.equal((await webhook(charge(1), 'wrong-hash-wrong-hash-wrong-hash-xx')).status, 401);
  assert.equal((await webhook(charge(1), null)).status, 401);
  process.env.FLW_SECRET_HASH = 'short';
  assert.equal((await webhook(charge(1), 'short')).status, 401);
  process.env.FLW_SECRET_HASH = HASH;
  assert.equal(verifyCalls, 0);
  assert.equal((await findPayment(p.txRef))?.status, 'pending');
});

test('the body is never trusted: only data.id is used', async () => {
  const p = await payment();
  flw['2'] = { tx_ref: p.txRef, status: 'pending', amount: 15000, currency: 'UGX' };
  await webhook(charge(2, { tx_ref: p.txRef, status: 'successful', amount: 15000 }));
  assert.equal((await findPayment(p.txRef))?.status, 'pending');
  // A body naming our tx_ref but another transaction's id settles only that transaction's own payment.
  const other = await payment();
  flw['3'] = { tx_ref: other.txRef, status: 'successful', amount: 15000, currency: 'UGX' };
  await webhook(charge(3, { tx_ref: p.txRef }));
  assert.deepEqual([(await findPayment(p.txRef))?.status, (await findPayment(other.txRef))?.status], ['pending', 'paid']);
  // settle() itself refuses another payment's transaction, for callers like a reconcile job.
  assert.equal(await settle(p, { id: 3, ...flw['3'] }), null);
  flw['2'].status = 'failed';
  await webhook(charge(2));
  assert.equal((await findPayment(p.txRef))?.status, 'failed');
});

test('short or another currency goes to review; more is fine; final means final', async () => {
  const short = await payment();
  flw['10'] = { tx_ref: short.txRef, status: 'successful', amount: 1500, currency: 'UGX' };
  await webhook(charge(10));
  assert.deepEqual([(await findPayment(short.txRef))?.status, (await findPayment(short.txRef))?.note], ['review', 'PAID 1500 UGX']);
  const kes = await payment();
  flw['11'] = { tx_ref: kes.txRef, status: 'successful', amount: 15000, currency: 'KES' };
  await webhook(charge(11));
  assert.equal((await findPayment(kes.txRef))?.status, 'review');
  const more = await payment();
  flw['12'] = { tx_ref: more.txRef, status: 'successful', amount: 15500, currency: 'UGX' };
  await webhook(charge(12));
  assert.equal((await findPayment(more.txRef))?.status, 'paid');
  flw['12'].status = 'failed'; // a later, different answer doesn't undo it
  await webhook(charge(12));
  assert.equal((await findPayment(more.txRef))?.status, 'paid');
});

test('a repeated webhook fulfils once', async () => {
  const p = await payment();
  flw['20'] = { tx_ref: p.txRef, status: 'successful', amount: 15000, currency: 'UGX' };
  const logged: unknown[] = [];
  const info = console.info;
  console.info = (...args: unknown[]) => void logged.push(args);
  try {
    await Promise.all([1, 2, 3].map(() => webhook(charge(20))));
  } finally {
    console.info = info;
  }
  assert.equal(logged.length, 1);
});

test('ignored events answer 200; an unreachable Flutterwave answers 500 so it retries', async () => {
  const p = await payment();
  flw['30'] = { tx_ref: p.txRef, status: 'successful', amount: 15000, currency: 'UGX' };
  assert.equal((await webhook({ event: 'transfer.completed', data: { id: 30 } })).status, 200);
  assert.equal((await webhook(charge(99999))).status, 200); // Flutterwave has no such transaction
  assert.equal((await webhook(null, HASH, 'not json')).status, 200);
  assert.equal((await webhook({ event: 'charge.completed', data: {} })).status, 200);
  assert.equal((await findPayment(p.txRef))?.status, 'pending');
  down = true;
  assert.equal((await webhook(charge(30))).status, 500);
  assert.equal((await findPayment(p.txRef))?.status, 'pending');
});

Common errors and fixes

Every Flutterwave status and error is explained in the Flutterwave error reference.

Symptom Likely cause Fix
Every webhook gets 401 FLW_SECRET_HASH differs from the dashboard, or is under 32 characters Use exactly the same long value in both places
401 only in production The hash isn’t set in the deployed environment Set it in your host’s environment variables and redeploy
Flutterwave shows 307 or 308 Auth middleware or a trailing-slash redirect Exclude /api/webhooks from the middleware matcher; use the exact URL
Verify says “No transaction was found for this id” Test and live keys mixed, or the id isn’t a transaction id Use the secret key of the same mode; transfers have their own lookup
Orders fulfilled twice Fulfilment ran on every webhook Fulfil only when settle() returns 'paid'
Some payments stay pending A webhook was lost or retries are off Verify pending payments by tx_ref from a scheduled job

Going to production

  • Switch to your live secret key, and set a new live secret hash in both the dashboard and your production environment.
  • Point Flutterwave at the production URL, and send one real small payment end to end.
  • Replace the Map in lib/payments.ts with your database, keeping the conditional update.
  • Log every webhook’s tx_ref and outcome, so you can answer “did they pay?” from your own records.

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

How do I verify a Flutterwave webhook in Next.js?

In the v3 API you set a secret hash in your dashboard, and Flutterwave sends that same value in the verif-hash header. Compare the two with a constant-time comparison in your route handler, then call GET /v3/transactions/{id}/verify with data.id from the body, and settle from that answer.

Is verif-hash a signature of the request body?

Not in v3. It is the secret hash itself, so it proves the request knows your secret, but says nothing about the body. That is why the body is only used for data.id, and everything that decides the outcome comes from the verify call.

Why does my Flutterwave webhook work locally but get 401 in production?

FLW_SECRET_HASH is usually missing or different in the deployed environment. Set it in your host's environment variables to exactly the value in the Flutterwave dashboard, and redeploy.

Which checks does Flutterwave say to do on a verified transaction?

That the tx_ref is yours, the status is successful, the currency matches, and the amount is at least what you expected. Anything else must not be given value.

Need this built for you?

I integrate MTN MoMo, M-Pesa and Airtel Money into Laravel / PHP, Python / Django, Node.js / Next.js apps: payments, callbacks, payouts and go-live, with tests.