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;
if (!ok) {
console.warn(`Verification failed for ${reference}`, check.data);
return;
}
orderStore.markPaid(reference);
console.log(`β
Order ${reference} marked as paid`);
} catch (err) {
orderStore.releaseEvent(key); // let a retry try again
throw err;
}
}
- We only care about
charge.successhere. Everything else is politely ignored. -
event + referenceis our logbook key. Same event, same reference, second time? Skip. - We claim the key before the
await. If two copies arrive at the same moment, only one gets past the logbook. - We check
status,amountandcurrencyagainst our own order. Not the webhook's numbers. Ours. - If something crashes,
releaseEventremoves the key so a future retry can try again.
Step 6: Order matters β src/app.ts and src/server.ts
Remember that app.use(express.json()) line? Here is where most bugs are born.
import express from "express";
import webhookRoutes from "./routes/webhook";
import { orderStore } from "./store/orderStore";
const app = express();
// Webhooks FIRST, before any JSON parser touches the body
app.use("/webhooks", webhookRoutes);
// Everybody else can have parsed JSON
app.use(express.json());
app.get("/orders/:reference", (req, res) => {
const order = orderStore.find(req.params.reference);
if (!order) {
res.status(404).json({ message: "Order not found" });
return;
}
res.json(order);
});
export default app;
The webhook router is mounted before express.json().
If you flip them, express.json() reads the body first. Then express.raw() sees the body has already been handled and does nothing, so req.body is an object, not a Buffer. I tested this exact mistake on purpose and our guard caught it:
Webhook body is not raw. Is express.json() running before this route?
status 400
Without that check you'd just get mysterious 401s on every real webhook and spend your Saturday blaming Paystack. It wasn't Paystack. It was you. It was always you π
And the server file is short:
import app from "./app";
import { config } from "./config";
app.listen(config.port, () => {
console.log(`Server running on http://localhost:${config.port} (mock Paystack: ${config.mockPaystack})`);
});
Step 7: Pretend to be Paystack β scripts/send-webhook.ts
We need someone to knock on the gate. This script builds a charge.success payload, stamps it with the same secret, and POSTs it. Exactly what Paystack does, minus the money. π
// Pretend to be Paystack: sign a payload with your secret and POST it.
import crypto from "node:crypto";
const secret = process.env.PAYSTACK_SECRET_KEY ?? "";
const url = process.env.WEBHOOK_URL ?? "http://localhost:4000/webhooks/paystack";
const reference = process.argv[2] ?? "order_001";
const amount = Number(process.argv[3] ?? 500000);
const body = JSON.stringify({
event: "charge.success",
data: { reference, amount, currency: "NGN", status: "success" },
});
const signature = crypto.createHmac("sha512", secret).update(body).digest("hex");
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "x-paystack-signature": signature },
body,
}).then((res) => console.log(`Webhook for ${reference} -> ${res.status}`));
Let's Test It π§ͺ
Build and start the server:
npm run build
export PAYSTACK_SECRET_KEY=sk_test_practice_only
npm start
In a second terminal (export the same PAYSTACK_SECRET_KEY there too):
1. A real, signed webhook
npm run -s webhook -- order_001 500000
curl -s localhost:4000/orders/order_001
Webhook for order_001 -> 200
{"reference":"order_001","amount":500000,"currency":"NGN","status":"paid"}
Server log: β
Order order_001 marked as paid π
2. The same webhook again (a retry)
npm run -s webhook -- order_001 500000
Webhook for order_001 -> 200
Server log: Duplicate event charge.success:order_001, skipping. We still reply 200, so Paystack stops retrying, but we don't deliver twice. Baba Gate checked his logbook. π₯°
3. A forged signature
BODY='{"event":"charge.success","data":{"reference":"order_002","amount":250000,"currency":"NGN","status":"success"}}'
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:4000/webhooks/paystack \
-H "Content-Type: application/json" \
-H "x-paystack-signature: iamdefinitelypaystack" \
-d "$BODY"
401
Leave out the header completely and you also get 401.
4. A real signature, but someone changed the amount
SIG=$(printf '%s' "$BODY" | openssl dgst -sha512 -hmac "$PAYSTACK_SECRET_KEY" | awk '{print $NF}')
TAMPERED=$(echo "$BODY" | sed 's/250000/2500/')
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:4000/webhooks/paystack \
-H "Content-Type: application/json" \
-H "x-paystack-signature: $SIG" \
-d "$TAMPERED"
401
The stamp was real, but the letter changed after stamping. Gate stays shut. πͺ
5. Valid signature, but Paystack's records disagree
This one uses a body with extra spaces too, just to prove raw-body hashing doesn't care about formatting:
PRETTY='{ "event": "charge.success", "data": { "reference": "order_002", "amount": 250000, "currency": "NGN", "status": "success" } }'
SIG=$(printf '%s' "$PRETTY" | openssl dgst -sha512 -hmac "$PAYSTACK_SECRET_KEY" | awk '{print $NF}')
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:4000/webhooks/paystack \
-H "Content-Type: application/json" \
-H "x-paystack-signature: $SIG" \
-d "$PRETTY"
curl -s localhost:4000/orders/order_002
200
{"reference":"order_002","amount":250000,"currency":"NGN","status":"pending"}
The signature passed (even with all those spaces), but our fake Paystack ledger says only 100 kobo was paid, not 250000. So the order stays pending and the server logs Verification failed for order_002. Baba Gate called the office, and the office said "no o". π
Before vs After
| Before π¬ | After π | |
|---|---|---|
| Who can mark an order paid | Anyone with curl | Only letters stamped with your secret |
| Body used for the signature | Re-built with JSON.stringify
|
The raw bytes Paystack sent |
| Comparing signatures | === |
timingSafeEqual with a length check |
| Retries / duplicates | Customer gets value twice | Logbook skips it |
| Amount check | Trust the webhook | Ask Paystack, compare with your own order |
| Slow work | Inside the request, risking timeouts | Reply 200 first, then process |
Why we do it this way (and what changes in production)
This is pretty much the same shape I use for real payment flows. At Prooval, where I built the backend from scratch, we take payments through Stripe, Paystack and Fincra, and every one of them comes down to the same Baba Gate questions: is this really from the provider, have I seen it before, and do the numbers match?
A few things you should upgrade before real money flows through this:
-
Use your database for the logbook. An in-memory
Setis wiped on every restart and isn't shared between servers. Save processed events in a table with a unique index on the key, so if two servers race, the database lets only one insert win. -
Move the slow work to a queue. Right now we
res.sendStatus(200)and then process in the same Node process. If the server dies in between, that event is gone, because Paystack already got its 200. A job queue (I wrote about BullMQ on LinkedIn recently) or at least a "received events" table you can replay fixes this. - Lock down the route. Paystack also publishes the IP addresses it sends webhooks from, so you can whitelist them as an extra layer. Check their webhook docs for the current list.
-
Switch the mock off. Set
PAYSTACK_MOCK=falseand use your real test secret key. TheverifyTransactionfunction will call the real endpoint.
Recap (say it with me) π£οΈ
- A webhook URL is public. Anyone can POST to it.
- Paystack signs every webhook with HMAC SHA512 of the body, using your secret key, in
x-paystack-signature. - Hash the raw body (
express.raw({ type: "application/json" })), notJSON.stringify(req.body). - Mount the webhook route before
express.json(). - Compare with
timingSafeEqual, and check lengths first. - Reply 200 fast, then do the work.
- The same event can come more than once. Keep a logbook.
- Before giving value, verify the transaction and compare amount and currency with your order.
Baba Gate would be proud. π«‘
I hope this was helpful. Don't forget to drop a like and follow ππ
If you've been burned by a webhook bug before, tell me about it in the comments. I promise not to laugh. Too much. π
Thank you for taking the time to read π₯°π₯°
See you on the next one π€




Top comments (0)