If you've integrated more than one Nigerian or African payment provider, you've written the same webhook-verification
code several times, and probably got it subtly wrong at least once. I did. So I tested how each provider actually
signs its webhooks against their own documentation, and packaged the result as an open-source library.
This post covers:
- Why webhook verification matters (and fails)
- How Paystack, Flutterwave, Monnify, Squad, Korapay, Fincra, Interswitch and Nomba each sign webhooks
- The two bugs in provider documentation I found while testing
- A one-line way to do it correctly in Node.js, Next.js, NestJS, Express, Django, Flask or FastAPI
Why verification matters
Your webhook URL is public. Anyone who finds it can POST a fake charge.success and, if you don't verify the
signature, get goods for free. Every provider signs its webhooks so you can prove they're genuine. The catch: they all
do it differently.
The #1 bug: verifying a parsed body
Signatures are computed over the exact bytes the provider sent. Most frameworks parse JSON before your handler runs.
If you then do JSON.stringify(req.body) and hash that, it usually matches. Then one day a payload contains a forward
slash, a unicode character or a float formatted differently, and verification starts failing (or you disable it "for
now").
Keep the raw body:
| Framework | How |
|---|---|
| Express | express.raw({ type: 'application/json' }) on the webhook route |
| NestJS | NestFactory.create(AppModule, { rawBody: true }), then req.rawBody |
| Fastify | a buffer content-type parser in a scoped plugin |
| Next.js App Router / Hono / Workers | await request.arrayBuffer() |
| Next.js Pages Router | export const config = { api: { bodyParser: false } } |
| Django | request.body (already raw) |
| FastAPI | await request.body(); don't declare a Pydantic body on that route |
How each provider signs webhooks
| Provider | Header | Algorithm | What's signed |
|---|---|---|---|
| Paystack | x-paystack-signature | HMAC-SHA512 (hex), secret key | raw body |
| Flutterwave v3 | verif-hash | none; it's your dashboard "secret hash" | nothing (!) |
| Flutterwave v4 | flutterwave-signature | HMAC-SHA256 (base64), secret hash | raw body |
| Monnify | monnify-signature | HMAC-SHA512 (hex), client secret | raw body |
| Squad | x-squad-encrypted-body | HMAC-SHA512 (hex, upper-case), secret key | raw body |
| Korapay | x-korapay-signature | HMAC-SHA256 (hex), secret key | only the data object |
| Fincra | signature | HMAC-SHA512 (hex), webhook secret (not your API key) | payload |
| Interswitch | X-Interswitch-Signature | HMAC-SHA512 (hex), webhook secret | raw body |
| Nomba | nomba-signature + nomba-timestamp | HMAC-SHA256 (base64), signature key | selected fields + timestamp |
A few things worth knowing:
- Flutterwave v3's
verif-hashdoesn't protect the body. It's a shared secret sent as a header. Anyone who learns it can send any payload. Always re-verify the transaction with Flutterwave's API (GET /v3/transactions/{id}/verify) before giving value. - Paystack only sends webhooks from
52.31.139.75,52.49.173.169and52.214.14.220, and retries for up to 72 hours, so you will receive duplicates. Make your handler idempotent. - Korapay signs
JSON.stringify(body.data), not the whole body, so you must extractdataexactly. - Monnify sandbox webhooks are not signed at all. Code that "works in sandbox" may have never verified anything.
- Nomba is the only one here that signs a timestamp (so you can reject replays), but it doesn't sign the amount, so re-verify via the API.
- Amounts use different units: Paystack and Squad send kobo; Flutterwave, Monnify, Korapay, Fincra and Nomba send naira. Mixing them up is a 100× bug.
Two documentation bugs I found
1. Monnify's docs describe the wrong algorithm. The prose says the signature is "SHA-512(client secret + request
body)". But Monnify's own published example hash only reproduces as HMAC-SHA512 of the body keyed with the client
secret. If you implement the prose, no real webhook will ever verify.
2. Nomba's example signature has the wrong case. The example in the docs starts with a lower-case q; computing
it gives an upper-case Q. Nomba's sample code compares signatures case-insensitively, which is why their example
"works". If you compare base64 strictly, the documented example fails.
Both are now covered by automated tests in the library (the tests assert the published vectors reproduce, and that
Monnify's prose formula does not).
Doing it in one line
I packaged all of this as africa-payments-webhooks.
It's MIT-licensed, has zero dependencies, and works with JavaScript/TypeScript and Python.
npm install africa-payments-webhooks # or pip install africa-payments-webhooks Next.js App Router (also Hono, Cloudflare Workers, Deno, Bun):
// app/api/webhooks/paystack/route.ts import { fetchWebhook } from 'africa-payments-webhooks/web'; export const POST = fetchWebhook({ provider: 'paystack', secret: process.env.PAYSTACK_SECRET_KEY!, onEvent: async (event) => { if (event.type === 'payment.succeeded') { // event.reference, event.amount → { value: 500000, unit: 'minor', currency: 'NGN' } await markOrderPaid(event.reference); } }, }); Express:
import express from 'express'; import { expressWebhook } from 'africa-payments-webhooks/express'; app.post( '/webhooks/monnify', express.raw({ type: 'application/json' }), expressWebhook({ provider: 'monnify', secret: process.env.MONNIFY_CLIENT_SECRET!, onEvent }), ); Several providers on one endpoint. The provider is detected from the headers:
fetchWebhook({ provider: ['paystack', 'flutterwave', 'monnify'], secret: { paystack: PAYSTACK_KEY, flutterwave: FLW_SECRET_HASH, monnify: MONNIFY_SECRET }, onEvent, }); Django:
from africa_payments_webhooks.adapters.django import webhook_view def on_event(event): if event.type == "payment.succeeded": mark_order_paid(event.reference) urlpatterns = [path("webhooks/paystack/", webhook_view(provider="paystack", secret=settings.PAYSTACK_SECRET_KEY, on_event=on_event))] Every provider produces the same event shape (payment.succeeded, payment.failed, transfer.succeeded,
refund.processed, …) with the amount and its unit. The handler also:
- returns the right HTTP response for each provider (Interswitch wants a 200 with an empty body, for example);
- rejects already-parsed bodies with a clear
body_already_parsederror instead of failing silently; - deduplicates retries if you give it a store (Redis/Postgres recipes are in the docs);
- supports secret rotation (
secret: [newKey, oldKey]); - includes
signTestPayload()so you can unit-test your own handler with genuinely signed fake webhooks.
It runs on Node.js 20+, Bun, Deno, Cloudflare Workers, Vercel and AWS Lambda, with adapters for Express, Fastify,
NestJS, Koa, Next.js and Hono, plus Django, Flask and FastAPI on the Python side.
Still re-verify before giving value
A valid signature proves the webhook came from the provider. It doesn't prove the customer paid what you expected.
Before fulfilling an order, call the provider's verify endpoint and check the amount, currency, status and reference.
Webhooks tell you something happened; the API tells you exactly what.
Links
- GitHub: https://github.com/evergreenesoftware/africa-payments-webhooks
- Docs: https://evergreenesoftware.github.io/africa-payments-webhooks/
- npm: https://www.npmjs.com/package/africa-payments-webhooks
- PyPI: https://pypi.org/project/africa-payments-webhooks/
If a real webhook from one of these providers doesn't verify, please open an issue. Provider docs change, and real
payloads are how the library stays correct. Which provider should be next: OPay, Paga, or M-Pesa?
Popoola Sinaayo, backend engineer (Node.js/NestJS & Django).
Disclosure: this article was drafted with AI assistance and then reviewed and edited by me. Every signature detail above is checked by the library's automated tests against the providers' own published examples.