Integrating Paystack Webhooks Into Your SaaS: A Bulletproof Guide
You charged the customer. Paystack confirmed the payment. But your database still shows them on a free plan. Sound familiar?
This is the failure mode that haunts SaaS products built on shaky webhook integrations. The Paystack API call worked — the money moved — but your backend never reliably heard about it. The result is a support ticket, a frustrated user, and a founder manually toggling subscription flags at 11 PM.
This guide fixes that. We will walk through receiving, verifying, and idempotently processing Paystack webhook events in a Node.js backend, with enough rigour that you can sleep soundly after every deployment.
Why Webhooks, Not Just API Polling
After a payment, the naive approach is to poll Paystack's GET /transaction/verify/:reference endpoint and call it a day. This works in a demo. In production, it breaks in three predictable ways:
- Race conditions — your verify call fires before Paystack finishes settling the transaction.
- Missed events — if your server is briefly down during polling, you lose the window.
- Scalability cost — polling N subscriptions per minute burns API quota and adds latency.
Webhooks flip the model: Paystack calls you the moment something meaningful happens. Your job is to receive that call correctly, every time.
Step 1: Expose a Dedicated Webhook Endpoint
Create a route that exists solely for Paystack events. Do not reuse your general API routes. Paystack will POST a JSON body to this URL whenever an event fires — charge.success, subscription.create, invoice.payment_failed, and others.
// routes/webhook.js (Express)
const express = require("express");
const crypto = require("crypto");
const router = express.Router();
// CRITICAL: use express.raw() here, NOT express.json()
// You need the raw buffer to verify the signature
router.post(
"/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 after verification
const event = JSON.parse(req.body);
// 3. Acknowledge immediately
res.status(200).send("OK");
// 4. Process asynchronously
await handleWebhookEvent(event);
}
);
Three things in that snippet deserve your attention:
express.raw()is non-negotiable. If Express parses the body to JSON first, the raw buffer changes and your HMAC will never match.- Respond
200before processing. Paystack expects a fast acknowledgement. If you do heavy work before responding, you risk timeouts — and Paystack will retry, causing duplicate processing. - Never skip signature verification. A public endpoint without verification is an open door for spoofed payment events.
Step 2: Verify the Signature Correctly
Paystack signs every webhook payload using your secret key and HMAC-SHA512. The signature arrives in the x-paystack-signature header.
The only acceptable outcome: if the computed hash does not match the header value exactly, drop the request with a 401. No logging of the payload, no partial processing. Treat it as an attack.
One common mistake is pulling the live secret key in a test environment or vice versa. Store both keys in your environment config and load the correct one based on NODE_ENV. A mismatch here will cause every webhook to fail silently.
Step 3: Idempotent Event Processing
Paystack will retry failed webhooks — up to five times over several hours. That means your handler must be idempotent: processing the same event twice must produce the same result as processing it once.
The pattern is straightforward: track processed event references in your database.
async function handleWebhookEvent(event) {
if (event.event === "charge.success") {
const { reference, amount, customer } = event.data;
// Check if already processed
const alreadyProcessed = await db.webhookEvents.findOne({ reference });
if (alreadyProcessed) return; // safe no-op
// Begin atomic operation
await db.transaction(async (trx) => {
await trx.webhookEvents.insert({ reference, processedAt: new Date() });
await trx.subscriptions.activate({ email: customer.email, amount });
});
}
}
Key design decisions here:
- Store the reference before doing anything else. If you activate the subscription first and then the insert fails, a retry will double-activate.
- Use a database transaction. The idempotency record and the business logic change must succeed or fail together.
- Index the
referencecolumn. These lookups happen on every inbound webhook, so a missing index will hurt you under load.
Step 4: Handle the Full Event Taxonomy
charge.success is the entry point, but a production SaaS integration needs to handle a broader set of events:
| Event | What to do |
|---|---|
charge.success | Activate subscription, send receipt |
subscription.create | Record subscription ID and plan |
subscription.disable | Downgrade user, notify them |
invoice.payment_failed | Trigger dunning flow, alert user |
refund.processed | Reverse credits, update billing records |
Build a dispatcher — a switch or a map of event handlers — so each event type has a single, testable function responsible for it. Avoid one giant if-else chain that becomes unmaintainable as you add plans and features.
Step 5: Observability and Alerting
A webhook handler that fails silently is as bad as no handler at all. Instrument yours:
- Log every inbound event with its reference and event type before processing.
- Log the outcome — success, duplicate skip, or error — with enough context to replay manually.
- Alert on error rates. If more than a handful of webhook events are erroring per hour, someone should know before a user raises a ticket.
- Store raw payloads for a rolling 30-day window. When a customer disputes a charge, you want the original event on record.
Tools like Sentry, Datadog, or even a simple Slack alert via a webhook (yes, a webhook for your webhook errors) are all valid here.
Step 6: Test Before You Go Live
Paystack provides a test mode with its own secret key. Use it. Replay events from the Paystack dashboard and verify that:
- Your signature check passes in test mode with the test secret key.
- Processing the same event twice does not create duplicate records.
- Your endpoint responds within two seconds under normal load.
Write integration tests that post a mock payload with a valid HMAC signature. If your CI pipeline never exercises the webhook route, you will discover bugs in production.
Why This Matters for Your Project
Payment infrastructure is the one part of a SaaS where silent failures cost real money and real trust. Whether you are building your first subscription product or scaling to thousands of active plans, a webhook handler built with signature verification, idempotency, and proper observability is not an optimisation — it is the baseline. Get it right from day one and every future billing feature you add will stand on solid ground.





