A payment lands in Paystack. The webhook fires. Your server returns a 200 OK. But the customer's account is never credited — and nobody notices until a support ticket arrives three days later.
This is not a hypothetical. It is the most common silent failure in African SaaS products, and it almost always comes down to a webhook handler that was built to pass a tutorial, not to survive production.
Most guides tell you to verify the Paystack HMAC signature and call it a day. That is the floor, not the ceiling. A production-grade handler needs three additional layers: idempotency protection, retry-safe processing logic, and a dead-letter queue for events that keep failing. This guide builds all three in Node.js.
Why Webhooks Fail Silently
Paystack, like every payment processor, operates on an at-least-once delivery guarantee. If your endpoint does not respond with a 2xx status within a few seconds, Paystack will retry the webhook — sometimes multiple times over several hours. This creates two classes of problems:
- Duplicate processing: Your server receives the same
charge.successevent twice and credits the customer twice. - Dropped events: Your handler throws an unhandled error, returns a
500, and after all retries are exhausted, the event is gone — unless you built a safety net.
Network hiccups, cold-start latency on serverless functions, and database timeouts are all realistic triggers on infrastructure common across Ghana, Nigeria, and the broader West African cloud region.
Step 1: Verify the Signature (The Part Everyone Knows)
Never skip this. Every incoming request must be validated against your Paystack secret key before any business logic runs.
const crypto = require("crypto");
function verifyPaystackSignature(req, secret) {
const hash = crypto
.createHmac("sha512", secret)
.update(JSON.stringify(req.body))
.digest("hex");
if (hash !== req.headers["x-paystack-signature"]) {
throw new Error("Invalid signature");
}
}
One critical implementation note: use the raw request body for hashing, not a re-serialized version. If your Express app runs express.json() before this middleware, the body may have been re-parsed and key ordering could differ. Use express.raw({ type: "application/json" }) on your webhook route specifically, then parse manually.
Step 2: Idempotency Keys — The Part Most Guides Skip
Every Paystack event payload carries a unique event id field. This is your idempotency key. Before processing any event, check whether you have already handled it.
The pattern is straightforward:
- Extract
event.idfrom the payload. - Query your database for that ID in a
processed_webhook_eventstable. - If it exists, return
200 OKimmediately — do nothing else. - If it does not exist, insert the ID before processing (not after), then run your business logic.
Inserting before processing is intentional. It uses your database's uniqueness constraint as a distributed lock. If two retries arrive simultaneously, only one will win the insert; the other will get a unique constraint violation and bail out safely.
async function handleWebhook(event, db) {
const inserted = await db.query(
`INSERT INTO processed_webhook_events (event_id, received_at)
VALUES ($1, NOW())
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[event.id]
);
if (inserted.rowCount === 0) {
// Already processed — safe to acknowledge and exit
return { status: "duplicate", eventId: event.id };
}
await processEvent(event, db);
return { status: "processed", eventId: event.id };
}
This single pattern eliminates double-crediting, double-fulfillment, and double-sending of confirmation emails — the most expensive class of webhook bugs.
Step 3: Retry-Safe Business Logic
Your processEvent function must be idempotent in itself, not just guarded by the table above. Consider the case where your handler inserts the event ID successfully, then crashes halfway through fulfillment before you can mark it complete. On the next retry, the event ID already exists in the table, so your guard skips it — and fulfillment never finishes.
The fix is a status column on your events table:
received— inserted, not yet processedprocessing— currently being handledcompleted— fully processedfailed— exhausted retries
On each incoming event, if the status is received or failed (within retry budget), pick it up and process it. Only mark completed after all downstream side-effects have committed. Wrap the entire operation in a database transaction where possible.
This transforms your webhook handler from a fire-and-forget endpoint into a mini event processor — which is exactly what it needs to be when money is involved.
Step 4: Dead-Letter Queues for Events That Keep Failing
Some events will fail repeatedly — perhaps because a third-party fulfillment API is down, or because a data validation edge case you did not anticipate. After N failed attempts (a sensible default is 3–5), stop retrying automatically and route the event to a dead-letter queue (DLQ).
A DLQ does not have to be Kafka or RabbitMQ. For most African SaaS products at early-to-mid scale, a dead_letter_events database table works perfectly:
- Store the full event payload
- Store the last error message and stack trace
- Flag it for manual review or an ops alert
Connect this table to a simple internal dashboard or a Slack alert so your team sees failed events in near-real-time. The goal is zero silent failures: every dropped event must surface somewhere actionable.
For products that have crossed into higher transaction volumes, graduating to a proper message broker like Redis Streams or AWS SQS with a configured DLQ is a natural next step.
Respond Fast, Process Async
One architectural point that applies regardless of the above: your webhook endpoint should do almost nothing synchronously. Verify the signature, enqueue the event, return 200 OK — all within 500ms. Move all database writes and downstream calls into an async worker.
This prevents Paystack's timeout from triggering unnecessary retries on events that your system actually received correctly. It also makes your handler resilient to slow database connections, which are a realistic occurrence depending on your hosting region and database provider.
Checklist Before You Ship
Before your Paystack webhook handler goes to production, verify each of the following:
- Raw body is used for HMAC verification, not re-serialized JSON
- Signature validation fails closed — any error rejects the request
processed_webhook_eventstable exists with a unique index onevent_id- Idempotency insert happens before business logic, not after
- Event status is tracked across the full lifecycle
- Failed events route to a DLQ and trigger an alert
- Webhook endpoint responds within 500ms by offloading work to a queue or worker
- All of this is covered by integration tests that replay real Paystack event payloads
Why This Matters for Your Project
If you are building a SaaS product that processes payments for African customers, your webhook handler is not plumbing — it is the backbone of your revenue recognition and customer trust. A dropped transaction event does not just lose money; it creates a support burden, erodes user confidence, and can trigger chargebacks. The patterns in this guide — idempotency keys, lifecycle status tracking, dead-letter queues, and async processing — are standard practice in mature fintech systems globally. Implementing them early costs a day of engineering. Retrofitting them after a production incident costs far more.





