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

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:

  1. Create a Hooksnode project and webhook source.
  2. Turn on the Paystack signing preset on the source.
  3. Point Paystack at the source URL.
  4. Add one destination per service, with filters.
  5. Verify signatures 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 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-2xx answer, 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 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 Paystack signing preset

Hooksnode can check Paystack's signature before it accepts an event, so forged requests never reach your services.

  1. Open the source and turn on Signing.
  2. Choose the Paystack preset.
  3. Paste your Paystack secret key. Use the test secret key for a test-mode project and the live secret key for a live project.
  4. 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

  1. In the Paystack dashboard, open Settings, API Keys & Webhooks.
  2. Set the Live Webhook URL (or the test one, while you build) to your Hooksnode source URL.
  3. 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):

  1. The headers of the incoming Paystack 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 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:

  1. Use data.reference as the stable ID. For a service that handles several event types, combine it with the event name, for example charge.success:<reference>, so a refund and a charge on the same reference are not confused.
  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 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.reference in the same transaction as its work
  • Handlers answer 200 fast, including for duplicates

Further reading