Queueing Paystack Webhook Work with BullMQ
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.
Why You Should Queue Paystack Webhook Work
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:
- Fast responses. Your endpoint returns in milliseconds, so Paystack never thinks delivery failed.
- Built-in retries. If your worker crashes or the database is temporarily down, the job stays in the queue and gets retried automatically.
- Decoupled scaling. You can run multiple workers to process jobs in parallel without changing your webhook endpoint at all.
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.
Prerequisites: Redis and BullMQ Setup
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:
- Local Redis. Install Redis on your machine for development. On Ubuntu:
sudo apt install redis-server. On macOS:brew install redis. - Managed Redis. For production, use a managed service like Upstash, Redis Cloud, or AWS ElastiCache. Upstash is popular for African deployments because it offers a generous free tier and serverless pricing.
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.
The Express Webhook Endpoint
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:
- Raw body parsing. We use
express.raw()instead ofexpress.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. - Job naming. We use
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. - Timestamp. We add
receivedAtto the job data so we can track how long jobs sit in the queue before processing.
The BullMQ Worker: Processing Jobs
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.
Configuring Retries and Backoff
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:
- Attempt 1: Immediate processing when the job is picked up
- Attempt 2: 3 seconds after the first failure
- Attempt 3: 6 seconds after the second failure
- Attempt 4: 12 seconds after the third failure
- Attempt 5: 24 seconds after the fourth failure
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.
Dead-Letter Queue for Terminal Failures
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.
Monitoring Your Queue in Production
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):
- Queue depth. How many jobs are waiting? A consistently growing queue means your workers cannot keep up.
- Processing time. How long does each job take? Sudden increases suggest downstream problems.
- Failure rate. What percentage of jobs fail? A spike usually means a code bug or an infrastructure issue.
- Dead-letter count. Any job in the dead-letter queue needs immediate attention.
Running the Worker in Production
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.
Separating Event Types with Named Queues
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.
Common Pitfalls to Avoid
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.
Key Takeaways
- ✓Your webhook endpoint should do exactly two things: verify the signature and add the payload to the queue. Everything else happens in the worker.
- ✓BullMQ uses Redis as its backing store. Every job is persisted in Redis, so if your server restarts, pending jobs survive and get processed when the worker comes back up.
- ✓Return 200 before doing any business logic. Paystack waits for your response, and if you take too long, it marks the delivery as failed and retries later, creating duplicates.
- ✓BullMQ supports automatic retries with exponential backoff. Configure 3 to 5 retries so transient failures (database hiccups, network blips) resolve themselves without manual intervention.
- ✓Run your worker in a separate process from your web server. This keeps webhook responses fast even when the worker is crunching through a backlog.
- ✓Use named queues to separate different event types (payments, transfers, refunds) so you can prioritize and monitor them independently.
- ✓Add a dead-letter queue configuration so jobs that fail all retries land somewhere visible instead of vanishing silently.
Frequently Asked Questions
- Can I use BullMQ without a dedicated Redis server?
- BullMQ requires Redis. However, you do not need to run your own Redis server. Managed services like Upstash offer serverless Redis with a free tier that handles thousands of webhook jobs per day. Upstash is a popular choice for African startups because it has low-latency endpoints and pay-per-request pricing.
- What happens to queued jobs if my server restarts?
- Jobs are stored in Redis, not in your application memory. When your server or worker restarts, BullMQ reconnects to Redis and picks up where it left off. Waiting jobs get processed, and active jobs that were interrupted get retried. This is one of the main advantages of using a persistent queue over processing webhooks in-memory.
- How many workers should I run for a typical Paystack integration?
- For most integrations handling a few hundred transactions per day, one worker with concurrency 5 is more than enough. If you process thousands of transactions daily, add a second worker instance. BullMQ handles multiple workers consuming from the same queue automatically. Start with one and scale based on queue depth metrics.
- Should I use BullMQ or Bull (the older version)?
- Use BullMQ. It is the actively maintained successor to Bull, built by the same team. BullMQ has better TypeScript support, improved performance, built-in flow and dependency features, and is the recommended choice for new projects. Bull still works but receives only maintenance updates.
- Can BullMQ handle the volume of a high-traffic Paystack merchant?
- Yes. BullMQ and Redis can handle thousands of jobs per second. Companies processing millions of transactions use BullMQ in production. The bottleneck in a Paystack webhook pipeline is almost always your business logic (database writes, API calls to other services), not the queue itself.
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