# 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

<!-- Newest first, plain language, max 5 entries. Every session that changes this
     guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. -->

- **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

```json
{
  "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." |

> `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:

```ts
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.

| `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-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.
