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 200 before 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 reference column. 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:

EventWhat to do
charge.successActivate subscription, send receipt
subscription.createRecord subscription ID and plan
subscription.disableDowngrade user, notify them
invoice.payment_failedTrigger dunning flow, alert user
refund.processedReverse 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:

  1. Your signature check passes in test mode with the test secret key.
  2. Processing the same event twice does not create duplicate records.
  3. 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.