Bonaventure OgetoBy Bonaventure Ogeto|

Handle Paystack Webhooks in Node.js and Express

To handle Paystack webhooks in Express, create a POST route that reads the raw request body, computes an HMAC SHA-512 hash using your secret key, and compares it to the x-paystack-signature header. If the signatures match, parse the event and process it. Return a 200 status immediately.

How Paystack Webhooks Work

When a payment succeeds (or fails, or a subscription renews), Paystack sends a POST request to the webhook URL you configured in your dashboard. The request body is JSON containing the event type and the full transaction data.

Paystack signs every webhook with an HMAC SHA-512 hash of the request body, using your secret key. The hash is sent in the x-paystack-signature header. You verify this signature to confirm the webhook actually came from Paystack and was not tampered with.

To set your webhook URL: go to your Paystack Dashboard > Settings > API Keys & Webhooks > Webhook URL.

Capturing the Raw Body

Express normally parses JSON request bodies automatically with express.json(). But signature verification needs the raw bytes, not the parsed object. If Express parses the body first, rebuilding it as a string may produce different whitespace or key ordering, breaking the signature check.

The solution: capture the raw body alongside the parsed body.

var express = require('express');
var crypto = require('crypto');
var app = express();

// For webhook route: capture raw body
app.use('/api/webhooks/paystack', express.json({
  verify: function(req, res, buf) {
    req.rawBody = buf;
  }
}));

// For all other routes: normal JSON parsing
app.use(express.json());

The verify option in express.json() runs before parsing. We store the raw buffer on the request object so we can hash it later. This middleware must be registered before the default express.json() to avoid conflicts.

Verify the Webhook Signature

Create the webhook route and verify the signature before processing:

var PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;

app.post('/api/webhooks/paystack', function(req, res) {
  // 1. Get the signature from the header
  var signature = req.headers['x-paystack-signature'];

  if (!signature) {
    return res.status(400).send('Missing signature');
  }

  // 2. Compute the expected hash
  var hash = crypto
    .createHmac('sha512', PAYSTACK_SECRET)
    .update(req.rawBody)
    .digest('hex');

  // 3. Compare
  if (hash !== signature) {
    console.error('Webhook signature mismatch');
    return res.status(400).send('Invalid signature');
  }

  // 4. Signature valid. Process the event.
  var event = req.body;

  console.log('Received event:', event.event);

  // Return 200 immediately
  res.sendStatus(200);

  // Process asynchronously (after response)
  handlePaystackEvent(event).catch(function(err) {
    console.error('Error processing webhook:', err);
  });
});

Returning 200 before processing is important. If your processing takes more than a few seconds and Paystack does not get a response, it will retry the webhook. Sending 200 first tells Paystack "I received it" while you process in the background.

Process Webhook Events

Handle the events relevant to your business:

async function handlePaystackEvent(event) {
  var eventType = event.event;
  var data = event.data;

  if (eventType === 'charge.success') {
    await handleSuccessfulPayment(data);
  } else if (eventType === 'charge.failed') {
    console.log('Payment failed for reference:', data.reference);
  } else if (eventType === 'subscription.create') {
    await handleNewSubscription(data);
  } else if (eventType === 'invoice.payment_failed') {
    await handleFailedRenewal(data);
  } else if (eventType === 'transfer.success') {
    await handleSuccessfulTransfer(data);
  } else {
    console.log('Unhandled event type:', eventType);
  }
}

async function handleSuccessfulPayment(data) {
  var reference = data.reference;
  var amount = data.amount;
  var currency = data.currency;

  // Idempotent fulfillment
  var result = await db.orders.updateOne(
    { reference: reference, paid: false },
    { $set: { paid: true, paidAt: new Date(), paystackData: data } }
  );

  if (result.modifiedCount === 0) {
    console.log('Order already fulfilled or not found:', reference);
    return;
  }

  // Deliver value
  var order = await db.orders.findOne({ reference: reference });
  await sendConfirmationEmail(order.email, order);
}

The key events for most applications:

  • charge.success - one-time payment completed
  • subscription.create - new subscription started
  • invoice.payment_failed - subscription renewal failed
  • transfer.success / transfer.failed - payout completed or failed
  • refund.processed - refund completed

Testing Webhooks Locally

Paystack cannot reach your localhost. You have two options for local testing:

Option 1: Use a tunnel. Tools like ngrok or localtunnel expose your local server to the internet:

npx ngrok http 3000

Copy the HTTPS URL (something like https://abc123.ngrok.io) and set it as your webhook URL in the Paystack dashboard: https://abc123.ngrok.io/api/webhooks/paystack.

Option 2: Simulate locally. Construct a webhook payload and sign it yourself:

var crypto = require('crypto');

var payload = JSON.stringify({
  event: 'charge.success',
  data: {
    reference: 'test_ref_123',
    amount: 500000,
    currency: 'NGN',
    status: 'success',
    customer: { email: 'test@example.com' },
  },
});

var hash = crypto
  .createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
  .update(payload)
  .digest('hex');

// Use curl or fetch to POST to your local server
// with header x-paystack-signature: 
// and body: 

Production Checklist

  1. HTTPS only. Your webhook URL must use HTTPS. Paystack will not send webhooks to HTTP URLs in production.
  2. Respond quickly. Return 200 within 5 seconds. Queue heavy processing for background workers.
  3. Idempotent handlers. Paystack may send the same event more than once. Your handler must produce the same result regardless of how many times it runs.
  4. Log everything. Log the event type and reference for every webhook. This helps debug missing payments.
  5. Monitor failures. Check the Paystack dashboard for failed webhook deliveries. If your endpoint returns non-200 responses, Paystack shows them in the webhook logs.

Ship Payments Faster

Key Takeaways

  • Webhooks are the reliable payment notification channel. Redirect callbacks can fail if the customer closes their browser.
  • You must verify the x-paystack-signature header using HMAC SHA-512 with your secret key. Never process unverified webhooks.
  • The raw body is required for signature verification. If Express parses the JSON before you hash it, the signature will not match.
  • Return 200 immediately. Do heavy processing asynchronously. If Paystack does not get a 200 within a few seconds, it retries.
  • Paystack retries failed webhooks with increasing intervals. Make your handler idempotent so duplicate events cause no harm.
  • The most important event for payments is charge.success. For subscriptions, also handle subscription.create and invoice.payment_failed.

Frequently Asked Questions

Why does my webhook signature verification fail?
The most common cause is Express parsing the JSON body before you hash it. The raw bytes must be hashed, not the re-serialized object. Use the verify option in express.json() to capture the raw buffer. Also make sure you are using the correct secret key (test vs live).
How many times does Paystack retry a failed webhook?
Paystack retries with increasing intervals. If your endpoint keeps returning non-200 responses, Paystack eventually stops trying. Check the webhook logs in your Paystack dashboard to see failed deliveries.
Can I use the same webhook URL for test and live modes?
Yes, but you need to use the correct secret key for signature verification. Test webhooks are signed with your test secret key, and live webhooks with your live secret key. If you serve both from the same endpoint, check the key mode based on the incoming data.
Do I still need to verify transactions if I use webhooks?
Yes. The webhook confirms the event happened, but you should still call the verify endpoint to get the authoritative transaction state. This provides defense in depth. The webhook tells you what happened; verification confirms it.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon