Mobile Rate Limits — Integration Guide
Any endpoint can answer 429. Until now you could not tell which cap you hit — the global per-IP floor and a feature cap like "30 likes per 10 seconds" returned different, equally anonymous messages. Every 429 now carries
code: "RATE_LIMITED"and names the cap. One shape, one handler.
- API base URL (local):
http://localhost:3001 - Auth:
Authorization: Bearer <accessToken>— most caps are counted per account, so they follow the user across devices and networks
What's new
- 2026-09-15 — Every 429 now returns the same body:
code: "RATE_LIMITED"pluslimit,scope,max,windowSecondsandretryAfter. Write one handler, branch onlimit, and waitretryAfterseconds instead of retrying blind.limit: "global"means the network-wide floor, not anything you did on that screen — back the whole app off, don't show a feature-specific message. The old strings ("Rate limit exceeded, retry in 24 seconds", a bare"Too many requests", and OTP'sRATE_LIMIT_EXCEEDEDcode) are gone; if you match on any of those, switch tocode === "RATE_LIMITED".
The 429 body
{
"success": false,
"error": "Too many requests",
"code": "RATE_LIMITED",
"limit": "post-like",
"scope": "user",
"max": 30,
"windowSeconds": 10,
"retryAfter": 10
}
| Field | Meaning |
|---|---|
code | Always "RATE_LIMITED". This is what you branch on, not the error string. |
limit | Which cap fired. A bucket name from the tables below, or "global". |
scope | What the cap counts against — see below. Decides the wording you show. |
max | Requests allowed per window. |
windowSeconds | Window length. Absent when limit is "global" — use retryAfter there. |
retryAfter | Seconds to wait. Matches the Retry-After header. Honour it; do not retry sooner. |
scope values:
scope | Counted against | Say something like |
|---|---|---|
user | the signed-in account | "You're doing that too fast — try again in a moment." |
client | the account, or the onboarding session, or the IP if neither | same as user |
ip | the network address | "Too many attempts from this network." |
recipient | the email or phone being targeted | "Too many codes sent to this address." |
retryAfteron a feature cap is the full window, not the time actually remaining. It is a safe upper bound, never an under-estimate — so a countdown built on it may look slightly pessimistic.limit: "global"sends the exact remaining time.
Handling it
One place, not per screen:
if (res.status === 429) {
const body = await res.json();
if (body.code === "RATE_LIMITED") {
if (body.limit === "global") {
// Not this screen's fault — the whole app is over the per-IP floor.
// Pause background polling/prefetch too, or you re-trigger it instantly.
backOffEverything(body.retryAfter);
} else {
disableThisAction(body.limit, body.retryAfter);
}
}
}
Three rules:
- Never auto-retry without waiting
retryAfter. A retry inside the window is still counted, so a retry loop keeps its own window alive indefinitely. limit: "global"is app-wide. It is the per-IP floor across every endpoint, so a screen that just loads four things in parallel can trip it without any of those four being over their own cap. Back off globally, including timers and prefetch, and do not blame the screen in the UI.- Parallel fan-out counts. Four calls on screen open are four requests against the floor. Prefer one sequential pass, or accept the budget below.
The caps
Global floor — every endpoint, counted per IP.
limit | Cap |
|---|---|
global | Configured per environment. Ask before assuming a number; it is not the same locally and in production. |
Everyone behind one office/school Wi-Fi shares this bucket.
Feature caps — counted per account unless scope says otherwise.
limit | Cap | Endpoint |
|---|---|---|
post-like, post-save, post-interest, comment-like | 30 / 10s | like, save, interest, comment-like |
chat-reaction | 30 / 10s | chat reactions |
global-search, user-search, mention-search, chat-search | 30 / 10s | the search boxes — debounce, don't fire per keystroke |
chat-send | 60 / 60s | send message |
post-share | 60 / 60s | share link |
gif-search, gif-trending | 60 / 60s | GIF picker |
post-repost | 30 / 10min | repost |
catalog-post-types, catalog-topics, catalog-suggest-categories, catalog-intents, schools-states | 30 / 60s | pickers — cache these, they barely change |
catalog-post-type-detail, schools-districts, schools-search | 60 / 60s | pickers |
report-create | 20 / 1h | file a report |
post-request-submit | 100 / 24h | post requests |
schools-submit | 5 / 1h | submit a missing school |
Auth and OTP caps — these are the ones a user actually notices.
limit | Cap | scope | Endpoint |
|---|---|---|---|
forgot-password | 5 / 1h | recipient | POST /api/auth/forgot-password, per email address |
forgot-password-ip | 20 / 1h | ip | same endpoint, per network |
verify-reset-code | 10 / 1h | ip | POST /api/auth/verify-reset-code |
reset-password | 10 / 1h | ip | POST /api/auth/reset-password |
otp-request:<purpose> | 5 / 15min | recipient | POST /api/verification/send, per address and purpose |
limit on the OTP cap carries the purpose — otp-request:password_reset,
otp-request:email_verification — so you can tell "too many reset codes" from
"too many verification codes" for the same address.
Two things that are not rate limits and do not look like one:
- Wrong OTP three times invalidates the code: 400,
code: "MAX_ATTEMPTS_EXCEEDED". The user must request a new code — there is nothing to wait out. POST /api/auth/forgot-passwordalways returns 200, even when the address has no account and even when its own cap was hit behind the scenes. That is deliberate: a different status would tell an attacker which addresses exist. Never infer "the email was sent" from the 200.
Things worth knowing
- Counters are per server process and reset on deploy. Do not build a client
mirror of the budget and assume it matches; read
retryAfterfrom the response. Retry-Afteris on the response headers as well as in the body, if your networking layer already handles that header.- A 429 from the global floor can arrive on a request whose own feature cap has
plenty of room. That is expected — check
limitbefore deciding what to show.