Paystack retries webhook deliveries when your endpoint returns a non-2xx status code or does not respond within the timeout window. Retries are sent at increasing intervals over several hours. To cope, your handler must be idempotent (processing the same event twice causes no harm), respond with 200 before doing heavy work, and monitor for repeated delivery failures that indicate an infrastructure problem.
Paystack considers a webhook delivery "failed" when any of these conditions are met:
A 200 response with any body (or no body) counts as a successful delivery. Paystack does not inspect the response body. It only looks at the status code. Returning 200 OK, 200 {"status": "received"}, or 200 (empty body) all work equally well.
One subtle point: if your load balancer or CDN intercepts the request and returns an error page (like a Cloudflare 522 timeout or an nginx 502 bad gateway), Paystack sees that error code. Your application never gets the request, but Paystack still counts it as a failed delivery and schedules a retry.
Paystack uses an exponential backoff strategy for retries. The exact intervals are not publicly documented to the second, but based on observed behavior and Paystack's documentation, the pattern is roughly:
The total retry window spans several hours. After all retries are exhausted, Paystack stops trying. The event is not delivered again unless you manually trigger a resend from the Paystack dashboard.
Important: Paystack does not document a fixed number of retries. The retry policy can change, and different event types may have different retry behaviors. Do not build your system around an assumed retry count. Instead, build it to be resilient regardless of whether Paystack retries 3 times or 10 times.
What this means practically:
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Each retry sends the exact same HTTP request: same body, same headers, same x-paystack-signature. There is no retry counter in the payload, no "this is attempt 3" header, no way to distinguish a retry from the original delivery by looking at the request content.
This means:
Some payment providers include a delivery attempt number in their webhook headers. Paystack does not. This is not a limitation you can work around; it is a design decision that makes idempotency a non-negotiable part of your architecture.
Idempotency means that processing the same event N times produces the same outcome as processing it once. For a charge.success webhook, this means:
The simplest implementation uses the transaction reference as the idempotency key:
async function handleChargeSuccess(data) {
const reference = data.reference;
// Attempt to mark as paid, but only if not already paid
const result = await db.query(
'UPDATE orders SET status = $1, paid_at = NOW() ' +
'WHERE reference = $2 AND status != $1 ' +
'RETURNING id',
['paid', reference]
);
if (result.rowCount === 0) {
// Either the order does not exist or it is already paid
console.log('Skipping duplicate or unknown: ' + reference);
return;
}
// Only runs once: the first time the order transitions to 'paid'
await sendReceipt(reference);
}
The WHERE clause status != 'paid' ensures the UPDATE only succeeds once. The second time the handler runs, the UPDATE matches zero rows, and the receipt is not sent again.
This pattern works for all event types, not just charge.success. For transfer.failed, check if the transfer is already marked as failed. For refund.processed, check if the refund was already recorded. The principle is always the same: check before acting.
The most common cause of unnecessary retries is a slow handler. Your endpoint takes 8 seconds to process the event, Paystack times out after 5 seconds, and now you get a retry for an event you already processed. The retry hits your handler again, which takes another 8 seconds. If your handler is not idempotent, you now have double-processed the event.
The fix is to return 200 before doing any business logic. Verify the signature, acknowledge the delivery, then process the event asynchronously:
app.post('/webhook/paystack', express.raw({ type: 'application/json' }), async (req, res) => {
// Verify signature (fast: sub-millisecond)
const isValid = verifyPaystackSignature(req.body, req.headers['x-paystack-signature']);
if (!isValid) return res.sendStatus(401);
// Return 200 immediately
res.sendStatus(200);
// Process in the background
const payload = JSON.parse(req.body.toString());
processWebhookEvent(payload).catch(err => {
console.error('Webhook processing failed: ' + err.message);
});
});
Better yet, use a job queue (BullMQ, Celery, Laravel Queues) to dispatch the processing to a background worker. The inline background approach above works but has a weakness: if your server restarts between the 200 response and the processing completion, the event is lost. A queue persists the job across restarts.
See Queueing Webhook Work with BullMQ, Queueing with Celery, or Queueing with Laravel Queues for complete queue implementations.
For a deeper analysis of why the 200-first pattern is critical, see Why You Must Return 200 Immediately.
If Paystack is retrying your webhooks, something is wrong. You need to know about it before Paystack exhausts all retries and your events are lost forever.
Paystack dashboard. Check the webhook delivery logs in your Paystack dashboard under Settings > API Keys & Webhooks. The dashboard shows recent webhook deliveries and whether they succeeded or failed. A stream of failures here is an immediate red flag.
Application-level monitoring. Track webhook delivery success and failure rates in your own monitoring. If you are storing raw payloads (and you should be), you can detect duplicates by counting how many times the same reference + event_type combination appears:
-- Find events delivered more than once (retries happened)
SELECT reference, event_type, COUNT(*) as delivery_count
FROM webhook_events
WHERE received_at > NOW() - INTERVAL '24 hours'
GROUP BY reference, event_type
HAVING COUNT(*) > 1
ORDER BY delivery_count DESC;
If this query returns results, you are getting retries. Look at the time gaps between deliveries to understand the retry pattern and identify the root cause (slow handlers, server downtime, signature verification failures).
Alert thresholds. Set up alerts for:
These thresholds catch problems early, before Paystack exhausts retries and events start getting lost.
Paystack does not guarantee delivery. If all retries fail, the event is gone. Your safety net is the Paystack Verify Transaction API, which lets you check the status of any transaction by reference at any time.
Run a periodic reconciliation job that compares your database against Paystack:
async function reconcileRecentTransactions() {
// Get orders that should have been paid in the last 2 hours
const pendingOrders = await db.query(
'SELECT reference FROM orders ' +
'WHERE status = $1 AND created_at > NOW() - INTERVAL $2',
['pending', '2 hours']
);
for (const order of pendingOrders.rows) {
const response = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(order.reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
const result = await response.json();
if (result.data && result.data.status === 'success') {
// This transaction was paid but the webhook was never processed
console.log('Found unprocessed payment: ' + order.reference);
await handleChargeSuccess(result.data);
}
}
}
Run this job every 15 to 30 minutes. It catches any transactions where the webhook was never delivered (or was delivered but all retries failed). This is your last line of defense against lost webhooks.
Do not use the Verify API as your primary notification method. It requires you to know the transaction reference, which means you already need to have initiated the transaction. Webhooks are the primary channel; the Verify API is the backup. See Verify Transaction: The Call You Must Never Skip for the full verification pattern.
If your webhook endpoint fails consistently over an extended period, Paystack may temporarily disable webhook deliveries to your URL. This is a protective measure to prevent Paystack from wasting resources on an unreachable endpoint.
Signs that your webhook URL has been disabled:
To recover:
The best way to avoid this situation is to monitor your webhook failure rate proactively and fix issues before they accumulate. A single failed delivery is fine (Paystack will retry). A thousand consecutive failed deliveries will trigger protective measures.
You cannot easily test Paystack's retry behavior with their sandbox because retries happen on Paystack's schedule, not yours. But you can simulate retries in your local environment to verify your handler's idempotency:
// test/webhook-retry.test.js
const crypto = require('crypto');
const request = require('supertest');
const app = require('../app');
function signPayload(payload, secret) {
return crypto.createHmac('sha512', secret).update(JSON.stringify(payload)).digest('hex');
}
describe('Webhook retry handling', () => {
const payload = {
event: 'charge.success',
data: {
reference: 'TEST_' + Date.now(),
amount: 50000,
currency: 'NGN',
status: 'success',
customer: { email: 'test@example.com' },
},
};
const signature = signPayload(payload, process.env.PAYSTACK_SECRET_KEY);
it('should process the first delivery', async () => {
const res = await request(app)
.post('/webhook/paystack')
.set('x-paystack-signature', signature)
.set('Content-Type', 'application/json')
.send(JSON.stringify(payload));
expect(res.status).toBe(200);
// Verify the order was marked as paid
});
it('should handle a retry (duplicate) gracefully', async () => {
// Send the exact same request again
const res = await request(app)
.post('/webhook/paystack')
.set('x-paystack-signature', signature)
.set('Content-Type', 'application/json')
.send(JSON.stringify(payload));
expect(res.status).toBe(200);
// Verify no duplicate processing occurred
});
});
This test sends the same webhook payload twice and verifies that both deliveries return 200 but only the first one triggers actual processing. If the second delivery causes a duplicate database entry, a duplicate email, or any other side effect, your handler is not idempotent and will cause problems when Paystack retries in production.
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