Why You Must Return 200 Immediately from a Paystack Webhook
Paystack has a timeout on webhook deliveries. If your endpoint does not return a 200 status code within a few seconds, Paystack treats the delivery as failed and retries. If you do heavy work before responding (database writes, API calls, sending emails), you risk timing out. The fix is to return 200 immediately after verifying the signature, then push the event payload to a background queue for processing.
The Timeout Problem
Paystack sends a webhook and waits for a response. Not forever. There is a timeout. If your server does not return a 200 within that window, Paystack considers the delivery failed.
What happens next is the problem. Paystack retries the event. Now your server might process the same event twice. If your handler credits a customer account on charge.success, a retry means the customer gets credited twice for a single payment. If your handler sends a confirmation email, the customer gets two emails. If it triggers a fulfillment workflow, you ship two packages.
The root cause is always the same: your handler did too much work before sending the response.
Here is a typical example of a handler that breaks this rule:
// BAD: This handler does everything before responding
app.post('/webhooks/paystack', express.json(), async (req, res) => {
const event = req.body;
if (event.event === 'charge.success') {
// Step 1: Update the database (50ms normally, 3s under load)
await db.query(
'UPDATE orders SET status = $1 WHERE payment_reference = $2',
['paid', event.data.reference]
);
// Step 2: Call a third-party API to start fulfillment (500ms to 5s)
await fetch('https://shipping-api.example.com/fulfill', {
method: 'POST',
body: JSON.stringify({ orderId: event.data.reference }),
});
// Step 3: Send confirmation email (200ms to 2s)
await sendEmail(event.data.customer.email, 'Payment received!');
// Step 4: Update analytics (100ms)
await analytics.track('payment_completed', event.data);
}
// FINALLY respond, after all the work is done
res.status(200).send('OK');
});
On a good day, this takes about 1 second. On a bad day (database under load, shipping API slow, email service queued), it takes 10 seconds or more. Paystack has already timed out and scheduled a retry.
This article is part of the Paystack webhooks engineering guide.
What Happens When Paystack Retries
When Paystack treats a delivery as failed, it queues a retry. The retry schedule is not publicly documented with exact intervals, but retries happen over a period of hours with increasing delays between attempts.
The problem is not the retry itself. The problem is what your handler does when it receives the retry while the original delivery is still being processed. Consider this timeline:
T+0s Paystack sends webhook. Your handler starts processing.
T+3s Database write completes. Handler starts calling shipping API.
T+5s Paystack times out. Schedules retry.
T+8s Shipping API responds. Handler sends email.
T+10s Handler returns 200. Too late. Paystack already moved on.
T+60s Paystack retry arrives. Your handler starts again.
T+61s Database write: order is already "paid." Do you skip? Or update again?
T+62s Shipping API: fulfillment already started. Do you send a duplicate?
T+63s Email: customer gets a second confirmation.
T+64s Handler returns 200. Paystack marks delivery as successful.
Everything that happened between T+60s and T+64s is a duplicate. The customer is confused (two emails), the shipping team is confused (two fulfillment requests), and your database might be in an inconsistent state depending on how the second write interacted with the first.
If you do not have idempotency (see idempotent webhook handlers), every retry creates problems. But even with idempotency, you are doing unnecessary work. The first delivery already succeeded. The retry is wasted effort that burns resources and creates log noise.
The solution: respond first, process later.
The Respond-First Pattern
The fix is straightforward. Your webhook handler should do exactly three things:
- Verify the signature.
- Return 200.
- Push the event to a queue.
That is it. No database writes. No API calls. No emails. All of that happens in a background worker that picks up the event from the queue.
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
async (req, res) => {
// 1. Verify signature
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
const hash = crypto
.createHmac('sha512', secret)
.update(req.body)
.digest('hex');
if (hash !== signature) {
return res.status(400).send('Invalid signature');
}
// 2. Return 200 IMMEDIATELY
res.status(200).send('OK');
// 3. Queue the event for background processing
const event = JSON.parse(req.body.toString());
try {
await webhookQueue.add('paystack-event', {
eventType: event.event,
data: event.data,
receivedAt: new Date().toISOString(),
});
} catch (err) {
// Queue push failed. Log and handle.
console.error('Failed to queue webhook event: ' + err.message);
// Fallback: write to database directly
await db.query(
'INSERT INTO webhook_backlog (event_type, payload, received_at) ' +
'VALUES ($1, $2, NOW())',
[event.event, JSON.stringify(event.data)]
);
}
}
);
The critical line is res.status(200).send('OK') before any processing. After this line executes, Paystack knows the delivery succeeded. Everything after it is background work.
In Express, res.send() sends the response but does not terminate the function. The code after it continues to run. This is intentional. You use this behavior to queue work after responding.
The try-catch around the queue push is a safety net. If the queue itself is down (Redis crashed, for example), you fall back to writing the payload to a database table. A separate worker polls that table and processes any backlogged events. This way, you never lose an event even if your queue is temporarily unavailable.
Queue Options for Background Processing
Any queue that supports persistent jobs works for webhook processing. The choice depends on your stack:
BullMQ (Node.js + Redis)
The most popular choice for Node.js applications. Jobs are stored in Redis and survive restarts. Supports delayed jobs, retries, concurrency limits, and dead letter queues. See queueing webhook work with BullMQ.
const { Queue, Worker } = require('bullmq');
const IORedis = require('ioredis');
const connection = new IORedis(process.env.REDIS_URL);
const webhookQueue = new Queue('paystack-webhooks', { connection });
// Worker processes events from the queue
const worker = new Worker('paystack-webhooks', async (job) => {
const { eventType, data } = job.data;
switch (eventType) {
case 'charge.success':
await handleChargeSuccess(data);
break;
case 'transfer.success':
await handleTransferSuccess(data);
break;
// ... other events
}
}, { connection, concurrency: 5 });
Database-backed queue (no Redis required)
If you do not want to run Redis, you can use your existing PostgreSQL database as a job queue. This is simpler to deploy and maintain, though it does not scale as well as Redis under high load.
// Write the event to a queue table
async function enqueueEvent(eventType, payload) {
await db.query(
'INSERT INTO webhook_queue (event_type, payload, status, created_at) ' +
'VALUES ($1, $2, $3, NOW())',
[eventType, JSON.stringify(payload), 'pending']
);
}
// Poll for pending events (run this on a timer or separate process)
async function processQueue() {
// Grab one pending event and lock it
const result = await db.query(
'UPDATE webhook_queue SET status = $1, started_at = NOW() ' +
'WHERE id = (SELECT id FROM webhook_queue WHERE status = $2 ' +
'ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED) ' +
'RETURNING *',
['processing', 'pending']
);
if (result.rows.length === 0) return; // Nothing to process
const job = result.rows[0];
try {
await processEvent(job.event_type, JSON.parse(job.payload));
await db.query(
'UPDATE webhook_queue SET status = $1, completed_at = NOW() ' +
'WHERE id = $2',
['completed', job.id]
);
} catch (err) {
await db.query(
'UPDATE webhook_queue SET status = $1, error = $2 WHERE id = $3',
['failed', err.message, job.id]
);
}
}
// Run the processor every 2 seconds
setInterval(processQueue, 2000);
The FOR UPDATE SKIP LOCKED clause is PostgreSQL-specific and allows multiple workers to process the queue concurrently without conflicts. Each worker grabs the next available job and locks it. Other workers skip locked rows and grab different ones.
For other queue options, see the guides for Celery (Python) and Laravel Queues (PHP).
Serverless Environments and the 200 Rule
The respond-first pattern has a complication on serverless platforms (Vercel, Netlify, AWS Lambda). On these platforms, the function execution environment is terminated shortly after the response is sent. Any code running after res.send() may be killed before it finishes.
This means the "respond, then queue" pattern does not work reliably on serverless unless you use a method that completes before the response. Two approaches:
Approach 1: Write to an external queue before responding.
Push to a managed queue service (AWS SQS, Upstash Redis, a database table) before returning the 200. This adds latency to the response, but managed queue writes are fast (10 to 50ms) and well within Paystack's timeout window.
// Vercel serverless function
export default async function handler(req, res) {
// Verify signature (omitted for brevity)
const event = JSON.parse(req.body.toString());
// Write to queue BEFORE responding (fast operation)
await fetch(process.env.QUEUE_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
eventType: event.event,
data: event.data,
}),
});
// Now respond
res.status(200).send('OK');
// Function will be terminated soon after this.
// The queue already has the event.
}
Approach 2: Use the Vercel waitUntil API.
Vercel provides a waitUntil function that extends the function lifetime beyond the response. It lets you return 200 to Paystack immediately and continue processing in the background. The function stays alive until the waitUntil promise resolves.
// Vercel with waitUntil
import { waitUntil } from '@vercel/functions';
export default async function handler(req, res) {
// Verify signature (omitted for brevity)
const event = JSON.parse(req.body.toString());
// Return 200 immediately
res.status(200).send('OK');
// Process in the background
waitUntil(processEvent(event));
}
For platform-specific details, see the guides for Vercel, Netlify, AWS Lambda, and Cloudflare Workers.
What Counts as Heavy Work
A useful mental model: anything that could take more than 100ms or could fail for reasons outside your control should not happen before the 200 response. Here is a rough breakdown:
Safe before responding (fast and reliable):
- HMAC signature verification (sub-millisecond)
- JSON parsing (sub-millisecond for webhook payloads)
- Pushing to an in-process queue or writing to an in-memory buffer
Borderline (usually fast, but can be slow under load):
- A single INSERT to a local database (10 to 50ms normally, but can spike)
- A single write to Redis (1 to 5ms normally)
- A single write to a managed queue service (10 to 50ms)
Definitely too heavy for before responding:
- Multiple database queries or transactions
- Calling external APIs (Paystack verify, shipping, notifications)
- Sending emails or SMS
- Generating PDFs, images, or files
- Running complex business logic with multiple steps
- Calling the Paystack API to verify the transaction (do this in the background worker)
The "borderline" category is where judgment matters. A single database INSERT to log the raw event is fine in most cases and can serve as your queue (see the database-backed queue approach above). But if that single INSERT turns into a 3-second operation during a traffic spike, you have a problem. If you go this route, set a statement timeout on the query:
// Set a 1-second timeout on the queue write
await db.query('SET LOCAL statement_timeout = 1000');
await db.query(
'INSERT INTO webhook_queue (event_type, payload, created_at) ' +
'VALUES ($1, $2, NOW())',
[event.event, JSON.stringify(event.data)]
);
If the INSERT takes more than 1 second, it will be cancelled. You lose the event, but Paystack will retry. This is a tradeoff: you protect the response time at the cost of occasional retries.
Monitoring Webhook Response Time
You should track how long your webhook handler takes to respond. This tells you whether you are at risk of hitting the timeout before it actually happens.
app.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
async (req, res) => {
const startTime = Date.now();
// Verify signature
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
const hash = crypto
.createHmac('sha512', secret)
.update(req.body)
.digest('hex');
if (hash !== signature) {
return res.status(400).send('Invalid signature');
}
// Queue the event
const event = JSON.parse(req.body.toString());
await webhookQueue.add('paystack-event', {
eventType: event.event,
data: event.data,
});
// Respond
res.status(200).send('OK');
// Track response time
const duration = Date.now() - startTime;
console.log(
'Webhook response time: ' + duration + 'ms for ' + event.event
);
// Alert if response time is getting close to the danger zone
if (duration > 2000) {
console.warn(
'SLOW WEBHOOK RESPONSE: ' + duration + 'ms for ' + event.event +
'. Risk of timeout on next delivery.'
);
}
}
);
If you see response times climbing above 2 seconds, investigate. Common causes: slow database connections, Redis connection pool exhaustion, DNS resolution delays, or high CPU load on the server. Fix the slow component before Paystack starts retrying events unnecessarily.
For a comprehensive monitoring setup, see monitoring and alerting on webhook failures.
The One Exception: Signature Verification
There is one thing you must do before responding: verify the signature. If you return 200 before verifying, you acknowledge receipt of a potentially forged event. Paystack considers it delivered and will not retry. If it was forged, you have no record of the forgery, and the real event (if there is one) will not be re-sent.
Signature verification is fast (sub-millisecond for HMAC SHA512), so it does not affect response time. The only scenario where it adds meaningful latency is if you struggle to read the raw body, which is a framework configuration issue rather than a performance issue. See the raw body problem.
The order is always:
- Read raw body.
- Compute HMAC. Compare to header.
- If mismatch: return 400. Stop.
- If match: return 200.
- Queue the event.
Never skip step 2. Never reorder steps 3 and 4. This is the foundation of webhook security and reliability.
For the full signature verification guide, see verifying the x-paystack-signature header correctly.
Key Takeaways
- ✓Paystack expects a 200 response from your webhook endpoint within a few seconds. Any non-2xx response or a timeout triggers a retry.
- ✓If you do heavy work before responding (database writes, API calls, emails), you risk exceeding the timeout and triggering unnecessary retries.
- ✓Retries without idempotency lead to duplicate processing: double credits, double emails, double fulfillment.
- ✓The correct pattern is: verify the signature, return 200, then push the event to a background queue for processing.
- ✓Even a fast handler can become slow under load. Database queries that take 50ms normally might take 5 seconds when your database is under pressure.
- ✓If you cannot set up a queue immediately, write the raw payload to a database row and process it asynchronously with a polling worker.
Frequently Asked Questions
- What status code should I return from a Paystack webhook handler?
- Return 200. Any 2xx status code (200, 201, 204) tells Paystack the delivery succeeded. Any non-2xx response (400, 500, etc.) or a timeout tells Paystack the delivery failed and triggers a retry. Convention is to return 200 with a simple body like "OK".
- What is the Paystack webhook timeout?
- Paystack does not publicly document an exact timeout value, but it is in the range of a few seconds. Best practice is to respond within 1 to 2 seconds. If your handler consistently takes more than 3 seconds to respond, you are in the danger zone for timeouts and retries.
- Can I process the webhook inline without a queue if my handler is fast?
- If your handler takes less than 1 second consistently (even under load, during traffic spikes, and when your database is slow), inline processing is technically fine. But "fast today" does not mean "fast tomorrow." A queue gives you resilience against future slowdowns. If you cannot set up a full queue, at minimum write the payload to a database row and process it in a separate loop.
- Does returning 200 mean I have to process the event successfully?
- No. Returning 200 only means you received the event. It does not promise successful processing. If your background worker fails to process the event, that is your problem to solve (via retries, dead letter queues, or manual intervention). Paystack will not resend the event after a 200 response.
- What if my queue is down and I cannot queue the event after responding 200?
- This is the worst-case scenario. You acknowledged receipt but cannot process the event. Build a fallback: write the payload to a database table or a local file. Then have a recovery process that picks up these orphaned events. Also monitor your queue health so you know when it is down. For a deeper look at recovery, see replaying failed webhooks safely.
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