Skip to main content
Version: Latest

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" plus limit, scope, max, windowSeconds and retryAfter. Write one handler, branch on limit, and wait retryAfter seconds 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's RATE_LIMIT_EXCEEDED code) are gone; if you match on any of those, switch to code === "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
}
FieldMeaning
codeAlways "RATE_LIMITED". This is what you branch on, not the error string.
limitWhich cap fired. A bucket name from the tables below, or "global".
scopeWhat the cap counts against — see below. Decides the wording you show.
maxRequests allowed per window.
windowSecondsWindow length. Absent when limit is "global" — use retryAfter there.
retryAfterSeconds to wait. Matches the Retry-After header. Honour it; do not retry sooner.

scope values:

scopeCounted againstSay something like
userthe signed-in account"You're doing that too fast — try again in a moment."
clientthe account, or the onboarding session, or the IP if neithersame as user
ipthe network address"Too many attempts from this network."
recipientthe email or phone being targeted"Too many codes sent to this address."

retryAfter on 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:

  1. Never auto-retry without waiting retryAfter. A retry inside the window is still counted, so a retry loop keeps its own window alive indefinitely.
  2. 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.
  3. 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.

limitCap
globalConfigured 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.

limitCapEndpoint
post-like, post-save, post-interest, comment-like30 / 10slike, save, interest, comment-like
chat-reaction30 / 10schat reactions
global-search, user-search, mention-search, chat-search30 / 10sthe search boxes — debounce, don't fire per keystroke
chat-send60 / 60ssend message
post-share60 / 60sshare link
gif-search, gif-trending60 / 60sGIF picker
post-repost30 / 10minrepost
catalog-post-types, catalog-topics, catalog-suggest-categories, catalog-intents, schools-states30 / 60spickers — cache these, they barely change
catalog-post-type-detail, schools-districts, schools-search60 / 60spickers
report-create20 / 1hfile a report
post-request-submit100 / 24hpost requests
schools-submit5 / 1hsubmit a missing school

Auth and OTP caps — these are the ones a user actually notices.

limitCapscopeEndpoint
forgot-password5 / 1hrecipientPOST /api/auth/forgot-password, per email address
forgot-password-ip20 / 1hipsame endpoint, per network
verify-reset-code10 / 1hipPOST /api/auth/verify-reset-code
reset-password10 / 1hipPOST /api/auth/reset-password
otp-request:<purpose>5 / 15minrecipientPOST /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-password always 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 retryAfter from the response.
  • Retry-After is 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 limit before deciding what to show.
⤓ Download .md