So you added Paystack to your app.
The checkout pops up. The test card goes through. Your webhook route logs charge.success.
You mark the order as paid, you push to production, and you go to bed feeling like a payments engineer. 😎
Now, quick question.
What stops anybody on the internet from sending this to your server?
curl -X POST https://your-api.com/webhooks/paystack \ -H "Content-Type: application/json" \ -d '{"event":"charge.success","data":{"reference":"order_001","amount":500000}}' If your answer is "nothing"... then congratulations, you just built a free ₦5,000 voucher machine. 😭😭
Your webhook URL is just a URL. Anyone who finds it can POST to it. So in this article we are going to fix that, step by step:
- Check that the webhook really came from Paystack (the signature)
- Do it on the raw body, because that's where most people get stuck
- Ignore the same event if it shows up twice
- Ask Paystack directly before we give anybody value
The Estate Gate Analogy 🏘️
Picture an estate in Lagos with a gate and a security man. Let's call him Baba Gate.
A delivery man walks up and says: "Paystack sent me. The customer in Flat 3 has paid. Release their goods."
A lazy Baba Gate just opens the gate. Anyone can say "Paystack sent me".
A good Baba Gate does four things:
- Checks the stamp on the letter. Paystack signs every letter with a stamp only Paystack and you can make (your secret key).
- Checks the stamp against the original letter. Not a photocopy. Not a version someone retyped neatly. The original.
- Checks his logbook. "Did I already let this same delivery in this morning?"
- Calls the office to confirm. "Did Flat 3 really pay ₦5,000?"
Hold on to Baba Gate. He is the whole article. 😁
Prerequisites
- Node.js 18 or newer (we use the built-in
fetch) - Basic Express knowledge (routes, middleware)
- A little TypeScript (if you've read my Express + TypeScript post, you're good)
- A Paystack account is optional. I'll explain below.
Honest note: in this tutorial we fake the Paystack side so you can run everything on your laptop. We sign our own test webhooks with a practice secret, and the "call the office" step uses a small fake ledger instead of the real Verify Transaction API. When you are ready, one environment variable switches it to the real API.
Let's Dive Right In 😁😁
mkdir paystack-webhook-guard && cd paystack-webhook-guard npm init -y npm i express@5 npm i -D typescript @types/express @types/node Here is where we're going:
paystack-webhook-guard/ ├── src/ │ ├── config.ts │ ├── app.ts │ ├── server.ts │ ├── routes/ │ │ └── webhook.ts │ ├── services/ │ │ ├── paystack.ts │ │ └── paymentHandler.ts │ ├── store/ │ │ └── orderStore.ts │ └── utils/ │ └── verifySignature.ts ├── scripts/ │ ├── send-webhook.ts │ └── why-raw.ts ├── package.json └── tsconfig.json And the flow, Baba Gate style:
Paystack ──POST──▶ /webhooks/paystack │ ▼ express.raw() → keep the ORIGINAL letter (Buffer) │ ▼ check x-paystack-signature ──✗──▶ 401, gate stays shut │ ✓ ▼ JSON.parse + reply 200 quickly │ ▼ logbook: seen this event? ──yes──▶ skip │ no ▼ call Paystack: verify reference, amount, currency │ ✓ ▼ mark order as paid 🎉 Step 0: The boring setup — tsconfig.json and package.json
Nobody claps for config files, but they are holding the house up.
{ "compilerOptions": { "target": "ES2022", "module": "commonjs", "rootDir": ".", "outDir": "dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src", "scripts"] } Add these scripts to your package.json:
"scripts": { "build": "tsc --strict", "start": "node dist/src/server.js", "webhook": "node dist/scripts/send-webhook.js" } That's it. strict is on, because we are adults now. 😅
Step 1: Baba Gate's secret — src/config.ts
This file does one thing. It reads your secret key and refuses to start without it.
export const config = { port: Number(process.env.PORT ?? 4000), paystackSecret: process.env.PAYSTACK_SECRET_KEY ?? "", // true = pretend to be Paystack's Verify Transaction API (for local practice) mockPaystack: process.env.PAYSTACK_MOCK !== "false", }; if (!config.paystackSecret) { throw new Error("PAYSTACK_SECRET_KEY is missing. Put it in your environment."); } - Your Paystack secret key is what Paystack uses to stamp the letters. Never put it in your frontend. Never commit it.
-
PAYSTACK_MOCKis on by default so you can practise without hitting the real API.
Step 2: Checking the stamp — src/utils/verifySignature.ts
Paystack's webhook docs say every webhook comes with an x-paystack-signature header, and that header is an HMAC SHA512 signature of the payload, signed with your secret key.
In plain English: Paystack takes the exact body it's sending you, mixes it with your secret key, and produces a long hex string. We do the same mixing on our side. If our string matches theirs, the letter is real.
import crypto from "node:crypto"; export function isValidPaystackSignature( rawBody: Buffer, signature: string | undefined, secret: string ): boolean { if (!signature) return false; const expected = crypto .createHmac("sha512", secret) .update(rawBody) .digest("hex"); const expectedBuf = Buffer.from(expected, "utf8"); const receivedBuf = Buffer.from(signature, "utf8"); // timingSafeEqual throws if the lengths differ, so check first if (expectedBuf.length !== receivedBuf.length) return false; return crypto.timingSafeEqual(expectedBuf, receivedBuf); } Three important lines, don't skip them:
-
.update(rawBody)— we hash the raw bytes, not a JavaScript object. (Step 4 explains why this matters so much.) -
crypto.timingSafeEqual— a normal===can stop early on the first different character, and that timing difference is information an attacker can measure.timingSafeEqualcompares in constant time. - The length check —
timingSafeEqualthrows aRangeErrorif the two buffers have different lengths. I tested it:Input buffers must have the same byte length. So if someone sendsx-paystack-signature: lol, we returnfalseinstead of crashing.
Step 3: The estate records — src/store/orderStore.ts
We need somewhere to keep orders and a logbook of events we've already handled. For this tutorial, it's a Map and a Set in memory.
export type OrderStatus = "pending" | "paid"; export interface Order { reference: string; amount: number; // in kobo (100 kobo = ₦1) currency: "NGN"; status: OrderStatus; } // In-memory for this tutorial. In real life: your database. const orders = new Map<string, Order>([ ["order_001", { reference: "order_001", amount: 500000, currency: "NGN", status: "pending" }], ["order_002", { reference: "order_002", amount: 250000, currency: "NGN", status: "pending" }], ]); // Our logbook of events we have already handled const processedEvents = new Set<string>(); export const orderStore = { find(reference: string): Order | undefined { return orders.get(reference); }, markPaid(reference: string): void { const order = orders.get(reference); if (order) order.status = "paid"; }, // Returns false if we've seen this key before claimEvent(key: string): boolean { if (processedEvents.has(key)) return false; processedEvents.add(key); return true; }, releaseEvent(key: string): void { processedEvents.delete(key); }, }; - Amounts are in kobo. Paystack works with the subunit of the currency, so ₦5,000 is
500000. Mix this up and someone pays ₦50 for a ₦5,000 order. 🙃 -
claimEventis Baba Gate's logbook. First time we see a key, we write it down and returntrue. Second time,false.
In production this is your database. More on that at the end.
Step 4: The original letter, not a photocopy — src/routes/webhook.ts
This is the heart of the whole thing. 👀
Most Express apps have this line at the top of app.js:
app.use(express.json()); It reads the request body, parses it, and gives you a nice object in req.body. Lovely for normal routes. Terrible for webhooks.
Why? Because once Express has parsed the body, the original bytes are gone. To check the signature you'd have to do JSON.stringify(req.body) and hope it comes out byte for byte the same as what Paystack sent.
To be fair, Paystack's own docs example does exactly that, and it works when the re-built string happens to match the original exactly. But "happens to match" is not a payments strategy. 😅 Stripe's docs say it plainly: they need the raw body for signature verification, and any change to it makes verification fail.
Don't believe me? Here's a tiny script that signs the same data in three slightly different "shapes":
// Same data, different bytes = different signature import crypto from "node:crypto"; const secret = "sk_test_practice_only"; const sign = (s: string) => crypto.createHmac("sha512", secret).update(s).digest("hex").slice(0, 16); const raw = '{"event":"charge.success","data":{"reference":"order_001","amount":500000}}'; const rawWithSpaces = '{ "event": "charge.success", "data": { "reference": "order_001", "amount": 500000 } }'; const rawEscaped = '{"event":"charge.success","data":{"reference":"order\\/001","amount":500000}}'; for (const original of [raw, rawWithSpaces, rawEscaped]) { const rebuilt = JSON.stringify(JSON.parse(original)); console.log(sign(original) === sign(rebuilt) ? "✅ match" : "❌ MISMATCH", "|", original); } npx tsc --strict && node dist/scripts/why-raw.js Output:
✅ match | {"event":"charge.success","data":{"reference":"order_001","amount":500000}} ❌ MISMATCH | { "event": "charge.success", "data": { "reference": "order_001", "amount": 500000 } } ❌ MISMATCH | {"event":"charge.success","data":{"reference":"order\/001","amount":500000}} Same data. One extra space, or one escaped /, and the stamp no longer matches. That's the photocopy problem: Baba Gate is comparing the stamp against a retyped letter.
So we tell Express: for this one route, don't parse anything, just give me the raw bytes.
import express, { Router, Request, Response } from "express"; import { config } from "../config"; import { isValidPaystackSignature } from "../utils/verifySignature"; import { handlePaystackEvent, PaystackEvent } from "../services/paymentHandler"; const router = Router(); router.post( "/paystack", express.raw({ type: "application/json" }), (req: Request, res: Response) => { // 1. We must have the raw bytes, not a parsed object if (!Buffer.isBuffer(req.body)) { console.error("Webhook body is not raw. Is express.json() running before this route?"); res.sendStatus(400); return; } // 2. Check the stamp const signature = req.get("x-paystack-signature"); if (!isValidPaystackSignature(req.body, signature, config.paystackSecret)) { res.sendStatus(401); return; } // 3. Only now do we read the letter let event: PaystackEvent; try { event = JSON.parse(req.body.toString("utf8")) as PaystackEvent; } catch { res.sendStatus(400); return; } // 4. Say "received" fast, then do the work res.sendStatus(200); handlePaystackEvent(event).catch((err) => console.error("Failed to process webhook:", err) ); } ); export default router; Let's walk through it:
-
express.raw({ type: "application/json" })gives usreq.bodyas aBuffer. We must settype, because by defaultexpress.raw()only handlesapplication/octet-stream, and Paystack sends JSON. - The
Buffer.isBuffercheck catches the classic mistake (see Step 6). If some other parser got there first, we fail loudly instead of silently rejecting every real webhook. - We verify before we
JSON.parse. Never read a letter you haven't confirmed is real. - We reply
200before the slow work. Paystack's docs say to acknowledge the event before running long tasks, because a slow handler can time out and count as a failure. And failures get retried: in live mode, every 3 minutes for the first 4 tries, then hourly for the next 72 hours. Which leads us nicely to...
Step 5: The logbook and the phone call — src/services/paystack.ts and src/services/paymentHandler.ts
Retries mean the same event can arrive more than once. Stripe's docs say the same about their webhooks, and recommend logging the event IDs you've processed and skipping ones you've already seen. Paystack's "Verify Payments" page also tells you to confirm you haven't already delivered value for a transaction, especially if you use webhooks.
Also: a valid stamp proves the letter came from Paystack. It doesn't hurt to call the office and confirm the details match your order before you release goods. Paystack's Verify Transaction endpoint is GET https://api.paystack.co/transaction/verify/:reference with your secret key as a Bearer token.
First, the "phone call":
import { config } from "../config"; export interface VerifyResponse { status: boolean; message: string; data: { status: string; // "success", "failed", "abandoned", ... reference: string; amount: number; // subunit, e.g. kobo currency: string; }; } // Pretend Paystack's records, used only when PAYSTACK_MOCK is on const mockLedger: Record<string, VerifyResponse["data"]> = { order_001: { status: "success", reference: "order_001", amount: 500000, currency: "NGN" }, order_002: { status: "success", reference: "order_002", amount: 100, currency: "NGN" }, }; export async function verifyTransaction(reference: string): Promise<VerifyResponse> { if (config.mockPaystack) { const data = mockLedger[reference]; if (!data) return { status: false, message: "Transaction reference not found", data: { status: "failed", reference, amount: 0, currency: "NGN" } }; return { status: true, message: "Verification successful", data }; } const res = await fetch( `https://api.paystack.co/transaction/verify/${encodeURIComponent(reference)}`, { headers: { Authorization: `Bearer ${config.paystackSecret}` } } ); return (await res.json()) as VerifyResponse; } - With
PAYSTACK_MOCKon, we answer frommockLedger. Noticeorder_002in the fake ledger says100kobo. That's on purpose. We'll use it to see what happens when the numbers don't agree. 😋 - With
PAYSTACK_MOCK=false, it calls the real endpoint with your key.
Now the handler, where Baba Gate makes his final decision:
import { orderStore } from "../store/orderStore"; import { verifyTransaction } from "./paystack"; export interface PaystackEvent { event: string; data: { reference: string; amount: number; currency: string; status: string }; } export async function handlePaystackEvent(event: PaystackEvent): Promise<void> { if (event.event !== "charge.success") { console.log(`Ignoring event: ${event.event}`); return; } const { reference } = event.data; const key = `${event.event}:${reference}`; // Have we let this visitor in before? if (!orderStore.claimEvent(key)) { console.log(`Duplicate event ${key}, skipping`); return; } try { const order = orderStore.find(reference); if (!order) { console.warn(`No order for reference ${reference}`); return; } if (order.status === "paid") { console.log(`Order ${reference} already paid`); return; } // Don't trust the letter alone. Call Paystack and ask. const check = await verifyTransaction(reference); const ok = check.status === true && check.data.status === "success" && check.data.amount === order.amount && check.data.currency === order.currency; Source:https://dev.to/fredabod/stop-trusting-every-webhook-verify-paystack-signatures-in-express-for-beginners-1gkg
