Paystack Webhook Retries: What to Expect and How to Cope
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.
What Triggers a Paystack Webhook Retry
Paystack considers a webhook delivery "failed" when any of these conditions are met:
- Non-2xx status code. Your endpoint returns 400, 401, 403, 404, 500, 502, 503, or any other status code outside the 200-299 range. Even a 301 redirect counts as a failure.
- Connection timeout. Paystack cannot establish a TCP connection to your server within the timeout window. This happens when your server is down, your firewall blocks the connection, or DNS resolution fails.
- Response timeout. Paystack connects to your server, sends the request, but your endpoint does not respond within the allowed time. This is the most common cause of retries for working applications. Your handler is doing too much work before returning a response.
- Connection reset. Your server accepts the connection but drops it before sending a response. This can happen when your application crashes mid-request or when a load balancer kills a slow connection.
- SSL/TLS errors. If your SSL certificate is expired, misconfigured, or uses an untrusted CA, Paystack cannot complete the HTTPS handshake and treats it as a failed delivery.
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.
The Retry Schedule
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:
- First retry: A few minutes after the initial failure
- Second retry: 10 to 30 minutes later
- Third retry: About an hour later
- Subsequent retries: Increasing intervals, up to several hours apart
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:
- If your server is down for 5 minutes (a quick restart), Paystack will catch up via retries. No events will be lost.
- If your server is down for a few hours, some events will be delivered via retries, but later retries may not reach the end of the retry window. You should have a fallback mechanism.
- If your server is down for a day, many events will have exhausted their retries. You need an independent reconciliation process to catch the gaps.
The Retry Payload Is Identical
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:
- Your signature verification works the same way. You do not need special handling for retries. The same verification logic that works for the first delivery works for every retry.
- You cannot tell whether a request is a retry. From your handler's perspective, every delivery looks like a fresh webhook. You must treat every delivery as potentially the first or potentially a retry.
- Idempotency is mandatory. Since you cannot distinguish retries, your handler must be safe to run multiple times with the same input. See Idempotent Webhook Handlers: The Complete Pattern for implementation details.
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.
Coping Strategy 1: Idempotent Handlers
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 first delivery marks the order as paid and sends a receipt email.
- The second delivery (a retry) checks, finds the order already marked as paid, and does nothing.
- The third delivery does the same thing: checks, finds it is already done, skips.
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.
Coping Strategy 2: Return 200 Immediately
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.
Coping Strategy 3: Monitor for Repeated Failures
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:
- Webhook endpoint returning non-200 more than 5 times per hour
- Average webhook handler response time exceeding 2 seconds
- More than 10 duplicate deliveries (retries) in a 24-hour period
These thresholds catch problems early, before Paystack exhausts retries and events start getting lost.
Coping Strategy 4: The Verify API Safety Net
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.
When Paystack Disables Your Webhook URL
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:
- You stop receiving any webhooks at all, even though transactions are happening.
- The Paystack dashboard shows your webhook URL with a warning or error status.
- New transactions complete successfully (your customers can pay), but your system is never notified.
To recover:
- Fix the underlying issue (server down, SSL expired, handler crash).
- Check your webhook URL configuration in the Paystack dashboard.
- If Paystack disabled your URL, update it (you can set it to the same URL) to signal that you have fixed the problem.
- Send a test webhook from the dashboard to confirm delivery works.
- Use the Verify API to reconcile any transactions you missed during the downtime.
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.
Testing Retry Behavior Locally
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.
Key Takeaways
- ✓Paystack retries when your endpoint returns any non-2xx status code or does not respond within the timeout window. A 200 response is the signal that delivery succeeded.
- ✓Retries follow an exponential backoff schedule. Early retries come within minutes, later retries are spaced hours apart. The total retry window spans several hours.
- ✓Each retry sends the exact same payload with the exact same signature. Your handler will see identical requests, so idempotency is not optional.
- ✓The fastest way to prevent retries is to return 200 immediately and process the event in the background via a queue. Long-running handlers are the most common cause of unnecessary retries.
- ✓Monitor your webhook failure rate in the Paystack dashboard. A high failure rate might trigger Paystack to disable your webhook URL temporarily.
- ✓Paystack does not guarantee delivery. After all retries are exhausted, the event is gone. Use the Verify Transaction API as a safety net to catch anything webhooks missed.
Frequently Asked Questions
- How many times does Paystack retry a failed webhook?
- Paystack does not publicly commit to a fixed retry count. The retry policy uses exponential backoff and spans several hours. Rather than relying on a specific number, build your system to be idempotent (safe for any number of retries) and use the Verify Transaction API as a safety net to catch events that were never successfully delivered.
- Does Paystack retry if my endpoint returns 201 or 204?
- Status codes in the 2xx range (200, 201, 204, etc.) are generally treated as successful delivery. However, the safest approach is to return exactly 200. Paystack documentation specifically mentions 200 as the expected success response, and using other 2xx codes introduces unnecessary ambiguity.
- What is the timeout before Paystack considers a delivery failed?
- Paystack allows a limited window for your endpoint to respond. The exact timeout is not publicly documented to the second, but it is short enough that any handler doing database queries, API calls, or email sends risks exceeding it. The safe strategy is to return 200 within a few hundred milliseconds and process the event in the background.
- Can I configure the retry schedule in my Paystack dashboard?
- No. The retry schedule is controlled entirely by Paystack. You cannot change the number of retries, the backoff intervals, or the timeout duration. Your only control is over your own endpoint: make it fast, make it reliable, and make it idempotent.
- If Paystack retries exhaust, how do I recover the missed events?
- Use the Paystack Verify Transaction API to check the status of pending transactions. Run a periodic reconciliation job that queries Paystack for transactions your system has not processed. If you stored raw webhook payloads, you can also replay events that were received but not processed. For the complete recovery pattern, see our guide on 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