Charging a card is the easy part. The hard part is what happens next — confirming that the charge actually succeeded, handling retries when your server was briefly down, and making sure you never fulfill the same order twice. That is exactly what Paystack webhooks are for, and most tutorials skip straight past them.
This guide walks through the full webhook integration lifecycle in a Node.js backend: receiving events, verifying their authenticity, persisting them idempotently, and handling Paystack's retry behavior gracefully.
Why Webhooks, Not Just API Polling
After initiating a payment, you could poll Paystack's GET /transaction/verify/:reference endpoint until a status change appears. This works in development. In production, it is fragile — polling adds latency, burns API quota, and breaks down completely when your server is unavailable at the moment of settlement.
Webhooks invert the model. Paystack pushes a signed HTTP POST to your endpoint the instant a payment event occurs. Your job is to receive it, validate it, act on it, and respond fast.
Setting Up the Webhook Endpoint
Start with a minimal Express route. Keep it lean — heavy processing belongs in a queue or background worker, not the request handler.
import express from "express";
import crypto from "crypto";
const router = express.Router();
const PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;
router.post(
"/webhooks/paystack",
express.raw({ type: "application/json" }), // RAW body required for signature check
async (req, res) => {
const signature = req.headers["x-paystack-signature"];
const hash = crypto
.createHmac("sha512", PAYSTACK_SECRET)
.update(req.body)
.digest("hex");
if (hash !== signature) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body);
// Acknowledge immediately — processing happens async
res.sendStatus(200);
await handlePaystackEvent(event);
}
);
Two things to notice here. First, express.raw() is mandatory. If you parse the body with express.json() before hashing it, the byte sequence changes and the signature will never match. Second, the 200 OK goes out before your business logic runs. Paystack expects a fast acknowledgement; anything slower than a few seconds risks a retry.
Verifying Signatures Correctly
Paystack signs every webhook payload using HMAC-SHA512 with your secret key. The signature appears in the x-paystack-signature header.
The single most common mistake is running signature verification against a parsed (stringified) body instead of the raw buffer. Always pass req.body as a Buffer to the HMAC update call, which is why the express.raw() middleware is scoped specifically to this route.
Never skip signature verification in production — even behind a firewall. An attacker who discovers your webhook URL can forge events that mark orders as paid.
Idempotency: The Part Everyone Skips
Paystack retries webhook delivery if your endpoint does not respond with a 2xx status within a reasonable window. This is helpful for resilience, but it means your handler will sometimes receive the same event more than once.
Without idempotency guards, a single successful payment can trigger duplicate order fulfillments, double email sends, or double credit top-ups.
The fix is straightforward: persist a record of every processed event reference before acting on it.
async function handlePaystackEvent(event) {
const { event: eventType, data } = event;
if (eventType === "charge.success") {
const { reference } = data;
// Check if already processed
const existing = await db.webhookEvents.findOne({ reference });
if (existing) return; // Duplicate — safely ignore
// Mark as processed first, then fulfill
await db.webhookEvents.insertOne({ reference, processedAt: new Date() });
await fulfillOrder(reference, data);
}
}
Insert the record before fulfillment (or inside a transaction if your database supports it). If you fulfill first and then the insert fails, a retry will re-process the event. Insert first, and a crash before fulfillment simply means the event gets processed on the next delivery — which is the safe failure mode.
Handling the Event Types That Matter
Beyond charge.success, a production integration should handle several other event types:
charge.success— Payment confirmed. Fulfill the order, send receipt, update subscription status.transfer.success/transfer.failed— If you use Paystack Transfers for payouts, these confirm or flag disbursement outcomes.subscription.create/subscription.disable— Essential for SaaS apps managing recurring billing state.invoice.payment_failed— Trigger dunning workflows or notify the customer to update their card.
Structure your handler as a dispatcher that routes event types to dedicated functions rather than a single monolithic switch block. This makes each handler independently testable.
Retry Logic and Failure Recovery
Paystack retries failed webhook deliveries several times with increasing intervals. Your endpoint will eventually receive old events — sometimes hours later. Design accordingly:
- Always check event age. If
data.paid_atis more than 24 hours ago and you have no record of the order, flag it for manual review rather than blindly fulfilling. - Return
200even for events you choose to ignore. If you return4xxor5xx, Paystack will keep retrying. Acknowledge unknown event types gracefully. - Log every incoming event to a durable store (database or structured log sink) before processing. When something goes wrong in production, raw event logs are invaluable for debugging.
Testing Without a Public URL
During local development, use a tunneling tool like ngrok or Cloudflare Tunnel to expose your local server. Then register the tunnel URL in the Paystack Dashboard under Settings → API Keys & Webhooks.
Paystack also provides a "Send Test Event" feature in the dashboard that fires real event payloads against your endpoint — far more reliable than hand-crafting mock request bodies.
Why This Matters for Your Project
A payment integration that only handles the happy path is a liability. Webhooks are the contract between your application and the payment processor — they carry the ground truth about what actually happened with money. Getting signature verification, idempotency, and retry handling right from the start means fewer customer support tickets, no missed fulfillments, and a backend that holds up under the unpredictable conditions of real-world traffic. Whether you are building a SaaS product, a marketplace, or an e-commerce platform, treating the webhook layer as a first-class engineering concern is what separates a prototype from a production-ready system.





