Apply rate limiting to every payment-related endpoint. Use express-rate-limit or a Redis-backed limiter. Set strict limits on initialization endpoints (5-10 requests per minute per IP) and generous limits on webhook endpoints (100+ per minute from Paystack IPs). Rate limit by IP, by user, and by business rules like maximum transactions per customer per day.
Paystack has its own API rate limits to protect their infrastructure. But your payment endpoints need their own rate limiting for a different reason: to protect your business from abuse.
Without rate limiting, attackers can:
Rate limiting does not replace other security measures (signature verification, amount validation, idempotency). It is a layer of defense that reduces the speed and scale of attacks.
The simplest approach uses the express-rate-limit package:
var rateLimit = require('express-rate-limit');
// Strict limit for payment initialization
var paymentInitLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 5, // 5 requests per minute per IP
standardHeaders: true,
legacyHeaders: false,
message: {
status: 429,
error: 'Too many payment attempts. Please wait a minute and try again.',
},
});
// Moderate limit for verification
var verifyLimiter = rateLimit({
windowMs: 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: {
status: 429,
error: 'Too many verification requests. Please wait.',
},
});
// Generous limit for webhooks (Paystack retries)
var webhookLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/payment/initialize', paymentInitLimiter, handleInitialize);
app.get('/api/payment/verify', verifyLimiter, handleVerify);
app.post('/webhook/paystack', webhookLimiter, handleWebhook);
This in-memory approach works for a single server process. For production with multiple server instances, you need a shared store like Redis.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
In-memory rate limiters have two problems: they reset when the server restarts, and they do not share state across multiple server instances. A Redis-backed limiter solves both.
var rateLimit = require('express-rate-limit');
var RedisStore = require('rate-limit-redis');
var Redis = require('ioredis');
var redis = new Redis(process.env.REDIS_URL);
var paymentInitLimiter = rateLimit({
store: new RedisStore({
sendCommand: function() {
return redis.call.apply(redis, arguments);
},
}),
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
keyGenerator: function(req) {
// Rate limit by IP
return 'ratelimit:init:' + req.ip;
},
});
var userPaymentLimiter = rateLimit({
store: new RedisStore({
sendCommand: function() {
return redis.call.apply(redis, arguments);
},
}),
windowMs: 24 * 60 * 60 * 1000, // 24 hours
max: 20, // 20 transactions per day per user
standardHeaders: true,
keyGenerator: function(req) {
// Rate limit by authenticated user
return 'ratelimit:user:' + (req.user ? req.user.id : req.ip);
},
});
app.post('/api/payment/initialize', paymentInitLimiter, userPaymentLimiter, handleInitialize);
Notice the two limiters stacked on the initialization endpoint: one limits by IP (5 per minute) and one limits by user (20 per day). An attacker using a single IP cannot exceed 5 attempts per minute. An authenticated user cannot exceed 20 transactions per day, regardless of how many IPs they use.
This is the endpoint attackers target most for card testing. Apply the strictest limits:
Moderate limits. Legitimate users may verify the same transaction multiple times (page refreshes, retries):
Be generous here. Paystack sends legitimate retries, and during busy periods you may receive many webhooks in quick succession:
Very strict limits. These endpoints move money:
Beyond simple request-per-minute limits, implement business-rule-based limiting that catches attacks that stay under the per-minute threshold:
async function checkBusinessRules(req) {
var email = req.body.email;
var ip = req.ip;
// Rule 1: Max 3 failed payments per email per hour
var recentFailures = await db.query(
'SELECT COUNT(*) FROM payment_attempts WHERE email = $1 AND status = $2 AND created_at > NOW() - INTERVAL '1 hour'',
[email, 'failed']
);
if (parseInt(recentFailures.rows[0].count) >= 3) {
return { blocked: true, reason: 'Too many failed payment attempts' };
}
// Rule 2: Max 5 different emails from the same IP per hour
var ipEmails = await db.query(
'SELECT COUNT(DISTINCT email) FROM payment_attempts WHERE ip_address = $1 AND created_at > NOW() - INTERVAL '1 hour'',
[ip]
);
if (parseInt(ipEmails.rows[0].count) >= 5) {
return { blocked: true, reason: 'Too many different emails from this IP' };
}
// Rule 3: Max transaction amount per day per user
var dailyTotal = await db.query(
'SELECT COALESCE(SUM(amount), 0) as total FROM successful_payments WHERE user_id = $1 AND created_at > NOW() - INTERVAL '24 hours'',
[req.user.id]
);
if (parseInt(dailyTotal.rows[0].total) > 10000000) { // 100,000 NGN daily limit
return { blocked: true, reason: 'Daily transaction limit reached' };
}
return { blocked: false };
}
app.post('/api/payment/initialize', paymentInitLimiter, async function(req, res) {
var ruleCheck = await checkBusinessRules(req);
if (ruleCheck.blocked) {
return res.status(429).json({ error: ruleCheck.reason });
}
// Record the attempt
await db.query(
'INSERT INTO payment_attempts (email, ip_address, amount, status) VALUES ($1, $2, $3, $4)',
[req.body.email, req.ip, req.body.amount, 'attempted']
);
// Proceed with initialization
await handleInitialize(req, res);
});
Business rules catch sophisticated attacks that rotate IPs or use botnets to stay under per-IP rate limits. The rules above catch card testing (many failures), distributed attacks (many emails from one IP), and high-value fraud (daily limits).
How you respond to rate-limited requests matters for both security and user experience:
var rateLimitHandler = function(req, res) {
res.set('Retry-After', '60');
res.status(429).json({
status: false,
message: 'Too many requests. Please wait and try again.',
});
};
Every rate limit hit should be logged and monitored. A sudden increase in rate-limited requests means an attack is in progress.
var paymentInitLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
handler: function(req, res) {
console.log(
'Rate limited: ' + req.ip +
' endpoint=' + req.path +
' user=' + (req.user ? req.user.id : 'anonymous')
);
// Increment a counter for alerting
incrementMetric('rate_limit_hits', {
endpoint: req.path,
ip: req.ip,
});
res.status(429).json({
status: false,
message: 'Too many requests. Please wait and try again.',
});
},
});
Set up alerts for thresholds like:
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