Skip to content

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}/buy
  • POST /api/v1/biz/verify
  • POST /api/v1/verify/verify
  • PUT /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

{
  "error": [
    { "msg": "Too many requests, please try again later" }
  ]
}

Some limiters use a different shape:

{ "status": "error", "msg": "Too many webhook callbacks from this IP." }
{ "status": "error", "msg": "Too many reset password attempts. Please try again later." }

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.