How to Build a Paystack Webhook Handler That Never Misses a Payment

Your customer just paid. Paystack fired the charge.success event. Your server was mid-deploy and returned a 502. Paystack retried. Your server processed it twice. The user got credited twice — or worse, your billing logic broke and they got credited zero times.

This is not a hypothetical. It is a Tuesday.

Most tutorials walk you through verifying the Paystack signature and calling it a day. That is table stakes. What actually protects your revenue and your users is everything that happens after the signature check: idempotent processing, deduplication, and graceful retry handling. This guide covers all of it.


Step 1: Verify the Signature — Correctly

Before anything else, confirm the event is genuinely from Paystack. They send an x-paystack-signature header containing an HMAC SHA-512 hash of the raw request body, signed with your secret key.

const crypto = require("crypto");

function verifyPaystackSignature(rawBody, signature, secretKey) {
  const hash = crypto
    .createHmac("sha512", secretKey)
    .update(rawBody)
    .digest("hex");
  return hash === signature;
}

Two common mistakes here: parsing the body with express.json() before this check (which destroys the raw buffer), and trimming whitespace that alters the hash. Always pass the raw body buffer to your HMAC function. Set up a dedicated raw body parser for your webhook route only.


Step 2: Respond with 200 Immediately

Paystack marks a webhook delivery as failed if it does not receive a 200 OK within a few seconds. If your billing logic involves database writes, sending emails, or calling third-party APIs, all of that takes time — time you do not have.

The pattern is simple: acknowledge first, process asynchronously.

Return 200 as soon as signature verification passes, then push the event payload onto a queue (Redis, BullMQ, SQS — your choice). Your worker processes the actual billing logic in the background. This decouples delivery reliability from processing speed.


Step 3: Store Every Incoming Event

Before you process anything, persist the raw event. This gives you an audit trail, a replay mechanism, and the foundation for deduplication.

A minimal webhook_events table looks like this:

ColumnTypeNotes
idUUIDPrimary key
paystack_event_idVARCHARPaystack's unique event ID
event_typeVARCHARe.g. charge.success
payloadJSONBFull raw payload
statusENUMpending, processed, failed
created_atTIMESTAMP
processed_atTIMESTAMPNullable

The paystack_event_id field is your deduplication key. Every Paystack event object contains an id field — use it.


Step 4: Implement Idempotency at the Database Level

Idempotency means running the same operation twice produces the same result as running it once. For SaaS billing, this is non-negotiable.

The safest implementation uses a database unique constraint combined with an upsert:

ALTER TABLE webhook_events
  ADD CONSTRAINT uq_paystack_event_id UNIQUE (paystack_event_id);

When your worker picks up an event, it attempts to insert a row with that paystack_event_id. If the insert fails due to a unique violation, the event was already processed — skip it silently and return success. If the insert succeeds, proceed with billing logic and mark the row as processed.

This is bulletproof against Paystack retries, network hiccups, and accidental double-processing from your own queue.


Step 5: Handle the Events That Actually Matter

For most SaaS products, you care about a handful of events:

  • charge.success — A one-time payment completed. Activate the user's plan or credit their account.
  • subscription.create — A recurring subscription was initiated.
  • invoice.payment_failed — A subscription renewal failed. Trigger your dunning flow.
  • subscription.disable — The subscription was cancelled. Downgrade the user's access.
  • transfer.success / transfer.failed — Relevant if you pay out to vendors or split payments.

Each event type should route to a dedicated handler function. A single sprawling if/else block is a maintenance trap. Use a registry pattern:

const eventHandlers = {
  "charge.success": handleChargeSuccess,
  "subscription.create": handleSubscriptionCreate,
  "invoice.payment_failed": handleInvoicePaymentFailed,
  "subscription.disable": handleSubscriptionDisable,
};

async function processEvent(event) {
  const handler = eventHandlers[event.event];
  if (!handler) return; // unknown event type — log and ignore
  await handler(event.data);
}

Step 6: Build a Retry and Dead-Letter Strategy

Even with a queue, processing can fail — your database might be temporarily unreachable, or a downstream API might time out. Design your worker with exponential backoff retries (3–5 attempts) before moving an event to a dead-letter queue.

Dead-letter events should alert your engineering team immediately. A payment event stuck in dead-letter means a user either got charged without access, or got access without being charged. Both are bad. Instrument this with whatever monitoring tool you use — Sentry, Datadog, or even a Slack webhook.

Never silently discard a failed event.


Step 7: Test with Paystack's Event Simulator

Paystack's dashboard includes a webhook event simulator under Settings > API Keys & Webhooks. Use it to fire test events against your local environment (exposed via ngrok or a similar tunnel) and verify your deduplication logic by sending the same event ID twice. Your second run should no-op cleanly.

Write integration tests that simulate duplicate paystack_event_id values and assert that your billing logic runs exactly once.


Common Pitfalls to Avoid

  • Trusting the payload without re-fetching — For high-stakes events, verify the transaction status by calling Paystack's GET /transaction/verify/:reference endpoint rather than trusting the webhook payload alone.
  • Processing synchronously in the HTTP handler — Already covered, but worth repeating. Always queue.
  • Not logging unhandled event types — You want visibility into events Paystack sends that your system ignores, especially as Paystack adds new event types.
  • Rotating your secret key without updating the handler — Signature verification will silently fail for every event. Monitor your signature failure rate as a metric.

Why This Matters for Your Project

Billing is the heartbeat of any SaaS product. A webhook handler that drops events or double-processes them does not just cause bugs — it erodes user trust and complicates reconciliation. Whether you are building a subscription platform, a marketplace, or a usage-based billing system, the architecture described here — immediate acknowledgment, persistent event storage, database-level idempotency, and a dead-letter strategy — is the minimum viable reliability bar. Getting it right from day one is significantly cheaper than retrofitting it after your first billing incident in production.