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:
- Create a Hooksnode project and webhook source.
- Turn on the Flutterwave signing preset on the source.
- Point Flutterwave at the source URL.
- Add one destination per service, with filters.
- Verify requests in each service.
- Make each handler idempotent, because delivery is at least once.
- 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
200as 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-
2xxanswer, 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
deadafter 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
- Sign up at hooksnode.com/register with email, Google or GitHub.
- On first sign-in, the dashboard asks you to create a workspace and a project.
- 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.
- Open the source and turn on Signing.
- Choose the Flutterwave preset.
- 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.
- 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
- In the Flutterwave dashboard, open Settings, Webhooks.
- Set the webhook URL to your Hooksnode source URL, and enter the same secret hash you pasted in Step 2.
- Turn on Enable Webhook retries, so Flutterwave tries again if Hooksnode cannot be reached.
- 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):
- The headers of the incoming Flutterwave request, except
Cookie,Authorization,X-Forwarded-*,Forwarded,X-Real-IP,Proxy-*and inboundX-Hooksnode-*. - The destination's extra headers.
X-Hooksnode-Source-Id: <source-id>.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:
- Use
data.tx_refas the stable ID. For a service that handles several event types, combine it with the event name and status, for examplecharge.completed:<tx_ref>:successful. Flutterwave's own suggestion iseventplusdata.idplusdata.status; either works if you use it everywhere. - Before doing the work, check whether you already handled that ID.
- 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_refin the same transaction as its work - Handlers answer
200fast, including for duplicates