How to Build a Paystack Webhook Handler That Never Drops an Event

Most payment bugs are not card errors. They are webhook errors — silent failures that leave a user's order in limbo while their bank account is already debited. If you are building a SaaS product on Paystack, the difference between a reliable payment system and a support nightmare often comes down to how well you handle the five lines of code after app.post('/webhook', ...).

This guide walks through building a production-grade Paystack webhook handler in Node.js — one that verifies signatures, deduplicates events, queues work asynchronously, and survives restarts.


Why Webhooks Fail in Production

Paystack sends a webhook as an HTTP POST to your server the moment a payment event occurs. Simple enough. But here is what tutorials usually omit:

  • Retries are real. If your server does not respond with a 200 within a few seconds, Paystack retries. If your handler is slow (because it is writing to a database, sending emails, etc.), you will receive the same event multiple times.
  • Order is not guaranteed. A charge.success event can arrive before a charge.pending in edge cases.
  • Your server will restart. If an event arrives during a deployment window, it can be lost unless you acknowledge it first and process it later.
  • Signature verification is skipped more often than it should be. Any endpoint without it is an open door for forged payment confirmations.

Step 1: Verify the Signature — Every Single Time

Paystack signs every webhook payload using your secret key. Verifying this signature is non-negotiable. Here is how to do it correctly in Express:

const crypto = require('crypto');

function verifyPaystackSignature(req, res, next) {
  const secret = process.env.PAYSTACK_SECRET_KEY;
  const hash = crypto
    .createHmac('sha512', secret)
    .update(JSON.stringify(req.body))
    .digest('hex');

  if (hash !== req.headers['x-paystack-signature']) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  next();
}

// IMPORTANT: use express.json() before this middleware,
// but pass the raw body to the HMAC — not a re-serialized version.
app.post('/webhook/paystack', express.json(), verifyPaystackSignature, handleWebhook);

One critical gotcha: if you use body-parser with any transformation options or if middleware re-serializes the payload, your HMAC will not match. Always compute the hash against the raw, unmodified request body. A reliable approach is to capture the raw buffer using express.raw({ type: 'application/json' }) and parse it manually after verification.


Step 2: Acknowledge Immediately, Process Asynchronously

Your handler must return 200 OK to Paystack as fast as possible — ideally within 2–3 seconds. Any business logic (database writes, email triggers, provisioning) should happen after the response is sent.

async function handleWebhook(req, res) {
  // Acknowledge Paystack immediately
  res.sendStatus(200);

  const event = req.body;

  // Push to queue for async processing
  await eventQueue.push(event);
}

This pattern decouples acknowledgment from processing. Paystack is satisfied; your system processes at its own pace.


Step 3: Implement Idempotency With an Event Log

Because Paystack retries on failure or timeout, you will receive duplicate events. Idempotency means processing the same event twice produces the same result — no double subscriptions, no duplicate receipts.

The simplest approach is an processed_events table in your database:

ColumnType
event_idVARCHAR (unique)
event_typeVARCHAR
processed_atTIMESTAMP

Before processing any event, check this table:

async function processEvent(event) {
  const alreadyProcessed = await db.query(
    'SELECT id FROM processed_events WHERE event_id = $1',
    [event.data.id]
  );

  if (alreadyProcessed.rows.length > 0) {
    console.log(`Duplicate event skipped: ${event.data.id}`);
    return;
  }

  // Process the event
  await handlePaymentSuccess(event.data);

  // Mark as processed
  await db.query(
    'INSERT INTO processed_events (event_id, event_type, processed_at) VALUES ($1, $2, NOW())',
    [event.data.id, event.event]
  );
}

Wrap the check and insert in a database transaction with a unique constraint on event_id for race-condition safety. If two duplicate events arrive simultaneously, the database constraint ensures only one succeeds.


Step 4: Build a Simple In-Process Queue (or Use a Real One)

For lower-traffic applications, an in-memory queue using p-queue or a simple async array works fine. For production SaaS with meaningful transaction volumes, use a proper message queue — BullMQ with Redis is the standard Node.js choice.

Here is a minimal BullMQ setup:

const { Queue, Worker } = require('bullmq');
const connection = { host: 'localhost', port: 6379 };

const webhookQueue = new Queue('paystack-webhooks', { connection });

// In your webhook handler:
await webhookQueue.add('process-event', event);

// Worker (can run in a separate process):
new Worker('paystack-webhooks', async job => {
  await processEvent(job.data);
}, { connection });

This gives you automatic retries with backoff, job persistence across restarts, and a dashboard (via Bull Board) to monitor failed jobs — invaluable when debugging a payment issue at 2 AM.


Step 5: Handle the Events That Actually Matter

Not all Paystack events need the same treatment. Here is a practical routing pattern:

const eventHandlers = {
  'charge.success': handleChargeSuccess,
  'transfer.success': handleTransferSuccess,
  'transfer.failed': handleTransferFailed,
  'subscription.create': handleSubscriptionCreate,
  'invoice.payment_failed': handleInvoiceFailure,
};

async function handlePaystackEvent(event) {
  const handler = eventHandlers[event.event];
  if (handler) {
    await handler(event.data);
  } else {
    console.warn(`Unhandled event type: ${event.event}`);
  }
}

Logging unhandled event types is important — Paystack occasionally introduces new event types, and you want visibility before they affect users.


Common Pitfalls Specific to African SaaS Contexts

  • Mobile Money events behave differently. MTN MoMo and Vodafone Cash payments via Paystack sometimes take longer to confirm. Do not provision access until charge.success — not on initialization.
  • USSD payments can time out and succeed later. Build your UI to handle a "pending" state gracefully rather than marking a session as failed immediately.
  • Test mode keys in production. It happens. Use environment-level config validation on startup to fail loud if PAYSTACK_SECRET_KEY does not start with sk_live_ in your production environment.

Why This Matters for Your Project

Payment reliability is a trust signal. For SaaS teams in Ghana and across Africa building on Paystack, a dropped webhook does not just mean a delayed subscription — it can mean a user churns before they ever experience your product's value. A handler built on signature verification, idempotent processing, and async queuing is not over-engineering; it is the baseline. The patterns here scale from your first hundred transactions to your first hundred thousand without a rewrite.