How to Build a Paystack Webhook Handler That Never Drops an Event
Most payment bugs are not card errors. They are webhook errors — silent failures that leave a user's order in limbo while their bank account is already debited. If you are building a SaaS product on Paystack, the difference between a reliable payment system and a support nightmare often comes down to how well you handle the five lines of code after app.post('/webhook', ...).
This guide walks through building a production-grade Paystack webhook handler in Node.js — one that verifies signatures, deduplicates events, queues work asynchronously, and survives restarts.
Why Webhooks Fail in Production
Paystack sends a webhook as an HTTP POST to your server the moment a payment event occurs. Simple enough. But here is what tutorials usually omit:
- Retries are real. If your server does not respond with a
200within a few seconds, Paystack retries. If your handler is slow (because it is writing to a database, sending emails, etc.), you will receive the same event multiple times. - Order is not guaranteed. A
charge.successevent can arrive before acharge.pendingin edge cases. - Your server will restart. If an event arrives during a deployment window, it can be lost unless you acknowledge it first and process it later.
- Signature verification is skipped more often than it should be. Any endpoint without it is an open door for forged payment confirmations.
Step 1: Verify the Signature — Every Single Time
Paystack signs every webhook payload using your secret key. Verifying this signature is non-negotiable. Here is how to do it correctly in Express:
const crypto = require('crypto');
function verifyPaystackSignature(req, res, next) {
const secret = process.env.PAYSTACK_SECRET_KEY;
const hash = crypto
.createHmac('sha512', secret)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
}
// IMPORTANT: use express.json() before this middleware,
// but pass the raw body to the HMAC — not a re-serialized version.
app.post('/webhook/paystack', express.json(), verifyPaystackSignature, handleWebhook);
One critical gotcha: if you use body-parser with any transformation options or if middleware re-serializes the payload, your HMAC will not match. Always compute the hash against the raw, unmodified request body. A reliable approach is to capture the raw buffer using express.raw({ type: 'application/json' }) and parse it manually after verification.
Step 2: Acknowledge Immediately, Process Asynchronously
Your handler must return 200 OK to Paystack as fast as possible — ideally within 2–3 seconds. Any business logic (database writes, email triggers, provisioning) should happen after the response is sent.
async function handleWebhook(req, res) {
// Acknowledge Paystack immediately
res.sendStatus(200);
const event = req.body;
// Push to queue for async processing
await eventQueue.push(event);
}
This pattern decouples acknowledgment from processing. Paystack is satisfied; your system processes at its own pace.
Step 3: Implement Idempotency With an Event Log
Because Paystack retries on failure or timeout, you will receive duplicate events. Idempotency means processing the same event twice produces the same result — no double subscriptions, no duplicate receipts.
The simplest approach is an processed_events table in your database:
| Column | Type |
|---|---|
event_id | VARCHAR (unique) |
event_type | VARCHAR |
processed_at | TIMESTAMP |
Before processing any event, check this table:
async function processEvent(event) {
const alreadyProcessed = await db.query(
'SELECT id FROM processed_events WHERE event_id = $1',
[event.data.id]
);
if (alreadyProcessed.rows.length > 0) {
console.log(`Duplicate event skipped: ${event.data.id}`);
return;
}
// Process the event
await handlePaymentSuccess(event.data);
// Mark as processed
await db.query(
'INSERT INTO processed_events (event_id, event_type, processed_at) VALUES ($1, $2, NOW())',
[event.data.id, event.event]
);
}
Wrap the check and insert in a database transaction with a unique constraint on event_id for race-condition safety. If two duplicate events arrive simultaneously, the database constraint ensures only one succeeds.
Step 4: Build a Simple In-Process Queue (or Use a Real One)
For lower-traffic applications, an in-memory queue using p-queue or a simple async array works fine. For production SaaS with meaningful transaction volumes, use a proper message queue — BullMQ with Redis is the standard Node.js choice.
Here is a minimal BullMQ setup:
const { Queue, Worker } = require('bullmq');
const connection = { host: 'localhost', port: 6379 };
const webhookQueue = new Queue('paystack-webhooks', { connection });
// In your webhook handler:
await webhookQueue.add('process-event', event);
// Worker (can run in a separate process):
new Worker('paystack-webhooks', async job => {
await processEvent(job.data);
}, { connection });
This gives you automatic retries with backoff, job persistence across restarts, and a dashboard (via Bull Board) to monitor failed jobs — invaluable when debugging a payment issue at 2 AM.
Step 5: Handle the Events That Actually Matter
Not all Paystack events need the same treatment. Here is a practical routing pattern:
const eventHandlers = {
'charge.success': handleChargeSuccess,
'transfer.success': handleTransferSuccess,
'transfer.failed': handleTransferFailed,
'subscription.create': handleSubscriptionCreate,
'invoice.payment_failed': handleInvoiceFailure,
};
async function handlePaystackEvent(event) {
const handler = eventHandlers[event.event];
if (handler) {
await handler(event.data);
} else {
console.warn(`Unhandled event type: ${event.event}`);
}
}
Logging unhandled event types is important — Paystack occasionally introduces new event types, and you want visibility before they affect users.
Common Pitfalls Specific to African SaaS Contexts
- Mobile Money events behave differently. MTN MoMo and Vodafone Cash payments via Paystack sometimes take longer to confirm. Do not provision access until
charge.success— not on initialization. - USSD payments can time out and succeed later. Build your UI to handle a "pending" state gracefully rather than marking a session as failed immediately.
- Test mode keys in production. It happens. Use environment-level config validation on startup to fail loud if
PAYSTACK_SECRET_KEYdoes not start withsk_live_in your production environment.
Why This Matters for Your Project
Payment reliability is a trust signal. For SaaS teams in Ghana and across Africa building on Paystack, a dropped webhook does not just mean a delayed subscription — it can mean a user churns before they ever experience your product's value. A handler built on signature verification, idempotent processing, and async queuing is not over-engineering; it is the baseline. The patterns here scale from your first hundred transactions to your first hundred thousand without a rewrite.




