Integrating Paystack Webhooks Reliably in a Node.js API

A payment is confirmed in Paystack's dashboard, but the user's subscription never activates. Your database shows no record of the transaction. Support tickets pile up. This is not a Paystack problem — it is a webhook integration problem, and it is far more common than most African SaaS teams want to admit.

Webhook endpoints are the invisible backbone of any payment flow. When they are flaky, every downstream feature — account activation, receipt emails, audit logs — breaks silently. This guide covers the three layers that separate a production-ready Paystack webhook handler from the tutorials that stop at res.sendStatus(200).


Layer 1: Signature Verification — Trust Nothing That Arrives at Your Endpoint

Paystack signs every webhook payload using HMAC-SHA512, computed from your secret key and the raw request body. Your first job is to verify that signature before touching the payload.

The critical mistake most developers make is letting a body-parsing middleware consume the raw stream before verification. Once express.json() parses the body, the raw bytes are gone — and your HMAC will never match.

Here is the correct pattern:

import express from "express";
import crypto from "crypto";

const app = express();

// Use raw body parser ONLY for the webhook route
app.post(
  "/webhooks/paystack",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-paystack-signature"];
    const secret = process.env.PAYSTACK_SECRET_KEY;

    const hash = crypto
      .createHmac("sha512", secret)
      .update(req.body) // req.body is a Buffer here
      .digest("hex");

    if (hash !== signature) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString());
    // Hand off to your event processor
    processPaystackEvent(event).catch(console.error);

    // Acknowledge immediately — Paystack will retry if you delay
    res.sendStatus(200);
  }
);

Notice the 200 is sent before processPaystackEvent resolves. Paystack expects a fast acknowledgment. If your handler takes more than a few seconds — say, because it is writing to a slow database — Paystack treats it as a failure and retries. That brings us to Layer 2.


Layer 2: Idempotent Event Handling — Prepare to Receive the Same Event Twice

Paystack retries webhook delivery when your endpoint does not respond within a timeout or returns a non-2xx status. This means your handler will occasionally receive the same event multiple times. If activating a subscription twice means charging a user twice or creating duplicate invoices, you have a serious business problem.

The fix is idempotency keyed on the event reference.

Every Paystack event carries a unique id field and a data.reference field. Store a record of processed event IDs in your database before doing any business logic. On subsequent deliveries, skip processing if the ID already exists.

Here is the pattern in pseudocode:

1. Extract event.id from the payload.
2. Query your `processed_webhook_events` table for that ID.
3. If found → return early (event already handled).
4. Begin a database transaction:
   a. Insert event.id into processed_webhook_events.
   b. Execute business logic (activate subscription, update order status, etc.).
   c. Commit transaction.
5. If the transaction fails → roll back. Do NOT insert the event ID.

The atomic insert-and-process pattern is the key detail. If you insert the event ID first and then your business logic crashes, you will never reprocess that event. Wrapping both operations in a single transaction ensures that either everything succeeds or nothing is recorded.

A simple PostgreSQL table to support this:

CREATE TABLE processed_webhook_events (
  event_id   TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  received_at TIMESTAMPTZ DEFAULT now()
);

This table also doubles as an audit trail — invaluable when a client disputes a charge.


Layer 3: Eliminating Silent Failures — Make Every Error Visible and Recoverable

Silent failures are the most expensive class of bug in payment systems. Your endpoint returns 200, Paystack is satisfied, but somewhere in your async processing chain an unhandled promise rejection swallows the error and the user's account is never updated.

Three practices close this gap:

1. Never Fire-and-Forget Without an Error Boundary

Wrap your async processor in a try/catch that writes failures to a dedicated error log or a dead-letter queue. A simple console.error in production is not enough — you need something you can alert on.

2. Build a Replay Mechanism

Store every inbound webhook payload (after signature verification) in a raw events table before processing. If your processor crashes, you can replay events from this table without waiting for Paystack to retry — which may not happen for rare event types.

3. Monitor the Gap Between Payment and Fulfillment

Set up a scheduled job that queries for payments confirmed in Paystack but with no corresponding fulfillment record in your database. A mismatch that persists for more than five minutes should trigger an alert. This catches failures that slip past all other defences.


Events Worth Handling Beyond charge.success

Most tutorials wire up charge.success and stop. A production SaaS needs to handle more:

  • charge.failed — Surface this to the user immediately; do not leave them wondering.
  • subscription.disable — Downgrade access, do not just log it.
  • invoice.payment_failed — Trigger a dunning sequence, not silence.
  • refund.processed — Reverse fulfillment where applicable and update your accounting records.

Each of these has real revenue or compliance implications. Ignoring them is a choice to lose money slowly.


Why This Matters for Your Project

Payment infrastructure is the one place where a subtle bug translates directly into lost revenue and eroded customer trust. For SaaS teams building in Ghana and across Africa — where Paystack is often the primary payment rail — a robust webhook layer is not an optimization; it is table stakes. Signature verification protects you from spoofed requests, idempotency protects you from duplicate processing, and active failure monitoring protects you from the bugs you do not know exist yet. Getting these three layers right means your product can scale its transaction volume without scaling its support burden.