How to Integrate Paystack Webhooks Into a Node.js Backend
Charging a card is the easy part. What happens after the charge — a delayed bank confirmation, a failed transfer, a disputed refund — is where most payment integrations quietly break. If your Node.js backend only handles the redirect after checkout, you are trusting a browser tab with your revenue logic. That is a bet you will eventually lose.
Paystack webhooks solve this by pushing payment events directly to your server, independent of what the user's browser does. This guide walks through a production-ready integration: signature verification, event routing, and idempotency guards that prevent double-processing — patterns refined on real African payment flows where mobile money, bank transfers, and card transactions all behave differently.
Why Webhooks, Not Just Callback URLs
Paystack provides a callback_url for redirecting users post-payment. Many teams treat this redirect as confirmation. The problem: a user can close the tab, lose network, or be redirected by a proxy before your callback fires. Meanwhile, Paystack has already captured the funds.
Webhooks bypass the browser entirely. Paystack's servers call your server over HTTPS whenever a payment event occurs — charge.success, transfer.failed, refund.processed, and more. Your backend processes the event in the background, with no dependency on the user's session.
Rule of thumb: Use the callback URL to improve UX (show a success screen). Use webhooks to update your database, provision access, and trigger downstream services.
Setting Up the Webhook Endpoint
Register your webhook URL in the Paystack Dashboard under Settings → API Keys & Webhooks. It must be publicly accessible and respond with HTTP 200 within 30 seconds — Paystack will retry failed deliveries up to five times with exponential backoff.
In your Node.js app using Express:
import express from 'express';
import crypto from 'crypto';
const router = express.Router();
// IMPORTANT: use raw body for signature verification
router.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
async (req, res) => {
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
// 1. Verify signature
const hash = crypto
.createHmac('sha512', secret)
.update(req.body) // raw Buffer, not parsed JSON
.digest('hex');
if (hash !== signature) {
return res.status(401).send('Invalid signature');
}
// 2. Parse and route
const event = JSON.parse(req.body);
await handlePaystackEvent(event);
// 3. Acknowledge immediately
res.sendStatus(200);
}
);
The single most common mistake: running the body through express.json() before hashing it. JSON serialisation is not byte-stable — whitespace differences will corrupt the HMAC and cause every signature check to fail. Always compute the hash against the raw buffer.
Verifying Signatures — and Why It Matters
The x-paystack-signature header is an HMAC-SHA512 digest of the raw request body, signed with your secret key. Verifying it protects against two real threats:
- Spoofed events — anyone who knows your webhook URL can POST fake
charge.successevents and provision themselves premium accounts for free. - Replay attacks — a valid past request being resent to trigger duplicate processing.
Always reject requests where the signature does not match. No exceptions, even in development.
Routing Events Cleanly
Payment flows generate many event types. Build a dispatcher that keeps handlers isolated:
async function handlePaystackEvent(event) {
const handlers = {
'charge.success': handleChargeSuccess,
'charge.failed': handleChargeFailed,
'transfer.success': handleTransferSuccess,
'transfer.failed': handleTransferFailed,
'refund.processed': handleRefundProcessed,
};
const handler = handlers[event.event];
if (handler) {
await handler(event.data);
} else {
console.warn(`Unhandled Paystack event: ${event.event}`);
}
}
This pattern makes it trivial to add new event types without touching existing logic, and keeps each handler unit-testable in isolation.
Idempotency: The Guard You Cannot Skip
Paystack retries webhook delivery when your endpoint does not respond with 200 in time — network hiccups, cold-start delays, or a deploy in flight can all trigger a retry. Without a guard, the same charge.success event could be processed twice, crediting a user's account multiple times or dispatching duplicate fulfilment emails.
Every event payload includes a unique reference. Persist it before doing any business logic:
async function handleChargeSuccess(data) {
const ref = data.reference;
// Idempotency check
const already = await db.payments.findOne({ reference: ref });
if (already) return; // already processed — safe to ignore
// Process the payment
await db.payments.create({
reference: ref,
amount: data.amount / 100, // Paystack amounts are in kobo/pesewas
currency: data.currency,
status: 'success',
customerEmail: data.customer.email,
paidAt: new Date(data.paid_at),
});
await provisionUserAccess(data.customer.email);
await sendConfirmationEmail(data.customer.email, data.amount / 100);
}
For high-throughput systems, wrap the idempotency check and insert in a database transaction or use a distributed lock (Redis SET NX) to prevent race conditions if two webhook deliveries arrive simultaneously.
Handling African Payment Specifics
Payment flows in West Africa involve nuances that generic tutorials ignore:
- Mobile money delays: MTN MoMo and Vodafone Cash payments can have confirmation delays of several minutes. Never assume immediate settlement — always rely on the
charge.successwebhook rather than the initial API response. - Bank transfer holds: Paystack's Pay With Transfer product sends a
charge.successonly after the bank confirms receipt. Your UI should show a "pending" state until the webhook fires. - Currency precision: Amounts arrive in the smallest currency unit — kobo for NGN, pesewas for GHS. Always divide by 100 before displaying or storing in your primary currency field.
- Refund events:
refund.processedandrefund.failedarrive separately from the original charge. Model refunds as child records linked to the original payment reference, not as mutations of the original record.
Testing Without Going Live
Use the Paystack Dashboard's Webhook Test tool to replay events against a local tunnel (ngrok works well). Pair this with a separate test secret key so your idempotency store stays clean. Write integration tests that POST raw payloads with a correctly computed test signature — this catches the raw-body/JSON parsing bug before it reaches production.
Why This Matters for Your Project
A payment integration that only handles the happy path in the browser is a liability waiting to surface at the worst possible moment — a high-traffic sale, a month-end reconciliation, or a disputed charge from a big client. Implementing signature verification, clean event routing, and idempotency guards transforms your Paystack integration from a prototype into infrastructure you can scale and audit with confidence. Whether you are building a SaaS subscription platform, an e-commerce checkout, or a fintech product serving Ghanaian and Nigerian users, this is the foundation that keeps your revenue logic trustworthy.





