> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phosra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Rate limits

> The per-window request quota, the exact response headers to read, and a copy-paste backoff loop — every number verified live against the sandbox.

Every `/api/v1/*` response tells you exactly where you stand in your window, so
you never have to guess your quota. Read the headers on the response you already
have — do not hard-code a number.

<Info>
  **Runnable.** Every header and status on this page was captured live from the
  partner sandbox at `https://phosra-api-sandbox-production.up.railway.app` on
  **2026-07-06**. The `/api/v1/platforms` read used below needs no API key, so you
  can reproduce the exact bytes.
</Info>

## The limit

| Tier               | Default limit    | Window                 | Keyed by                                                          |
| ------------------ | ---------------- | ---------------------- | ----------------------------------------------------------------- |
| **Sandbox / free** | **100 requests** | per **60 s** (rolling) | your developer org (authenticated) or source IP (unauthenticated) |
| **Paid**           | Raised per plan  | per 60 s               | your developer org                                                |

The default of **100 requests/minute** is the free-tier org limit
(`DefaultFreeRateLimitRPM`). Authenticated calls are counted **per developer org**
— every key in the org shares one bucket — so adding keys does not add quota.
Unauthenticated calls (the sandbox read routes) are counted **per source IP**.

<Note>
  **Discovery reads are unmetered.** The unauthenticated OCSS discovery surface —
  `/.well-known/ocss/trust-list`, profiles, and Annex B editions — carries **no**
  rate-limit headers and is not counted against your window. Only the `/api/v1/*`
  surface is metered. Poll discovery cheaply with [ETags](/versioning#conditional-reads-and-caching).
</Note>

## Read your window off every response

Every metered response carries three headers. Read them and you always know your
exact standing — no fixed quota to hard-code.

| Header                  | Meaning                                       | Example      |
| ----------------------- | --------------------------------------------- | ------------ |
| `X-RateLimit-Limit`     | Ceiling for the current window                | `100`        |
| `X-RateLimit-Remaining` | Requests left before a `429`                  | `99`         |
| `X-RateLimit-Reset`     | **Unix epoch seconds** when the window resets | `1783321140` |

```bash curl theme={null}
curl -s -D - -o /dev/null \
  https://phosra-api-sandbox-production.up.railway.app/api/v1/platforms \
  | grep -i x-ratelimit
```

```text Response headers theme={null}
x-ratelimit-limit: 100
x-ratelimit-remaining: 99
x-ratelimit-reset: 1783321140
```

<Warning>
  `X-RateLimit-Reset` is **Unix epoch seconds**, not a duration and not
  milliseconds. To sleep until reset: `wait = max(0, reset - now_seconds)`.
</Warning>

## What a 429 looks like

When you exhaust the window you get `429 Too Many Requests` with `Retry-After`
(seconds) **and** the same three counters, so a single response tells you both how
long to wait and that you are fully drained (`remaining: 0`). Captured live after
exhausting the 100-request window:

```text Response · 429 (per-IP limiter) theme={null}
HTTP/2 429
content-type: text/plain; charset=utf-8
retry-after: 60
x-ratelimit-limit: 100
x-ratelimit-remaining: 0
x-ratelimit-reset: 1783321440

Too Many Requests
```

<Note>
  **Two 429 bodies, one contract.** The per-IP throttle on unauthenticated routes
  returns the plaintext body above. The **per-org** limiter on authenticated routes
  returns the machine-readable house envelope instead — same status, same intent:

  ```json Response · 429 (per-org limiter) theme={null}
  { "error": "Too Many Requests", "message": "rate limit exceeded", "code": 429 }
  ```

  Branch on the **status code** (`429`) and honour `Retry-After` — never on the
  body shape. See the [errors reference](/errors#rate-limiting).
</Note>

## Handle it: wait for the reset, then retry

Do not retry a `429` immediately — sleep until the window resets, then send the
request exactly once. `Retry-After` (seconds) is the simplest signal;
`X-RateLimit-Reset` (absolute) is equivalent.

<CodeGroup>
  ```bash curl theme={null}
  BASE=https://phosra-api-sandbox-production.up.railway.app
  # Dump headers + status for one request
  HDRS=$(curl -s -D - -o /dev/null -w "HTTP_CODE:%{http_code}" \
    -H "Authorization: Bearer $PHOSRA_API_KEY" \
    "$BASE/api/v1/families")

  if echo "$HDRS" | grep -q "HTTP_CODE:429"; then
    RESET=$(echo "$HDRS" | tr -d '\r' | awk 'tolower($1)=="x-ratelimit-reset:"{print $2}')
    WAIT=$(( RESET - $(date +%s) )); [ "$WAIT" -lt 1 ] && WAIT=1
    sleep "$WAIT"
    # ...then retry the request once
  fi
  ```

  ```typescript TypeScript theme={null}
  async function withRateLimit<T>(call: () => Promise<Response>): Promise<Response> {
    let res = await call();
    if (res.status === 429) {
      // Prefer Retry-After (seconds); fall back to the absolute reset.
      const retryAfter = Number(res.headers.get("retry-after"));
      const reset = Number(res.headers.get("x-ratelimit-reset"));
      const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
        ? retryAfter * 1000
        : Math.max(0, reset * 1000 - Date.now());
      await new Promise((r) => setTimeout(r, waitMs));
      res = await call(); // retry exactly once
    }
    return res;
  }
  ```

  ```python Python theme={null}
  import time, requests

  def with_rate_limit(request_fn):
      res = request_fn()
      if res.status_code == 429:
          retry_after = res.headers.get("retry-after")
          if retry_after and retry_after.isdigit():
              wait = int(retry_after)
          else:
              reset = int(res.headers.get("x-ratelimit-reset", "0"))
              wait = max(0, reset - int(time.time()))
          time.sleep(wait)
          res = request_fn()  # retry exactly once
      return res
  ```

  ```go Go theme={null}
  func withRateLimit(do func() (*http.Response, error)) (*http.Response, error) {
  	res, err := do()
  	if err != nil {
  		return nil, err
  	}
  	if res.StatusCode == http.StatusTooManyRequests {
  		wait := time.Duration(0)
  		if ra, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
  			wait = time.Duration(ra) * time.Second
  		} else if reset, err := strconv.ParseInt(res.Header.Get("X-RateLimit-Reset"), 10, 64); err == nil {
  			if d := time.Until(time.Unix(reset, 0)); d > 0 {
  				wait = d
  			}
  		}
  		time.Sleep(wait)
  		return do() // retry exactly once
  	}
  	return res, nil
  }
  ```
</CodeGroup>

## Staying under the limit

<CardGroup cols={2}>
  <Card title="Read, don't assume" icon="gauge-high">
    Watch `X-RateLimit-Remaining` and slow down as it approaches `0` instead of
    sprinting into a `429`.
  </Card>

  <Card title="Poll discovery with ETags" icon="tag">
    Trust-list and edition reads are unmetered and support `If-None-Match` — a
    `304` costs you nothing. See [Versioning](/versioning#conditional-reads-and-caching).
  </Card>

  <Card title="Batch where the API allows" icon="layer-group">
    Prefer bulk-upsert rule writes over one call per rule; prefer a single
    fan-out enforce over per-child calls.
  </Card>

  <Card title="One bucket per org" icon="building">
    Adding keys does not add quota — they share the org window. Need more?
    Contact us to raise `rate_limit_rpm` on your plan.
  </Card>
</CardGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Limits & quotas" icon="ruler" href="/limits">
    The resource ceilings — page sizes, body caps, ID lengths — distinct from this `429` throttle.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/errors#rate-limiting">
    The full status/class table, including where the `429` sits.
  </Card>

  <Card title="Pagination" icon="list-ol" href="/pagination">
    Bound big reads so you make fewer, cheaper requests.
  </Card>
</CardGroup>
