Rate Limits¶
All limits are per IP address unless stated otherwise. The RateLimit-* headers
are returned on every limited response, so you can read your remaining budget
without guessing.
Limits by endpoint group¶
| Group | Limit | Window |
|---|---|---|
airtime, biller, data, user, agent, ai, debug |
300 | 1 minute |
biz, blacklist, blocklist, cables, cgwallet, education, electricity, network, product, transactions, wallet, admin, ip-allowlist, cron, user-context, schedule, crm, sandbox |
120 | 1 minute |
activity, auto-buy, category, promo, giveaway, faqs, feedback, file, gateway, keys, notification, permission, serviceicon, settings, signup-survey, slider, startime, upgrade, usecase, verify, voice-assistant, sim-gateway |
60 | 1 minute |
unsubscribe, webhooks |
unlimited | — |
These are the group-level limiters applied in utils/routes.js. Individual
routes layer stricter limits on top.
The purchase limit — 1 request / 5 seconds¶
A dedicated limiter caps purchase and wallet-transfer traffic:
const apiLimiterMaximumPurchase = rateLimit({
windowMs: 5 * 1000,
max: 1,
skip: (req) => req.method === 'OPTIONS',
});
But it only applies to session-authenticated channels. The dispatcher
selects the limiter by req.userAuth:
const MaximumLimiterPurchase = (req, res, next) => {
if (req.method === 'OPTIONS') return next();
let limiter = apiLimiter;
switch (req?.userAuth) {
case 'app':
case 'auth':
case 'web':
limiter = apiLimiterMaximumPurchase; // 1 req / 5 s
break;
default:
limiter = apiLimiter; // 60/min
}
limiter(req, res, next);
};
req.userAuth |
Effective purchase limit |
|---|---|
app |
1 request / 5 seconds |
auth |
1 request / 5 seconds |
web |
1 request / 5 seconds |
api (API key) |
60 requests / minute (group limiter) |
whatsapp-agent |
60 requests / minute (group limiter) |
API-key integrations are not throttled to one purchase every five seconds. Your real ceiling is the group limiter: 300/min for airtime and data, 120/min for cables, electricity and education.
This applies to:
POST /api/v1/{airtime,data,electricity,cables,education}/buyPOST /api/v1/biz/verifyPOST /api/v1/verify/verifyPUT /api/v1/wallet/manage-wallet(transfer)- every
POST /api/v1/agent/*buy and wallet route - every
POST /api/v1/sandbox/*purchase
Adaptive read limiters¶
Some read endpoints use a dispatcher that raises the limit for the highest traffic app/web screens:
const MaximumLimiter = (req, res, next) => {
let limiter = apiLimiter; // 60/min
switch (req?.userAuth) {
case 'app': case 'web': limiter = apiLimiterMaximum; break; // 120/min
default: limiter = apiLimiter; break; // 60/min
}
limiter(req, res, next);
};
| Limiter | app / web |
everyone else |
|---|---|---|
MaximumLimiter (120/min for app/web) |
120/min | 60/min |
MaximumTwentyLimiter |
60/min | 60/min |
MaximumLimiterPurchase |
1 req / 5 s | 60/min |
Affected reads include GET /api/v1/data/, /data/agent-data, /data/networks,
/cables/types, /electricity/, /education/, /network/, /settings/,
/slider/, /wallet/manage-wallet-balance, GET /api/v1/user/ and
GET /api/v1/usecase/.
Special-purpose limiters¶
| Limiter | Limit | Window | Applies to | Keyed on |
|---|---|---|---|---|
authLimiter |
50 | 15 min | POST /api/v1/user/{login,register,forgot-password,otp,resend-otp,reset-pin,reset-password,validate-otp} |
IP |
apiLimiterResetPassword |
3 | 15 min | PUT + POST /api/v1/user/reset-password |
IP |
apiLimiterWebhook |
240 | 1 min | Biller callback routes (AutoSyncNG, Ogdams, SES notifications) | IP |
apiLimiterAgentOtp |
5 | 10 min | POST /api/v1/agent/otp/send |
phone number |
| CRM limiter | 240 | 1 min | all /api/v1/crm/* |
IP |
apiLimiterAgentOtp is keyed on req.body.phoneNumber, not the IP, because
every WhatsApp purchase originates from the single Halo server — an IP key
would throttle all customers together.
Rate limit response¶
Some limiters use a different shape:
The status code varies
The default express-rate-limit response is HTTP 429. Some of the custom
limiters override the message but not the status, so 429 is the value to
handle. Branch on 429, and read RateLimit-* headers for the reset time.
The duplicate-request guard¶
Independent of the rate limiters, the platform actively blocks duplicate purchases. This is not a rate limiter — it is a correctness guard, and hitting it means your integration is retrying something that is already in flight.
Three layers, all applied before any money moves:
1. Atomic Redis lock. Keyed on
purchaselock:<kind>:<userId>:<phone>:<amount>[:<planId>], held for 180
seconds. Only the first request in a burst wins. Fails open if Redis is
unreachable.
2. Committed-transaction check. A database lookup for a matching transaction created in the last 180 seconds (excluding server-side retries). The message is precise:
"We just processed an identical request to 08012345678 42 seconds ago. If that one's still pending, please give it 138 seconds to settle before retrying."
3. Body hash. An in-memory hash of the exact request body, 60-minute TTL. An identical body within the hour is rejected with "Duplicate transaction detected".
All three return HTTP 300.
Practical guidance: if you get a duplicate-guard error, do not retry. Poll
GET /api/v1/transactions/resolve
with your apiRefId to find out what happened to the first attempt.
Practical budgets for an API-key integration¶
| Activity | Safe rate |
|---|---|
| Airtime / data purchases | 300/min group limit — but respect the duplicate guard and settle 300s first |
| Electricity / cables / education purchases | 120/min |
| Catalogue reads (plans, networks, discos) | 60–120/min |
| Transaction list / resolve | 120/min |
| Webhook endpoint (your server) | You control it — the platform retries failed deliveries up to 6 times |
Recommended production pattern: a small bounded queue (a few workers, not
hundreds) with a retry policy that only retries 500 and 503, treats 300
as "wait", and never retries 400 without a change to the request.