# Rate Limits & Errors

> Per-key limits, the headers that expose them, and how to back off.

Canonical: https://grout.app/developer/documentation/setting-up/rate-limits/

## Limits

Each key has its own limit, default **600 requests per minute**, configurable from 10 to 6 000 when the key is created or edited. The window is a fixed 60-second bucket.

| Header | Meaning |
|---|---|
| `X-RateLimit-Limit` | Requests allowed per minute for this key |
| `X-RateLimit-Remaining` | Requests left in the current window |
| `X-RateLimit-Reset` | Unix time (seconds) when the window resets |
| `Retry-After` | Only on 429/503: seconds to wait |

When the limit is exceeded you get `429 rate_limited`. The limiter fails **closed**: if its backing store is unreachable you get `503 rate_limiter_unavailable` with `Retry-After: 5` rather than an unlimited pass.

## Backing off

```js
async function call(url, init, attempt = 0) {
  const res = await fetch(url, init);
  if ((res.status === 429 || res.status === 503) && attempt < 5) {
    const wait = Number(res.headers.get('Retry-After') || 2 ** attempt);
    await new Promise((r) => setTimeout(r, wait * 1000));
    return call(url, init, attempt + 1);
  }
  return res;
}
```

## Bulk work

- Use `POST /students/bulk` (≤ 200 per call) instead of looping `POST /students`.
- Page through lists with `limit=200`.
- Prefer webhooks to polling; if you must poll, use `GET /events?since=` once a minute rather than re-listing resources.

## Idempotency

Writes that carry a `ref` are idempotent on it: merit credits, Alpha point awards, webhook event replays. Provisioning calls are not idempotent — store the returned `login_email` and check before re-creating. A dedicated `Idempotency-Key` header is on the roadmap.

## Error reference

| Status | Codes |
|---|---|
| 400 | `institution_required` (platform admins only) |
| 401 | `unauthorized`, `invalid_api_key` |
| 403 | `insufficient_scope`, `forbidden`, `institution_not_approved` |
| 404 | `not_found`, `user_not_found`, `no_secret` |
| 409 | `conflict`, `already_added`, `already_verified`, `user_inactive`, `insufficient_allowance`, `endpoint_inactive`, `void_failed` |
| 410 | `event_pruned` |
| 422 | `validation_error`, `invalid_faculty`, `invalid_scopes`, `no_owner`, `no_valid_students`, `https_required`, `private_host`, `events_required`, `endpoint_limit`, `key_limit`, `file_too_large` |
| 429 | `rate_limited` |
| 500 | `internal_error` |
| 501 | `not_implemented` |
| 503 | `rate_limiter_unavailable` |
