How to Build a Paystack Webhook Handler That Never Drops Events
A payment hits Paystack. The charge succeeds. Paystack fires a webhook to your server. Your server is mid-deploy, or your database is briefly overloaded, and the handler throws an unhandled exception. You return a 500. Paystack retries — but your retry logic isn't idempotent, so the order gets fulfilled twice. Or worse: it never gets fulfilled at all.
This is not a theoretical edge case. It is Tuesday at 2 PM on a busy SaaS product, and you have an angry customer on the line.
This guide is about building a webhook handler that holds up under real production conditions — idempotent processing, a durable queue, and graceful recovery from restarts.
What Paystack Actually Sends You
Before writing any handler logic, understand the payload you are working with. Paystack sends a JSON body with a consistent envelope:
{
"event": "charge.success",
"data": {
"id": 3049589,
"reference": "txn_abc123xyz",
"amount": 5000,
"currency": "GHS",
"status": "success",
"customer": {
"email": "user@example.com",
"customer_code": "CUS_xxxxxxx"
},
"metadata": {
"order_id": "ORD-9981"
},
"paid_at": "2024-11-15T14:32:00.000Z"
}
}
The event field tells you what happened. The data.reference is your transaction reference — the one you generated when initializing the transaction. This reference is your idempotency key. Treat it as sacred.
Step 1 — Verify the Signature First, Always
Every request to your webhook endpoint must be verified before any processing happens. Paystack signs payloads using HMAC-SHA512 with your secret key.
const crypto = require("crypto");
function verifyPaystackSignature(req, secret) {
const hash = crypto
.createHmac("sha512", secret)
.update(JSON.stringify(req.body))
.digest("hex");
return hash === req.headers["x-paystack-signature"];
}
One critical note: use JSON.stringify(req.body) only if your framework parses JSON body before this step. You must use the raw body buffer to generate the hash — not a re-serialised object. Parse raw bytes with express.raw({ type: "application/json" }) and keep a separate JSON parse step. If the bytes are mutated before hashing, verification will fail intermittently and you will spend hours debugging it.
Reject unverified requests immediately with a 401. Do not log the payload. Do not do anything else.
Step 2 — Acknowledge Fast, Process Slow
Paystack expects a 200 OK within a few seconds. If your handler does database writes, sends emails, or calls third-party APIs inline, you will eventually time out under load and Paystack will retry — creating duplicate processing.
The correct pattern is: acknowledge immediately, enqueue for processing.
app.post("/webhooks/paystack", rawBodyMiddleware, (req, res) => {
if (!verifyPaystackSignature(req, process.env.PAYSTACK_SECRET)) {
return res.status(401).send("Unauthorized");
}
const event = JSON.parse(req.body);
// Enqueue — do not await
webhookQueue.add("paystack-event", event, { attempts: 5, backoff: { type: "exponential", delay: 2000 } });
return res.status(200).send("Accepted");
});
This keeps your endpoint's response time under 100ms regardless of what the worker does downstream. Use BullMQ (backed by Redis) for the queue. It persists jobs across server restarts, which is the whole point.
Step 3 — Make Your Worker Idempotent
Your queue worker will process each job. The danger is duplicate delivery — Paystack may send the same event more than once if it does not receive a timely 200. Your worker must handle this gracefully.
The strategy: record processed references in your database before acting on them.
webhookQueue.process("paystack-event", async (job) => {
const { event, data } = job.data;
if (event !== "charge.success") return; // handle only what you need
const reference = data.reference;
// Idempotency check
const alreadyProcessed = await db.webhookLogs.findOne({ reference });
if (alreadyProcessed) {
console.log(`Duplicate event skipped: ${reference}`);
return;
}
// Atomic insert + business logic in a transaction
await db.transaction(async (trx) => {
await trx.webhookLogs.insert({ reference, event, processedAt: new Date() });
await fulfillOrder(data.metadata.order_id, trx);
await sendConfirmationEmail(data.customer.email);
});
});
The webhookLogs table acts as your deduplication ledger. Add a unique index on reference. If two workers race on the same reference, the second insert will fail and the transaction rolls back cleanly — no double-fulfillment.
Step 4 — Handle More Than charge.success
Production integrations deal with multiple event types. Structure your worker as a dispatcher:
Events worth handling
charge.success— payment confirmed, fulfill the ordertransfer.success/transfer.failed— payout status for marketplace disbursementssubscription.create/subscription.disable— plan lifecycle for SaaS billinginvoice.payment_failed— trigger dunning logic or grace period
Use a switch or a handler registry pattern rather than stacking if/else. Each handler should be independently testable and share the same idempotency wrapper.
Step 5 — Surviving Server Restarts
Because BullMQ persists jobs in Redis, any events that were enqueued but not yet processed survive a deployment or crash. When your server comes back up, workers resume processing from where they left off.
Two things to get right:
- Set
removeOnComplete: falseduring development so you can inspect completed jobs. In production, keep completed jobs for at least 24 hours for audit purposes. - Use job IDs tied to the Paystack reference —
webhookQueue.add("paystack-event", event, { jobId: data.reference }). BullMQ deduplicates jobs with the same ID if they are already in the queue. This gives you a second layer of deduplication before the job even reaches the worker.
What to Monitor
A resilient handler is only resilient if you know when it is struggling. Track:
- Queue depth — spikes indicate your workers are falling behind
- Failed job count — alert if this grows beyond a threshold
- Duplicate reference hits — a spike here means Paystack is retrying, which means you were returning non-200 responses
- Worker processing latency — how long between enqueue and completion
Export these metrics to whatever observability stack you use — Prometheus, Datadog, or even a simple cron job that writes queue stats to a dashboard.
Why This Matters for Your Project
Payment reliability is a direct proxy for revenue reliability. Every dropped webhook is a potential stuck order, a frustrated customer, or a failed subscription renewal. If you are building a SaaS product or marketplace on Paystack, the few hours it takes to implement queue-backed, idempotent webhook processing will pay for itself the first time your deployment pipeline and a high-traffic moment collide. Build it right once, and the payment layer becomes the least of your operational concerns.





