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.
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.
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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 completedsubscription.create - new subscription startedinvoice.payment_failed - subscription renewal failedtransfer.success / transfer.failed - payout completed or failedrefund.processed - refund completedPaystack 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:
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course