Mantissa Hooksnode is a Mantissa product A Mantissa product mantissatech.xyz

Send one Flutterwave webhook to several services

Author:
hooksnode
Published:
Updated:
  • flutterwave
  • webhooks
  • fan-out

Flutterwave sends webhooks to one URL that you set in the dashboard (Settings, Webhooks). That works until a second service needs the same events. Your ledger wants charge.completed. Your wallet service wants it too. So does the service that emails receipts, and the CRM. Now one endpoint has to call all of them, and if one is slow or down, the rest suffer with it.

This tutorial puts Hooksnode between Flutterwave and your services. Flutterwave sends each event to one Hooksnode URL. Hooksnode checks the verif-hash header, saves the event and delivers a copy to every destination you add, each with its own queue and retries.

You will:

  1. Create a Hooksnode project and webhook source.
  2. Turn on the Flutterwave signing preset on the source.
  3. Point Flutterwave at the source URL.
  4. Add one destination per service, with filters.
  5. Verify requests in each service.
  6. Make each handler idempotent, because delivery is at least once.
  7. Optionally, test against localhost with the Hooksnode CLI.

The problem with a single webhook URL

A common first fix is a small "router" endpoint that receives the Flutterwave event and calls each internal service in turn. It has real problems:

  • Coupled failures. If the CRM call hangs, the router may not answer Flutterwave within its 60 second timeout. Flutterwave then marks the attempt failed and, if retries are on, sends the whole event again, so the ledger and wallet see it twice.
  • Retry logic you have to own. Flutterwave counts only 200 as success, and retries only if Enable Webhook retries is on: 3 times, 30 minutes apart. It does not retry each of your internal services separately. That part is on you.
  • No per-service view. When one service misses an event, there is no log of which service got what.

A fan-out layer fixes this: Hooksnode answers Flutterwave, then delivers to your services on its own schedule.

How the setup looks

Flutterwave ->  https://hooksnode.com/p/<source-id>  ->  ledger
                                                    ->  wallet
                                                    ->  crm
                                                    ->  notifications

For each destination, Hooksnode:

  • Keeps a separate queue, attempt log and retry schedule. A failing CRM does not slow your ledger.
  • Makes up to 25 delivery attempts in total. An attempt fails on any non-2xx answer, or no answer within 15 seconds. The wait between attempts grows and never goes over 1 hour, so all attempts span about 17.5 hours.
  • Marks a delivery dead after the last failed attempt. You can then retry it from the dashboard, or retry failed deliveries in a batch.
  • Saves each event to its database before it is queued.

Step 1: Create a project and a webhook source

  1. Sign up at hooksnode.com/register with email, Google or GitHub.
  2. On first sign-in, the dashboard asks you to create a workspace and a project.
  3. Each project starts with one webhook source. Its URL looks like this:
https://hooksnode.com/p/<source-id>

Copy it from the project page. The default source has the same ID as the project.

Step 2: Turn on the Flutterwave signing preset

Hooksnode can check Flutterwave's verif-hash before it accepts an event, so forged requests never reach your services.

  1. Open the source and turn on Signing.
  2. Choose the Flutterwave preset.
  3. Paste the secret hash you set in the Flutterwave dashboard (Settings, Webhooks). Use the test hash for a test-mode project and the live hash for a live project.
  4. Save.

Hooksnode now checks the verif-hash header on every request. Flutterwave does not sign the body: it sends your secret hash back as is, so the check is a plain comparison, not an HMAC. A request with a missing or wrong hash is rejected with 401 and is not delivered to any destination.

Step 3: Paste the URL into Flutterwave

  1. In the Flutterwave dashboard, open Settings, Webhooks.
  2. Set the webhook URL to your Hooksnode source URL, and enter the same secret hash you pasted in Step 2.
  3. Turn on Enable Webhook retries, so Flutterwave tries again if Hooksnode cannot be reached.
  4. Save.

Hooksnode keeps the request body byte for byte and keeps the request headers, including verif-hash. It forwards both to your destinations, so each service can still check the hash itself if you want it to.

Tip: use separate Hooksnode projects for Flutterwave test mode and live mode, so test events never reach production services.

Step 4: Add a destination for each service

Add a destination before you send events. On the Free plan, a project with no destination refuses events with 400 ({"error":"no destinations configured"}).

For each service, open Destinations, Add destination and fill in:

