Security 12 min

Building Secure Webhook Handlers for Flutterwave in TypeScript

A properly secured Flutterwave webhook handler in TypeScript — signature verification, replay protection, and idempotent fulfillment — explained for developers and for non-technical builders relying on AI-generated code.

Written By

Build Studio

4 June 2026

Building a Nigerian App?

Discover all the APIs you need in our curated directory.

Browse APIs

Need Development Support?

Reach out for end-to-end app development, API integration, or hire our expert developers to join your team.

Hire Our Team

Who this is for

If you're a developer, this is a webhook handler you can put in front of real traffic: signature verification, a defense against replayed requests, and fulfillment logic that's safe to run twice. If you're building with AI assistance and reviewing code you didn't fully write, the "Why this matters" notes explain which parts are load-bearing security, not style choices an AI could safely simplify away.

What a webhook is, in plain terms

When a customer pays through Flutterwave, two things happen. First, their browser gets redirected back to your app with a "payment complete" message — this is fast, but it's just the customer's browser talking to yours, and browsers can be manipulated, closed early, or lie. Second, Flutterwave's servers send a separate HTTP request directly to your server, called a webhook, confirming what actually happened on their end.

The webhook is the one you can trust. It comes from Flutterwave's infrastructure, not the customer's browser, which is why every payment-status decision — did they pay, how much, in what currency — should be based on the webhook, never on the redirect alone.

The problem: your webhook URL is public

Your webhook endpoint (something like https://yourapp.com/api/webhooks/flutterwave) has to be reachable from the internet for Flutterwave to call it. That also means anyone can call it. Without verification, a stranger could send a fake "payment successful" request and get free product, credits, or access.

Flutterwave solves this with a secret hash: a value only you and Flutterwave know, which you configure once in your dashboard (Settings → Webhooks) and check on every incoming request.

Step 1: Set your webhook secret

In your Flutterwave dashboard, set a webhook secret hash — a random string only you control. Store it in your environment, never in code:

FLW_WEBHOOK_SECRET_HASH=your-long-random-string-here
FLW_SECRET_KEY=FLWSECK_TEST-xxxxxxxxxxxxxxxxxxxxx

FLW_WEBHOOK_SECRET_HASH is the value you put in the Flutterwave dashboard and check on incoming requests. FLW_SECRET_KEY is your API secret key, used separately in Step 3 to re-verify a transaction server-to-server.

Step 2: Verify the signature — correctly

Here's the handler, written as a Next.js App Router route at src/app/api/webhooks/flutterwave/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(req: NextRequest) {
  const signature = req.headers.get('verif-hash');
  const expected = process.env.FLW_WEBHOOK_SECRET_HASH!;

  // ── Constant-time comparison, not `===` ──
  // See "Why this matters" below.
  if (!signature || !timingSafeEqual(signature, expected)) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const payload = await req.json();

  if (payload.event === 'charge.completed' && payload.data.status === 'successful') {
    await handleSuccessfulCharge(payload.data);
  }

  // Flutterwave expects a fast 2xx. Do slow work (emails, notifications)
  // after this, or hand it off to a background job.
  return NextResponse.json({ status: 'success' });
}

function timingSafeEqual(a: string, b: string): boolean {
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  if (bufA.length !== bufB.length) return false;
  return crypto.timingSafeEqual(bufA, bufB);
}

Why this matters: A plain signature !== expected string comparison is technically "correct" but leaks timing information — how long the comparison takes can reveal, character by character, what the correct hash looks like, given enough repeated attempts. This is a real, documented attack class (a timing attack), not a theoretical one. It's also exactly the kind of shortcut an AI assistant will happily generate, because === works in every normal test you'd run — the vulnerability only shows up under adversarial conditions your tests don't cover. crypto.timingSafeEqual costs nothing extra and closes the hole.

Step 3: Re-verify with Flutterwave before you trust the amount

The signature proves the request came from Flutterwave. It does not, by itself, prove the transaction data hasn't been tampered with in transit, or that you're looking at the amount you expect. Best practice is a second, server-to-server check against Flutterwave's Verify Transaction API before you fulfill anything:

