Use BullMQ to queue Paystack webhook work by having your Express endpoint verify the signature, add the payload to a BullMQ queue in Redis, and return 200 immediately. A separate worker process picks up jobs from the queue and runs your business logic (updating orders, sending emails, crediting wallets). If the worker fails, BullMQ retries the job automatically with exponential backoff.
When Paystack sends a webhook to your server, it waits for a response. If your endpoint takes longer than a few seconds to reply, Paystack considers the delivery failed and schedules a retry. If your handler is doing real work (querying databases, sending emails, calling third-party APIs), you are racing against that timeout on every single event.
The fix is simple in concept: receive the webhook, drop the payload into a queue, and return 200 immediately. A background worker picks up the job and does the actual processing at its own pace. This pattern has three major benefits:
BullMQ is the most popular job queue library in the Node.js ecosystem. It is built on top of Redis, it handles retries and backoff out of the box, and it has a battle-tested track record in production payment systems. If your Paystack integration runs on Node.js, BullMQ is the natural choice.
BullMQ requires a running Redis instance. Redis is the actual storage layer where jobs live while they wait to be processed. You have a few options:
sudo apt install redis-server. On macOS: brew install redis.Install BullMQ and its Redis client in your Node.js project:
npm install bullmq ioredis
Create a shared Redis connection configuration that both your webhook handler and your worker will use:
// config/redis.js
const IORedis = require('ioredis');
const connection = new IORedis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: null,
});
module.exports = { connection };
The maxRetriesPerRequest: null setting is required by BullMQ. Without it, ioredis will timeout after a few retries and BullMQ will throw errors when Redis is temporarily slow.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Your webhook endpoint has exactly two responsibilities: verify the Paystack signature and add the payload to the queue. Nothing else should happen here. No database queries, no email sends, no API calls to other services.
// routes/webhook.js
const express = require('express');
const crypto = require('crypto');
const { Queue } = require('bullmq');
const { connection } = require('../config/redis');
const router = express.Router();
const paystackQueue = new Queue('paystack-webhooks', { connection });
router.post(
'/paystack/webhook',
express.raw({ type: 'application/json' }),
async (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)
.digest('hex');
if (hash !== signature) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString());
await paystackQueue.add(payload.event, {
event: payload.event,
data: payload.data,
receivedAt: new Date().toISOString(),
});
res.sendStatus(200);
}
);
module.exports = router;
A few things to notice about this code:
express.raw() instead of express.json() because we need the raw request body to verify the HMAC signature. If Express parses the JSON first, the serialized output might differ from the original payload, and your signature check will fail. See The Raw Body Problem for details.payload.event (like "charge.success" or "transfer.failed") as the job name. This makes it easy to filter and monitor specific event types in the BullMQ dashboard.receivedAt to the job data so we can track how long jobs sit in the queue before processing.The worker is a separate Node.js process that listens to the queue and processes jobs as they arrive. This runs independently from your Express server. In production, you would start it as its own service (a separate container, a separate PM2 process, or a separate systemd unit).
// workers/paystack-worker.js
const { Worker } = require('bullmq');
const { connection } = require('../config/redis');
async function handleChargeSuccess(data) {
const reference = data.reference;
const amount = data.amount / 100; // Convert from kobo to naira
const customerEmail = data.customer.email;
// 1. Check if this transaction was already processed (idempotency)
// const existing = await db.orders.findByReference(reference);
// if (existing && existing.status === 'paid') return;
// 2. Update your database
// await db.orders.updateStatus(reference, 'paid');
// 3. Send confirmation email
// await emailService.sendReceipt(customerEmail, amount);
console.log('Processed charge.success for ' + reference);
}
async function handleTransferSuccess(data) {
console.log('Processed transfer.success for ' + data.reference);
// Handle transfer completion
}
const worker = new Worker(
'paystack-webhooks',
async (job) => {
console.log('Processing job ' + job.id + ': ' + job.name);
switch (job.name) {
case 'charge.success':
await handleChargeSuccess(job.data.data);
break;
case 'transfer.success':
await handleTransferSuccess(job.data.data);
break;
case 'transfer.failed':
case 'transfer.reversed':
// Handle transfer failures
break;
default:
console.log('Unhandled event type: ' + job.name);
}
},
{
connection,
concurrency: 5,
}
);
worker.on('completed', (job) => {
console.log('Job ' + job.id + ' completed');
});
worker.on('failed', (job, err) => {
console.error('Job ' + job.id + ' failed: ' + err.message);
});
console.log('Paystack webhook worker started');
The concurrency: 5 setting means the worker can process up to five jobs simultaneously. For most Paystack integrations, a concurrency of 5 to 10 is a good starting point. If your processing involves heavy database writes or external API calls, start lower and increase as you understand your throughput.
The switch statement on job.name routes each event type to its handler function. This keeps the worker organized as you add support for more event types. Each handler should be idempotent, meaning processing the same event twice should not cause problems. See Idempotent Webhook Handlers for the full pattern.
One of BullMQ's biggest strengths is built-in retry logic. If your worker throws an error while processing a job, BullMQ can automatically retry it after a delay. This handles transient failures like database connection drops, temporary network issues, or rate limits from downstream APIs.
Configure retries when you add jobs to the queue:
await paystackQueue.add(
payload.event,
{
event: payload.event,
data: payload.data,
receivedAt: new Date().toISOString(),
},
{
attempts: 5,
backoff: {
type: 'exponential',
delay: 3000, // First retry after 3 seconds
},
removeOnComplete: { count: 1000 },
removeOnFail: false,
}
);
With this configuration:
The removeOnComplete: { count: 1000 } setting keeps only the last 1,000 completed jobs in Redis. Without this, completed jobs accumulate forever and eat up Redis memory. The removeOnFail: false setting keeps failed jobs around so you can inspect them and replay them later.
For payment-critical work, five attempts with exponential backoff is usually enough. If a job fails five times, it is probably not a transient issue and needs manual investigation. That is where dead-letter queues come in.
When a job exhausts all its retry attempts, BullMQ marks it as "failed." By default, failed jobs sit in the queue's failed set. But if you want to actively handle terminal failures (alert your team, trigger an investigation, or queue them for manual replay), you can set up a dead-letter pattern.
BullMQ does not have a built-in dead-letter queue in the traditional sense, but you can implement the pattern using the worker's "failed" event:
const deadLetterQueue = new Queue('paystack-dead-letters', { connection });
worker.on('failed', async (job, err) => {
if (job.attemptsMade >= job.opts.attempts) {
// Job has exhausted all retries, move to dead-letter queue
await deadLetterQueue.add('dead-' + job.name, {
originalJob: job.data,
error: err.message,
failedAt: new Date().toISOString(),
attempts: job.attemptsMade,
});
// Alert your team
console.error(
'ALERT: Paystack webhook job permanently failed after '
+ job.attemptsMade + ' attempts. Event: '
+ job.name + '. Error: ' + err.message
);
// In production, send a Slack notification or PagerDuty alert
}
});
Monitor the dead-letter queue daily. Any job that lands there represents a payment event that your system did not process. For a Paystack integration, that could mean a customer paid but did not get their product, or a transfer completed but your books were not updated. These are high-priority issues.
For a complete approach to handling events that exhaust all retries, see Replaying Failed Paystack Webhooks Safely.
A queue you cannot see is a queue you cannot trust. BullMQ provides several ways to monitor what is happening:
Bull Board is a web-based dashboard for BullMQ queues. Install it alongside your Express app:
npm install @bull-board/express @bull-board/api
const { createBullBoard } = require('@bull-board/api');
const { BullMQAdapter } = require('@bull-board/api/bullMQAdapter');
const { ExpressAdapter } = require('@bull-board/express');
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [
new BullMQAdapter(paystackQueue),
new BullMQAdapter(deadLetterQueue),
],
serverAdapter,
});
app.use('/admin/queues', serverAdapter.getRouter());
Bull Board shows you the number of waiting, active, completed, and failed jobs in real time. You can inspect individual jobs, see their data and error messages, and retry failed jobs directly from the dashboard.
Protect the dashboard. Do not expose Bull Board to the public internet. Put it behind authentication middleware or restrict it to internal networks. The dashboard shows your raw webhook payloads, which contain customer emails, transaction amounts, and other sensitive data.
Beyond the dashboard, track these metrics in your application monitoring (Datadog, Grafana, or even simple log-based alerts):
In development, you might run the worker in the same terminal as your Express server. In production, treat the worker as a separate service. Here are the common approaches:
PM2 is the simplest option for VPS deployments:
# ecosystem.config.js
module.exports = {
apps: [
{
name: 'api',
script: 'server.js',
instances: 2,
},
{
name: 'paystack-worker',
script: 'workers/paystack-worker.js',
instances: 1,
},
],
};
Start both processes with pm2 start ecosystem.config.js. PM2 will restart the worker automatically if it crashes and keep logs for both processes.
Docker Compose works well if you are containerizing your deployment:
# docker-compose.yml
services:
api:
build: .
command: node server.js
ports:
- "3000:3000"
depends_on:
- redis
worker:
build: .
command: node workers/paystack-worker.js
depends_on:
- redis
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
redis_data:
The key principle is that your web server and your worker share the same codebase and the same Redis instance, but they run as separate processes. The web server handles HTTP traffic. The worker handles job processing. They communicate through Redis, nothing else.
If you are deploying to a platform like Railway or Render, you can define the worker as a separate "background worker" service using the same Docker image but a different start command.
As your integration grows, you might want to process different event types with different priorities and concurrency levels. For example, charge.success events (a customer just paid) should be processed faster than invoice.update events (a subscription invoice was updated).
Instead of routing everything through a single queue, create separate queues for each category:
const paymentQueue = new Queue('paystack-payments', { connection });
const transferQueue = new Queue('paystack-transfers', { connection });
const subscriptionQueue = new Queue('paystack-subscriptions', { connection });
// In your webhook handler
switch (true) {
case payload.event.startsWith('charge.'):
await paymentQueue.add(payload.event, jobData);
break;
case payload.event.startsWith('transfer.'):
await transferQueue.add(payload.event, jobData);
break;
case payload.event.startsWith('subscription.'):
case payload.event.startsWith('invoice.'):
await subscriptionQueue.add(payload.event, jobData);
break;
default:
await paymentQueue.add(payload.event, jobData);
}
Then create separate workers for each queue, each with its own concurrency settings. Your payment worker might run at concurrency 10 to process checkout completions quickly, while your subscription worker runs at concurrency 2 because subscription events are less time-sensitive.
This separation also makes monitoring clearer. When your payment queue backs up, you know it is a payment processing issue, not just general queue congestion. It also means a flood of subscription events will not block your payment processing.
After helping teams implement BullMQ for Paystack webhooks across various African fintech products, these are the mistakes we see repeatedly:
1. Forgetting to handle Redis disconnections. If Redis goes down, your queue operations will throw errors. Your webhook endpoint will return 500 instead of 200, and Paystack will start retrying. Add error handling around the queue.add() call and consider falling back to storing the raw payload in your database if Redis is unavailable.
2. Running the worker inside the web server process. If you create the Worker in the same file as your Express app, a crash in the worker takes down your web server too. Keep them in separate processes. Always.
3. Not setting removeOnComplete. By default, BullMQ keeps every completed job in Redis forever. After a few thousand webhooks, your Redis memory usage will be significant. Set removeOnComplete: { count: 1000 } or removeOnComplete: { age: 86400 } to clean up automatically.
4. Ignoring job ordering. BullMQ processes jobs roughly in order, but with concurrency greater than 1, jobs can complete out of order. If you need strict ordering for certain event types (for example, processing a refund only after the original charge), you may need to implement ordering logic in your handler. See Webhook Ordering for strategies.
5. No idempotency in the worker. BullMQ may retry a job that partially succeeded. If your handler sent an email but crashed before marking the job complete, the retry will send the email again. Every handler must check whether the work was already done before doing it again.
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