Setting What to enter
Name A label, for example Ledger
URL The service's public HTTPS endpoint, for example https://ledger.example.com/webhooks/flutterwave
Filter Optional. Only send matching events (below)
Transform Optional. Reshape the body with a Go text/template
Extra headers Optional. For example an Authorization header your service expects
Signing Off by default. Turn it on to add X-Hooksnode-Signature (recommended, see Step 5)
Active Turn off to pause deliveries without deleting the destination

Destinations must be public endpoints. Hooksnode refuses localhost, private and internal ranges and cloud metadata addresses. Use the CLI (Step 7) for local testing.

Filters: send each service only what it needs

A filter is a JSON object of path and value pairs. All keys must match. Some useful Flutterwave filters:

Ledger, successful charges only:

{ "event": "charge.completed", "data.status": "successful" }

Wallet service, successful charges in naira:

{ "event": "charge.completed", "data.status": "successful", "data.currency": "NGN" }

Payouts service, transfer outcomes:

{ "event": "transfer.completed" }

Filters support equality, nested paths, and "any of" lists. There is no "greater than" or "contains". Filters run after the transform, so write them against the transformed body. The destination editor has a Test panel where you can paste a sample Flutterwave body and see whether the filter matches, without sending anything.

A filtered-out event shows as delivered for that destination, with status code 0 and the response {"filtered":true}.

Headers your services receive

Hooksnode builds the outgoing headers in this order (later steps win):

  1. The headers of the incoming Flutterwave request, except Cookie, Authorization, X-Forwarded-*, Forwarded, X-Real-IP, Proxy-* and inbound X-Hooksnode-*.
  2. The destination's extra headers.
  3. X-Hooksnode-Source-Id: <source-id>.
  4. X-Hooksnode-Signature: sha256=<hex>, if signing is on.

Step 5: Verify signatures at each destination

Hooksnode already checked the verif-hash on the way in (Step 2). Each service behind Hooksnode is still a public endpoint, though, so it should check that a request really came from Hooksnode.

Recommended: verify Hooksnode's signature

Turn on Signing on each destination. Hooksnode generates a 64-character secret per destination and adds:

X-Hooksnode-Signature: sha256=<hex>

The value is the HMAC SHA256 of the final body (after any transform), keyed with that destination's secret. Press Regenerate to rotate it; the old secret stops working at once.

Node.js (Express):

