Paystack Rate Limit and 429 Handling
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.
The 429 Response
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.
Understanding Paystack Rate Limits
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.
Code: Exponential Backoff with Jitter
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.
Caching Paystack Responses
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.
Batch Operations to Reduce API Calls
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:
- Verify transactions via webhooks, not polling. Instead of calling
/transaction/verifyevery 5 seconds to check if a transaction completed, wait for the webhook. - Batch recipient creation. If you are onboarding many payees at once, create them in a loop with delays rather than all at once.
- Use webhook data directly. The webhook payload contains the full transaction data. You do not need to call the verification endpoint again if you have already verified the webhook signature.
Common Causes of Rate Limit Issues
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);
}
}
});
Monitoring Your Rate Limit Usage
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.
Verification: Confirm Your Rate Limit Handling Works
Test your rate limit handling before it matters in production:
- Test exponential backoff. Mock the Paystack API to return 429 on the first 3 attempts and 200 on the 4th. Confirm your client retries correctly with increasing delays and eventually succeeds. Log the wait times to verify the exponential pattern.
- Test max retry exhaustion. Mock the API to always return 429. Confirm your client gives up after the maximum number of retries and throws a clear error rather than hanging forever.
- Test caching. Call
getBankList()twice in quick succession. Confirm the second call returns immediately from cache without making an API request. - Test cache expiry. Set a short TTL (1 second) on a cached item. Wait 2 seconds and call the function again. Confirm it makes a fresh API request.
- Test bulk transfers. Create a list of 150 test transfers. Run the batch processor. Confirm it splits into 2 batches with a delay between them and all transfers are submitted.
- Test monitoring. Send several requests, some of which trigger rate limiting. Check the monitoring stats. Confirm the counts and percentages are accurate.
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.
Key Takeaways
- ✓Paystack rate limits are roughly 10 requests per second per API key for most endpoints. The exact limit is not publicly documented and may vary by endpoint and account tier.
- ✓A 429 response means "slow down", not "your request is invalid". The request itself is fine. You are just sending too many too fast. Back off and retry.
- ✓Exponential backoff with jitter is the correct retry strategy. Wait 1 second, then 2, then 4, with a random jitter of 0 to 500ms added to each wait. This prevents thundering herd problems when multiple processes retry simultaneously.
- ✓Cache read-only data aggressively. The list of banks does not change hourly. Customer details do not change between requests. Transaction verification results do not change after a transaction reaches a terminal state.
- ✓Batch transfer operations using the /transfer/bulk endpoint instead of making individual /transfer calls. One bulk call for 50 transfers is far more efficient than 50 individual calls.
- ✓If you are hitting rate limits during normal operation (not bulk processing), you probably have an architectural problem. A well-designed payment integration rarely makes more than 2 to 3 API calls per transaction.
- ✓Monitor your 429 rate. If it spikes, investigate whether a bug is causing a retry loop or whether legitimate traffic has outgrown your current approach.
Frequently Asked Questions
- What is the Paystack API rate limit?
- Paystack does not publish exact rate limit numbers. Based on practical experience, the limit is approximately 10 requests per second per API key for most endpoints. The actual limit may vary by endpoint, account tier, and time of day. If you need higher limits, contact Paystack support.
- Does the Paystack 429 response include a Retry-After header?
- No. Unlike some APIs (like GitHub or Stripe), Paystack does not include a Retry-After header in 429 responses. You need to implement your own backoff strategy. Exponential backoff with jitter starting at 1 second is a good default.
- Will hitting rate limits cause Paystack to ban my account?
- Occasional 429 responses are normal and will not cause any account action. However, if your application continuously hammers the API without backing off (thousands of requests per second), Paystack may temporarily block your API key. This is rare and requires extreme abuse. Normal rate limit handling with backoff will never trigger a block.
- Should I use a queue for Paystack API calls?
- For bulk operations (mass payouts, batch reconciliation), yes. A queue with rate limiting ensures you never exceed the API limit. Use a job queue (like Bull for Node.js) with concurrency set to 5 and a delay of 200ms between jobs. For real-time operations (transaction initialization, verification), direct API calls with backoff are fine.
- Do test mode and live mode have separate rate limits?
- They use different API keys, so the rate limits are counted separately. However, Paystack may apply lower rate limits to test mode since it is not revenue-generating. If you are running load tests against the test API and hitting limits, switch to mocking the Paystack API responses instead.
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