Send one Paystack webhook to several services
- Author:
- hooksnode
- Published:
- Updated:
- paystack
- webhooks
- fan-out
Paystack lets you set one webhook URL per environment in the dashboard (Settings, API Keys & Webhooks). That works until a second service needs the same events. Your ledger wants charge.success. 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 Paystack and your services. Paystack sends each event to one Hooksnode URL. Hooksnode checks Paystack's signature, 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 Paystack signing preset on the source.
- Point Paystack at the source URL.
- Add one destination per service, with filters.
- Verify signatures 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 Paystack event and calls each internal service in turn. It has real problems:
- Coupled failures. If the CRM call hangs, the router may not answer Paystack in time. Paystack then marks the attempt failed and retries the whole event, so the ledger and wallet see it again.
- Retry logic you have to own. Paystack retries your URL until it gets a
200 OK(in live mode, every 3 minutes for the first 4 tries, then hourly for up to 72 hours). 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.
Paystack's own docs also advise you to answer 200 OK quickly and do long-running work afterwards. A fan-out layer makes that easy: Hooksnode answers Paystack, then delivers to your services on its own schedule.
How the setup looks
Paystack -> 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.
- Retries a failed delivery (any non-
2xxanswer, or no answer within 15 seconds) up to 25 times. 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 Paystack signing preset
Hooksnode can check Paystack's signature before it accepts an event, so forged requests never reach your services.
- Open the source and turn on Signing.
- Choose the Paystack preset.
- Paste your Paystack secret key. Use the test secret key for a test-mode project and the live secret key for a live project.
- Save.
Hooksnode now verifies the X-Paystack-Signature header on every request: the HMAC SHA512 of the raw body, keyed with your Paystack secret key. A request with a missing or wrong signature is rejected with 401 and is not delivered to any destination.
Step 3: Paste the URL into Paystack
- In the Paystack dashboard, open Settings, API Keys & Webhooks.
- Set the Live Webhook URL (or the test one, while you build) to your Hooksnode source URL.
- Save.
Hooksnode keeps the request body byte for byte and keeps the request headers, including x-paystack-signature. It forwards both to your destinations, so each service can still check the Paystack signature itself if you want it to.
Tip: use separate Hooksnode projects for Paystack 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/paystack |
| 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 Paystack filters:
Ledger, successful charges only:
{ "event": "charge.success" }
Wallet service, successful charges in naira:
{ "event": "charge.success", "data.currency": "NGN" }
Payouts service, transfer outcomes:
{ "event": ["transfer.success", "transfer.failed", "transfer.reversed"] }
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 Paystack 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 Paystack 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 Paystack's signature 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 Paystack secret key.
The Hooksnode signature carries no timestamp, so it does not stop replays on its own. Idempotent handling (Step 6) covers that.
Optional: also verify Paystack's signature
Hooksnode forwards the body and x-paystack-signature unchanged, so a service without a transform can also verify the Paystack signature itself (HMAC SHA512 of the raw body, keyed with your Paystack secret key):
const expected = crypto
.createHmac("sha512", process.env.PAYSTACK_SECRET_KEY)
.update(req.body) // raw bytes
.digest("hex");
const given = req.get("x-paystack-signature") ?? "";
This check fails on destinations with a transform, because the transform changes the body. It also means the service holds your Paystack secret key.
Do not allowlist IPs
Paystack publishes the IP addresses it sends webhooks from, but behind Hooksnode your services receive requests from Hooksnode, so a Paystack IP allowlist 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. Paystack can still deliver the same event more than once, so make each service idempotent on data.reference.
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.referenceas the stable ID. For a service that handles several event types, combine it with the event name, for examplecharge.success:<reference>, so a refund and a charge on the same reference are not confused. - 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 handleChargeSuccess(db, event) {
const eventId = `${event.event}:${event.data.reference}`;
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 (reference, amount_kobo, currency)
VALUES ($1, $2, $3)`,
[event.data.reference, 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 Paystack's Verify Transaction API 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/paystack
Each event the project receives is copied to http://localhost:3000/webhooks/paystack with the original method, headers and body, so x-paystack-signature still verifies 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 Paystack 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 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 Paystack 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. Paystack will then retry them on its own schedule, so watch your usage. A Free project covers this whole tutorial: one Paystack source and up to five services.
Checklist
- Source Signing on, Paystack preset chosen, Paystack secret key pasted
- Paystack 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.referencein the same transaction as its work - Handlers answer
200fast, including for duplicates