import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/webhooks/hooksnode", express.raw({ type: "*/*" }), (req, res) => {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", process.env.HOOKSNODE_SECRET).update(req.body).digest("hex");
  const given = req.get("X-Hooksnode-Signature") ?? "";
  const ok =
    given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  // Hand off to idempotent processing (Step 6), then answer fast.
  res.sendStatus(200);
});

Python (Flask):

import hashlib, hmac, os
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["HOOKSNODE_SECRET"].encode()

@app.post("/webhooks/hooksnode")
def hooksnode():
    body = request.get_data()
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    given = request.headers.get("X-Hooksnode-Signature", "")
    if not hmac.compare_digest(given, expected):
        abort(401)
    event = request.get_json()
    # Hand off to idempotent processing (Step 6).
    return "", 200

Always hash the raw body. Parsing and re-serialising the JSON changes the bytes and breaks the check. This signature works even when a destination uses a transform, and each service only needs its own Hooksnode secret, not your Flutterwave secret hash.

The Hooksnode signature carries no timestamp, so it does not stop replays on its own. Idempotent handling (Step 6) covers that.

Optional: also check verif-hash

Hooksnode forwards the verif-hash header unchanged, so a service can also compare it with your Flutterwave secret hash. Compare in constant time:

const expected = Buffer.from(process.env.FLW_SECRET_HASH);
const given = Buffer.from(req.get("verif-hash") ?? "");
const ok = given.length === expected.length && crypto.timingSafeEqual(given, expected);

Because the hash does not depend on the body, this check still works on destinations with a transform. It is weaker than the Hooksnode signature, though: it is the same fixed value on every request, so anyone who sees one request can reuse it, and every service that checks it has to hold your secret hash. Use it in addition to X-Hooksnode-Signature, not instead of it.

Do not allowlist IPs

Behind Hooksnode, your services receive requests from Hooksnode, not from Flutterwave, so an allowlist of Flutterwave addresses will block deliveries. Hooksnode has no fixed outbound IPs. Verify the X-Hooksnode-Signature header on each delivery instead of allowlisting IPs.

Step 6: Make every handler idempotent

Hooksnode delivers at least once, never exactly once. A service can receive the same event twice. For example, your endpoint did the work but timed out before it answered, so Hooksnode retried.

What Hooksnode deduplicates on the way in

Hooksnode drops exact resends of a signed request within 10 minutes. Flutterwave can still deliver the same event more than once, so make each service idempotent on data.tx_ref.

Senders you control can also set an X-Idempotency-Key header. When Hooksnode has already accepted an event with that key in the project, it stores nothing and answers 200 with:

{"status":"duplicate","message":"already accepted"}

If you send events to Hooksnode from your own code, treat a response where status == "duplicate" as success. Match on the status field, not on the message text.

Duplicates on the way out

Each service must be safe to run twice for the same event:

  1. Use data.tx_ref as the stable ID. For a service that handles several event types, combine it with the event name and status, for example charge.completed:<tx_ref>:successful. Flutterwave's own suggestion is event plus data.id plus data.status; either works if you use it everywhere.
  2. Before doing the work, check whether you already handled that ID.
  3. Record the ID in the same database transaction as the work.

PostgreSQL example:

CREATE TABLE processed_events (
  event_id   text PRIMARY KEY,
  handled_at timestamptz NOT NULL DEFAULT now()
);
async function handleChargeCompleted(db, event) {
  const eventId = `${event.event}:${event.data.tx_ref}:${event.data.status}`;

  await db.tx(async (t) => {
    const row = await t.oneOrNone(
      `INSERT INTO processed_events (event_id) VALUES ($1)
       ON CONFLICT (event_id) DO NOTHING
       RETURNING event_id`,
      [eventId]
    );
    if (!row) return; // Already handled. Still answer 200.

    await t.none(
      `INSERT INTO ledger_entries (tx_ref, amount, currency)
       VALUES ($1, $2, $3)`,
      [event.data.tx_ref, event.data.amount, event.data.currency]
    );
  });
}

When a duplicate arrives, answer 200. Answering with an error makes Hooksnode retry an event you already handled.

Also answer fast. Hooksnode waits 15 seconds per attempt. Do slow work (emails, third-party API calls) in a job queue after you have recorded the event.

Tip: For money movements, consider verifying the transaction with Flutterwave's transaction verify endpoint before crediting a wallet, in addition to the signature checks.

Step 7 (optional): Test against localhost

Destinations must be public, but the Hooksnode CLI can stream a project's live webhooks to your laptop with no tunnel.

Install (macOS with Homebrew):

brew install mantissatech/tap/hooksnode

Or on macOS and Linux:

curl -fsSL https://github.com/mantissatech/hooksnode-cli/releases/latest/download/install.sh | sh

Create an API key in the dashboard (workspace, API keys, Create key), then:

hooksnode login
hooksnode listen 3000 --project <project-id> --path /webhooks/flutterwave

Each event the project receives is copied to http://localhost:3000/webhooks/flutterwave with the original method, headers and body, so verif-hash still matches on your machine. The copy does not affect real deliveries. listen only sends events that arrive while it is connected; use replay in the dashboard for older ones.

Use a separate dev project pointed at Flutterwave test mode, so your tests never reach production destinations.

Step 8: Watch deliveries

Open Events in the dashboard. Each event shows its headers, body and every delivery attempt per destination. From there you can retry a failed delivery, or batch retry failed deliveries once a broken service is fixed. Hooksnode also emails your team when a destination starts failing.

Every 15 minutes, Hooksnode sends a HEAD request to each active destination. A 5xx or no answer marks it unhealthy; a 4xx marks it degraded.

Plans and limits

Hooksnode charges 1 credit per inbound event, so one Flutterwave event fanned out to five destinations uses one credit.

Project plan Price Credits per month Sources Destinations
Free $0 100,000 (hard cap) 1 5
Pro $10/month 1,000,000 (hard cap) 10 50

When credits run out, new events get HTTP 402. Flutterwave retries them only if retries are on, and only 3 times, so watch your usage. A Free project covers this whole tutorial: one Flutterwave source and up to five services.

Checklist

  • Source Signing on, Flutterwave preset chosen, secret hash pasted
  • Enable Webhook retries turned on in Flutterwave
  • Flutterwave webhook URL set to https://hooksnode.com/p/<source-id>
  • At least one destination added before going live
  • Filters tested in the destination Test panel
  • Signing on for each destination, and each service verifies X-Hooksnode-Signature
  • No IP allowlists on services behind Hooksnode
  • Each handler records data.tx_ref in the same transaction as its work
  • Handlers answer 200 fast, including for duplicates

Further reading