async function handleSuccessfulCharge(data: { id: number; tx_ref: string; amount: number; currency: string }) {
  const verifyRes = await fetch(
    `https://api.flutterwave.com/v3/transactions/${data.id}/verify`,
    { headers: { Authorization: `Bearer ${process.env.FLW_SECRET_KEY}` } }
  );
  const verified = await verifyRes.json();

  const tx = verified.data;
  if (
    tx.status !== 'successful' ||
    tx.currency !== 'NGN' ||
    tx.amount < getExpectedAmount(data.tx_ref) // your own order lookup
  ) {
    console.error(`Verification mismatch for ${data.tx_ref}`, tx);
    return; // Do NOT fulfill — something doesn't match what you expect.
  }

  await fulfillOrder(data.tx_ref);
}

function getExpectedAmount(txRef: string): number {
  // Look up the order you created before redirecting the customer to
  // checkout, and return the amount YOU expected — never trust a client-
  // supplied amount as the source of truth.
  throw new Error('implement me');
}

async function fulfillOrder(txRef: string) {
  // Mark the order paid. Must be safe to call twice — see Step 4.
}

Why this matters: Checking tx.amount against what you expected — not what the webhook claims — closes a specific hole: a compromised or misconfigured client could initiate a ₦100 charge but tag it with the tx_ref of a ₦100,000 order. The webhook alone won't catch that; comparing against your own stored order record will.

Step 4: Make fulfillment idempotent

Flutterwave — like every payment provider — retries webhooks that time out or return a non-2xx response. That means your handler will run more than once for the same transaction in normal operation, not just as an edge case.

async function fulfillOrder(txRef: string) {
  const order = await db.order.findUnique({ where: { txRef } });

  if (!order) {
    console.error(`No order found for tx_ref ${txRef}`);
    return;
  }

  if (order.status === 'paid') {
    return; // Already processed — this is a retry, not a new payment.
  }

  await db.order.update({
    where: { txRef },
    data: { status: 'paid', paidAt: new Date() },
  });

  // Now safe to send confirmation email, decrement inventory, etc.
}

Why this matters: Without the order.status === 'paid' guard, a retried webhook re-runs your entire fulfillment path — sending a second confirmation email, decrementing inventory twice, or granting access twice. This is the bug that turns "webhooks are unreliable" into a support ticket, when the webhook actually did its job correctly by retrying.

Testing before you go live

Flutterwave's dashboard has a "Test Webhook" button that sends a sample payload to your configured URL — use it to confirm your signature check passes on a legitimate request and rejects a request with a missing or wrong verif-hash header. If you're developing locally, tools like ngrok or cloudflared tunnel give you a public HTTPS URL that forwards to your local server, so you can test the real flow instead of guessing.

Deliberately break the signature (change one character in your env value and send a real webhook) to confirm your handler returns 401 — a webhook handler that's never seen a rejected request hasn't actually been tested.

Common mistakes

  • Comparing signatures with === instead of a timing-safe comparison.
  • Trusting the webhook payload's amount without checking it against your own order record.
  • No idempotency guard, so retries double-fulfill orders.
  • Returning a non-2xx or timing out on slow work (like sending emails synchronously), which triggers unnecessary retries and can make a working integration look broken.
  • Testing only the happy path — a handler that's only ever received valid, successful-charge webhooks hasn't been tested against the failure modes it needs to handle in production.

Before you go live

  • Signature check uses crypto.timingSafeEqual, not ===
  • Amount and currency are checked against your own stored order, not just the webhook payload
  • Fulfillment is idempotent — safe to run twice for the same tx_ref
  • You've tested both a valid and a deliberately invalid signature
  • Slow work (email, notifications) happens after responding, not before

Key takeaway

The redirect the customer sees is UX. The webhook — verified, cross-checked against your own records, and safe to retry — is the actual payment confirmation. Build for the case where it fires twice, because it will.

Share this guide:
More Resources

Ready to start building?

Explore the most comprehensive directory of APIs for Nigerian developers and find exactly what you need.

Browse the API Directory