Paystack returns HTTP 429 when you exceed the API rate limit. The limit is roughly 10 requests per second per API key for most endpoints. The fix is threefold: implement exponential backoff with jitter for retries, cache responses that do not change frequently (bank lists, customer details), and batch operations where possible instead of making individual API calls. Never retry 429 errors immediately. Wait, back off, and try again.
When you exceed the rate limit, Paystack returns:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"status": false,
"message": "You are sending too many requests. Please retry later."
}
Unlike some APIs, Paystack does not include a Retry-After header in the 429 response. You need to implement your own backoff logic.
The 429 response does not count against your account or trigger any penalties. It is simply Paystack telling you to slow down. Your request was not processed, so no state changed. You can safely retry the same request after waiting.
However, if you keep hammering the API without backing off, Paystack may temporarily block your API key for a longer period. This is rare but happens when a buggy retry loop sends thousands of requests in seconds.
Paystack does not publish exact rate limit numbers in their documentation. Based on practical experience, the limits are approximately:
| Scenario | Approximate limit | Notes |
|---|---|---|
| General API calls | ~10 req/sec per key | Applies to most endpoints |
| Transaction initialization | ~10 req/sec | Same as general |
| Transaction verification | ~10 req/sec | Cache terminal states |
| Transfer operations | ~10 req/sec | Use bulk endpoint for batches |
| Bank list / resolve | ~10 req/sec | Cache this data |
These limits are per API key, not per IP address. If you have multiple servers using the same API key, their combined requests count toward the same limit.
The limits may also be different for test mode and live mode. Test mode limits might be lower since Paystack prioritizes live transaction processing.
If you need higher limits for a legitimate use case (high-volume marketplace, bulk payouts), contact Paystack support. They can adjust limits for specific accounts.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
The correct way to handle 429 errors is exponential backoff with random jitter. Here is a reusable implementation:
// lib/paystack-client.ts
interface PaystackRequestOptions {
method?: string;
body?: any;
maxRetries?: number;
}
async function paystackRequest(
endpoint: string,
options: PaystackRequestOptions = {}
): Promise {
const { method = 'GET', body, maxRetries = 5 } = options;
const url = `https://api.paystack.co${endpoint}`;
const headers: Record = {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
};
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
// Success: return the response
if (res.ok) {
return res.json();
}
// Rate limited: back off and retry
if (res.status === 429) {
if (attempt === maxRetries) {
throw new Error(
`Paystack rate limit: exceeded ${maxRetries} retries for ${endpoint}`
);
}
const baseDelay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s, 16s
const jitter = Math.random() * 500; // 0-500ms random jitter
const delay = baseDelay + jitter;
console.warn(
`Paystack 429 on ${endpoint}. Retry ${attempt + 1}/${maxRetries} in ${Math.round(delay)}ms`
);
await new Promise(r => setTimeout(r, delay));
continue;
}
// Other errors: do not retry
const errorData = await res.json().catch(() => ({}));
throw new Error(
`Paystack ${res.status} on ${endpoint}: ${errorData.message || 'Unknown error'}`
);
} catch (err: any) {
if (err.message?.includes('Paystack rate limit') || err.message?.includes('Paystack')) {
throw err;
}
// Network error: retry with backoff
if (attempt === maxRetries) {
throw new Error(`Paystack network error on ${endpoint} after ${maxRetries} retries: ${err.message}`);
}
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
await new Promise(r => setTimeout(r, delay));
}
}
}
// Usage
const transaction = await paystackRequest('/transaction/verify/txn_abc123');
const banks = await paystackRequest('/bank');
const transfer = await paystackRequest('/transfer', {
method: 'POST',
body: { source: 'balance', amount: 50000, recipient: 'RCP_xyz' },
});
The jitter is critical. Without it, if 10 servers all get rate-limited at the same time, they all retry at exactly the same intervals, causing another rate limit spike. Jitter spreads the retries across the wait window.
The fastest API call is the one you do not make. Cache responses that do not change frequently:
// Simple in-memory cache with TTL
class PaystackCache {
private cache = new Map();
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
}
set(key: string, data: any, ttlSeconds: number) {
this.cache.set(key, {
data,
expiresAt: Date.now() + ttlSeconds * 1000,
});
}
}
const cache = new PaystackCache();
// Cache bank list for 24 hours (it rarely changes)
async function getBankList(): Promise {
const cached = cache.get('bank_list');
if (cached) return cached;
const data = await paystackRequest('/bank?country=nigeria&perPage=100');
cache.set('bank_list', data.data, 86400); // 24 hours
return data.data;
}
// Cache transaction verification for terminal states
async function verifyTransaction(reference: string): Promise {
const cacheKey = `txn_${reference}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const data = await paystackRequest(`/transaction/verify/${reference}`);
// Only cache terminal states (success, failed, abandoned)
const status = data.data?.status;
if (status === 'success' || status === 'failed' || status === 'abandoned') {
cache.set(cacheKey, data, 3600); // 1 hour
}
// Do NOT cache 'pending' transactions. Their status will change.
return data;
}
// Cache customer details for 5 minutes
async function getCustomer(customerCode: string): Promise {
const cacheKey = `customer_${customerCode}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const data = await paystackRequest(`/customer/${customerCode}`);
cache.set(cacheKey, data.data, 300); // 5 minutes
return data.data;
}
What to cache and for how long:
| Data | TTL | Reasoning |
|---|---|---|
| Bank list | 24 hours | Banks are added/removed rarely |
| Transaction (terminal state) | 1 hour | Status will not change |
| Customer details | 5 minutes | May be updated via dashboard |
| Balance | Do not cache | Changes with every transaction |
| Transfer status (pending) | Do not cache | Status will change |
For production systems with multiple server instances, use Redis instead of in-memory caching so all instances share the same cache.
The biggest rate limit offender in most Paystack integrations is bulk transfers. If you are paying out to 100 recipients, making 100 individual POST /transfer calls will hit the rate limit. Use the bulk transfer endpoint instead:
// Instead of 100 individual transfer calls, use one bulk call
async function bulkTransfer(
transfers: Array<{ amount: number; recipient: string; reason: string }>
) {
// Paystack bulk transfer endpoint
const res = await paystackRequest('/transfer/bulk', {
method: 'POST',
body: {
source: 'balance',
transfers: transfers.map((t, i) => ({
amount: t.amount,
recipient: t.recipient,
reason: t.reason,
reference: `bulk_${Date.now()}_${i}`,
})),
},
});
return res;
}
// Usage
const payouts = [
{ amount: 500000, recipient: 'RCP_abc', reason: 'July salary' },
{ amount: 350000, recipient: 'RCP_def', reason: 'July salary' },
{ amount: 420000, recipient: 'RCP_ghi', reason: 'July salary' },
// ... up to 100 transfers per batch
];
await bulkTransfer(payouts);
If you have more than 100 transfers, split them into batches of 100 with a delay between batches:
async function processBulkTransfers(
allTransfers: Array<{ amount: number; recipient: string; reason: string }>
) {
const batchSize = 100;
const batches = [];
for (let i = 0; i < allTransfers.length; i += batchSize) {
batches.push(allTransfers.slice(i, i + batchSize));
}
const results = [];
for (let i = 0; i < batches.length; i++) {
console.log(`Processing batch ${i + 1}/${batches.length} (${batches[i].length} transfers)`);
const result = await bulkTransfer(batches[i]);
results.push(result);
// Wait 2 seconds between batches to avoid rate limits
if (i < batches.length - 1) {
await new Promise(r => setTimeout(r, 2000));
}
}
return results;
}
Other opportunities to reduce API calls:
/transaction/verify every 5 seconds to check if a transaction completed, wait for the webhook.If you are hitting 429 errors, one of these patterns is likely the culprit:
1. Polling loop for transaction status
You are calling /transaction/verify in a tight loop waiting for a transaction to complete. This wastes API calls and hits rate limits quickly. Use webhooks instead.
2. Retry loop without backoff
A failed request triggers an immediate retry, which also fails, which triggers another immediate retry, creating an infinite loop of requests. Always use exponential backoff.
3. Frontend calling the API directly
Your frontend is making API calls to Paystack on every page load or user action without rate limiting. Move API calls to your backend and debounce user-triggered actions.
4. Cron job running too fast
A cron job that reconciles transactions or processes payouts is iterating through hundreds of records without any delay between API calls.
5. Multiple servers, same key
You scaled to multiple server instances, and each instance is making the same API calls independently. The combined request rate exceeds the limit even though each individual server is well-behaved.
// Bad: polling loop
async function waitForPayment(reference: string) {
while (true) {
const data = await paystackRequest(`/transaction/verify/${reference}`);
if (data.data.status === 'success') return data;
// No delay! This hammers the API
}
}
// Good: use webhook and only verify once as confirmation
app.post('/webhooks/paystack', async (req, res) => {
res.status(200).send('ok');
if (req.body.event === 'charge.success') {
// Verify once to confirm
const data = await paystackRequest(
`/transaction/verify/${req.body.data.reference}`
);
if (data.data.status === 'success') {
await fulfillOrder(data.data);
}
}
});
Track how often you hit 429 errors and which endpoints trigger them most frequently:
// Rate limit monitoring middleware
let rateLimitStats = {
total: 0,
limited: 0,
byEndpoint: {} as Record,
};
async function paystackRequestWithMonitoring(
endpoint: string,
options: PaystackRequestOptions = {}
): Promise {
rateLimitStats.total++;
if (!rateLimitStats.byEndpoint[endpoint]) {
rateLimitStats.byEndpoint[endpoint] = { total: 0, limited: 0 };
}
rateLimitStats.byEndpoint[endpoint].total++;
try {
return await paystackRequest(endpoint, options);
} catch (err: any) {
if (err.message?.includes('429') || err.message?.includes('rate limit')) {
rateLimitStats.limited++;
rateLimitStats.byEndpoint[endpoint].limited++;
}
throw err;
}
}
// Log stats every hour
setInterval(() => {
const rate = rateLimitStats.total > 0
? ((rateLimitStats.limited / rateLimitStats.total) * 100).toFixed(2)
: '0';
console.log(`Paystack API stats (last hour):`);
console.log(` Total requests: ${rateLimitStats.total}`);
console.log(` Rate limited: ${rateLimitStats.limited} (${rate}%)`);
for (const [endpoint, stats] of Object.entries(rateLimitStats.byEndpoint)) {
if (stats.limited > 0) {
console.log(` ${endpoint}: ${stats.limited}/${stats.total} rate limited`);
}
}
// Reset stats
rateLimitStats = { total: 0, limited: 0, byEndpoint: {} };
}, 3600000);
Alert if the rate-limited percentage exceeds 5%. At that point, you need to rethink your API usage patterns rather than just adding more backoff time.
Test your rate limit handling before it matters in production:
getBankList() twice in quick succession. Confirm the second call returns immediately from cache without making an API request.For a broader view of API error handling patterns, see Paystack API Timeouts and Retry Strategy. For the full error reference, see Paystack Errors and Troubleshooting: The Complete Reference.
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