Paystack will tell you a payment succeeded. The question is whether your backend listens correctly — and safely.
Most Node.js tutorials walk you through initializing a transaction and redirecting the user. That covers maybe 40% of a real payment integration. The remaining 60% lives inside your webhook handler: the silent, server-side receiver that Paystack calls every time something meaningful happens — a charge succeeds, a subscription renews, a transfer fails. Get this wrong in production and you end up with paid users locked out, duplicate order fulfillment, or silent revenue leakage you won't notice until a customer complains.
This guide covers the parts most tutorials skip.
Why Webhooks, Not Just Callback URLs
Paystack supports a callback_url that redirects the user after payment. Relying on it exclusively is a mistake. Users close tabs, lose connectivity, or get interrupted mid-redirect. The callback never fires, but the money already moved.
Webhooks are server-to-server. Paystack sends a POST request to your endpoint regardless of what the user's browser does. Your webhook handler is the authoritative source of truth for payment status — not the redirect, not the frontend.
Setting Up the Endpoint
In Express, start with a raw body parser. This is non-negotiable — signature verification requires the exact raw bytes Paystack signed, not a JSON-parsed object.
const express = require("express");
const crypto = require("crypto");
const app = express();
// Mount raw body parser ONLY for the webhook route
app.post(
"/webhooks/paystack",
express.raw({ type: "application/json" }),
async (req, res) => {
const secret = process.env.PAYSTACK_SECRET_KEY;
const hash = crypto
.createHmac("sha512", secret)
.update(req.body)
.digest("hex");
if (hash !== req.headers["x-paystack-signature"]) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body);
// Acknowledge immediately before processing
res.sendStatus(200);
await handlePaystackEvent(event);
}
);
A few things worth noting here:
express.raw()preserves the body buffer. If you useexpress.json()globally before this route, the raw body is gone and your HMAC will never match.- Respond with 200 before processing. Paystack expects a fast acknowledgment. If your handler takes too long, Paystack will retry — potentially triggering your logic multiple times. Send the 200 first, then process asynchronously.
- Never log the raw secret. Keep it in environment variables and rotate it if it ever touches a log file.
Verifying the Signature — and Why It Matters
The x-paystack-signature header is an HMAC-SHA512 hash of the request body, signed with your Paystack secret key. Verifying it proves the request genuinely came from Paystack — not a third party attempting to spoof a payment confirmation.
Skipping this check means any actor who knows your webhook URL can send a fabricated charge.success event and unlock premium features for free. This is not a theoretical risk. It happens.
Always verify. Always.
Handling Idempotency — The Problem Nobody Warns You About
Paystack retries webhook delivery if it doesn't receive a 2xx response within a reasonable window. Even with correct handling, network hiccups can cause duplicates. Your handler must be idempotent — processing the same event twice should produce the same result as processing it once.
The standard approach is to store processed event references in your database.
Every Paystack event payload includes a unique transaction reference in event.data.reference. Before processing, check whether that reference already exists in a processed_events table (or equivalent). If it does, skip and return early. If it doesn't, insert it and proceed.
processed_events table:
- reference (unique, indexed)
- event_type
- processed_at
This one pattern eliminates the entire class of double-fulfillment bugs — duplicate subscription activations, double wallet credits, or orders shipped twice.
Structuring the Event Handler
Paystack emits many event types. Route them cleanly:
async function handlePaystackEvent(event) {
switch (event.event) {
case "charge.success":
await handleChargeSuccess(event.data);
break;
case "subscription.create":
await handleSubscriptionCreated(event.data);
break;
case "transfer.success":
await handleTransferSuccess(event.data);
break;
case "transfer.failed":
await handleTransferFailed(event.data);
break;
default:
console.log(`Unhandled event type: ${event.event}`);
}
}
Each handler function should:
- Check idempotency against the reference
- Perform the business logic (activate subscription, credit wallet, fulfill order)
- Mark the reference as processed
- Log the outcome for auditability
Wrap each handler in a try/catch and log failures with enough context to replay them manually if needed. Silent failures in payment webhooks are how SaaS companies lose revenue without knowing it.
Production Hardening Checklist
Before you ship this to production, run through these:
- HTTPS only. Paystack will not send webhooks to plain HTTP endpoints in production.
- Whitelist Paystack IPs at the infrastructure level if your cloud provider supports it — adds a second layer beyond signature verification.
- Set up a dead-letter mechanism. If an event fails processing after retries, store it in a failed-events queue for manual review. Don't let it disappear.
- Monitor webhook latency. If your handler consistently responds slowly, Paystack retries stack up. Use a job queue (BullMQ, pg-boss) to offload heavy processing off the HTTP request cycle.
- Test with Paystack's dashboard. The Paystack dashboard lets you replay webhook events — use this during staging to simulate edge cases like failed subscriptions and dispute events.
A Note on Multi-Currency and African Market Nuances
If you're building a SaaS product serving Ghana, Nigeria, Kenya, or other African markets, Paystack is likely your primary gateway. A few regional considerations matter:
- Mobile money events (MTN MoMo, Airtel Money) come through the same
charge.successevent but carry different metadata inevent.data.channel. Handle these explicitly if your fulfillment logic differs by channel. - Currency codes in the payload are ISO 4217 — always read
event.data.currencyrather than assuming NGN or GHS. - Subscription billing cycles on Paystack can lag by hours during peak periods. Design your access control logic to tolerate a grace window rather than cutting off users the instant a renewal event is delayed.
Why This Matters for Your Project
A payment integration that only handles the happy path is a liability waiting to surface. For any SaaS product handling real revenue — whether you're billing monthly subscriptions, metered usage, or one-time purchases — the webhook handler is load-bearing infrastructure. Building it with signature verification, idempotency, and proper event routing from day one means fewer support tickets, fewer billing disputes, and a backend that scales without hidden failure modes. If you're building on Paystack in West Africa or beyond, this is the foundation worth getting right.





