Charging a card with Paystack is ten lines of code. Making sure your SaaS app correctly credits an account — exactly once, even under retries, network blips, or duplicate webhook deliveries — is where most production systems quietly break.
This guide skips the hello-world charge flow and focuses on the part that actually matters for a live product: receiving, verifying, and safely processing Paystack webhook events in a Node.js backend.
Why Webhooks, Not Just the Charge Response
When a user completes a payment, your frontend gets a reference string. You verify it server-side with Paystack's /transaction/verify endpoint, and life seems fine. But that pattern has a critical flaw: it ties payment confirmation to a user's browser session. If the tab closes, the network drops, or your verification call times out, your backend never learns the payment succeeded.
Webhooks flip this model. Paystack calls your server directly when a payment event occurs — independent of the user's device. For any SaaS app handling subscriptions, one-time purchases, or wallet top-ups, webhooks are not optional. They are the authoritative signal.
Setting Up Your Webhook Endpoint
Start by registering a publicly accessible URL in your Paystack dashboard under Settings → API Keys & Webhooks. During development, use a tool like ngrok to tunnel to localhost.
In your Express app, the webhook route must parse the raw request body — not the JSON-parsed version. Paystack's HMAC signature is computed against the raw bytes, so using express.json() before signature verification will cause every check to fail.
import express from "express";
import crypto from "crypto";
const router = express.Router();
// Use express.raw() for this route ONLY
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
.digest("hex");
if (hash !== signature) {
return res.status(401).send("Invalid signature");
}
// 2. Parse and handle
const event = JSON.parse(req.body.toString());
await handlePaystackEvent(event);
// 3. Respond immediately — Paystack expects 200 fast
res.sendStatus(200);
}
);
Two non-obvious details here: always respond with 200 quickly (before your business logic finishes if needed — offload to a queue), and never return a 4xx unless the payload is genuinely malformed. Paystack will retry on non-200 responses, which is the right behavior — but it means your handler must be safe to run multiple times.
Verifying the Signature — 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 you from spoofed events. Anyone who knows your webhook URL could POST a fake charge.success event and trick your app into crediting an account for a payment that never happened.
Use crypto.timingSafeEqual for the comparison in production to prevent timing attacks:
const a = Buffer.from(hash);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("Unauthorized");
}
It is a small change, but it closes a real class of vulnerability.
Building an Idempotent Event Handler
Paystack can — and will — deliver the same webhook more than once. Network timeouts, retries, and infrastructure hiccups all cause duplicate deliveries. If your handler credits a user's account or activates a subscription, running it twice is a serious bug.
The fix is idempotency: track which events you have already processed and skip duplicates.
Every Paystack event payload includes a unique data.id (the transaction ID) and an event type string. Store a record of processed event IDs in your database before doing any business logic.
Idempotency Pattern with PostgreSQL / Prisma
async function handlePaystackEvent(event) {
if (event.event !== "charge.success") return;
const txId = String(event.data.id);
// Atomic upsert — only process if not already seen
const result = await prisma.processedWebhook.upsert({
where: { paystackTxId: txId },
update: {},
create: { paystackTxId: txId, processedAt: new Date() },
});
// Prisma returns the existing record on conflict — check createdAt
if (result.processedAt < new Date(Date.now() - 1000)) {
// Already processed; skip
return;
}
// Safe to proceed with business logic
await creditUserAccount(event.data);
}
A cleaner approach on PostgreSQL is to use an INSERT ... ON CONFLICT DO NOTHING and check rowCount. The principle is the same: use the database as a coordination layer, not application memory. Application memory is not safe across pod restarts or horizontal scaling.
Handling the Key Event Types
Paystack emits dozens of event types. For a typical SaaS app, focus on these:
charge.success— A one-time payment or first subscription charge completed. Activate the feature, credit the wallet, or provision the account.subscription.create— A subscription plan was created for a customer. Store the subscription code for future management.invoice.payment_failed— A recurring charge failed. Trigger a grace period and notify the user.invoice.update— A subscription invoice changed status. Use this to sync subscription state.transfer.success/transfer.failed— Critical if your app initiates payouts to vendors or drivers (marketplace model).
Route each event type to a dedicated handler function. A large switch or a handler map keeps things readable and testable.
Offloading to a Queue for Reliability
Responding to Paystack within a few seconds is important — their retry window has limits. But crediting accounts, sending emails, and updating multiple database tables can take longer than that window allows.
The production pattern is to acknowledge the webhook immediately and push the raw event onto a job queue (BullMQ, AWS SQS, or even a simple Postgres-backed queue). A separate worker picks up the job, processes it with full idempotency checks, and retries on failure with exponential backoff. This decouples Paystack's delivery from your internal processing latency.
Testing Without Going Live
Paystack's dashboard lets you send test webhook payloads to your registered URL. Combine this with ngrok during development and you can iterate on your handler logic against real payload shapes without touching production funds. Keep a fixtures/ folder with sample event payloads for unit testing your handler functions in isolation.
Why This Matters for Your Project
Payment reliability is a direct proxy for user trust. An app that occasionally double-bills customers, misses successful payments, or fails silently under load will churn users fast — especially in markets like Ghana and Nigeria where fintech trust is hard-won and easily lost. Getting the webhook layer right from the start means your SaaS can scale confidently, process thousands of transactions per day, and maintain a clean audit trail without firefighting production incidents at 2 AM.





