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

# Rate limits and 429 handling

> Per-key minute + day buckets. Stripe-compatible X-RateLimit-* headers. Read Retry-After and back off.

# Rate limits

Marea uses **per-key minute and day buckets** with Stripe-compatible headers. Counts increment **before** the handler runs, so 4xx and 5xx responses still count against your quota — that's deliberate, and it defends against spam-of-invalid-requests abuse.

## Headers on every response

```http theme={null}
X-RateLimit-Limit: 60                ← rpm (requests-per-minute) for this key
X-RateLimit-Remaining: 47            ← remaining in the current minute window
X-RateLimit-Reset: 1714867260        ← epoch seconds when the minute rolls over
```

When you hit a limit, you also get:

```http theme={null}
HTTP/1.1 429 Too Many Requests
Retry-After: 23
```

`Retry-After` is in seconds. For minute-bucket overflow it's seconds-until-the-next-minute (always ≤ 60). For day-bucket overflow it can be up to **86,400 seconds** (a full day).

<Note>
  The headers report on the **minute bucket only** — there is no `X-RateLimit-Remaining-Day` header. Use `GET /v1/me`'s `rateLimit.remainingDay` for the day-bucket counter.
</Note>

## Default budgets

Read your live values from `GET /v1/me` (the `rateLimit` object). Values stored on the API key doc at issuance time:

| Key prefix  | Key type        | `rpm` | `rpd`        | Notes                                                                                                                                  |
| ----------- | --------------- | ----- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| `mk_user_*` | User scope      | 60    | 10,000       | Primary issuance via `POST /v1/users` (bootstrap, in the `userKey` field). Additional per-user keys via `POST /v1/users/:userId/keys`. |
| `mk_dev_*`  | Developer scope | 60    | 50 (default) | The dev-key `rpd` is the **bootstrap-per-day ceiling** — see below.                                                                    |

<Warning>
  **The developer key's `rpd` is intentionally tiny.** Developer keys exist to bootstrap accounts (`POST /v1/users`) and issue per-user keys (`POST /v1/users/:userId/keys`), not to drive sustained traffic. The `rpd: 50` default is the bootstrap-per-day cap (configurable at issuance time per the `IssueDeveloperKeyParams` contract). Once an account is bootstrapped, route all subsequent traffic through that account's `mk_user_*` key — that one gets the full 10,000/day budget.

  The middleware does not distinguish "this request is a bootstrap call" from "this request is something else" — it simply enforces whatever `rpd` is stored on the developer key. If you exhaust 50 requests on a dev key in a single day, you're locked out of that dev key for the rest of the UTC day, full stop.
</Warning>

To check your remaining budget at any time, call `GET /v1/me`. The response includes:

```json theme={null}
{
  "rateLimit": {
    "rpm": 60,
    "rpd": 50,
    "remainingMinute": 60,
    "remainingDay": 47
  }
}
```

<Note>
  **Header vs. body precision.** The exact `remainingMinute` value lives in the response headers (`X-RateLimit-Remaining`). The `/v1/me` body's `remainingMinute` is approximate — it's the value at response-assembly time, not at your next request. For backoff logic, trust the headers.
</Note>

## 429 response shape

```json theme={null}
{
  "error": {
    "type": "rate_limited",
    "code": "rate_limit_exceeded",
    "message": "Rate limit exceeded (rpm_exceeded). Retry after 23s.",
    "recoverable": true,
    "retryAfterMs": 23000,
    "nextActions": [
      { "label": "Wait 23s and retry the same request.", "method": null, "url": null }
    ]
  }
}
```

The `message` field tells you which bucket overflowed:

* `message` contains `rpm_exceeded` → minute bucket. Wait `Retry-After` seconds (always ≤ 60), then retry.
* `message` contains `rpd_exceeded` → day bucket. `Retry-After` is the seconds-remaining-in-the-day (up to 86,400). **Do not retry tight; wait the indicated time or escalate to the user.**

The `retryAfterMs` field on the error body mirrors `Retry-After` in milliseconds. Both `recoverable: true` and `nextActions[0].label` indicate that the call is safe to repeat once the window opens.

## Recommended agent backoff

Minute-bucket overflow:

```
attempt 1: hit 429, message contains "rpm_exceeded", Retry-After: 23
                                                  → sleep 23s, retry
attempt 2: succeed (200)
```

Day-bucket overflow:

```
attempt 1: hit 429, message contains "rpd_exceeded", Retry-After: 41200
                                                  → surface to user:
                                                    "Daily quota reached.
                                                     Try tomorrow or upgrade."
                                                  → DO NOT auto-retry
```

When using `Idempotency-Key`, **keep the same key across retries** — the server treats the eventual successful call as a replay-safe completion of the original request. See [Safe mutations](/concepts/safe-mutations).

## What counts against your quota

Every authenticated request to a `/v1` endpoint — 200, 4xx, 5xx — counts. The increment happens **before** the handler runs, so a malformed body that the handler would have rejected with a 422 still consumes one minute-bucket slot and one day-bucket slot.

Exceptions:

* Unauthenticated requests (rejected at the apiKey middleware before rate-limit increments) do not count.
* The health-check shortcut at `/healthz` does not pass through the rate-limit middleware.
* If the rate-limit Firestore transaction itself fails (infrastructure error), the request is **failed open** — it proceeds without an increment and without a 429. A structured log entry is emitted so the 5xx-rate alert surfaces the underlying infra failure.

## Conventions

Stripe-compatible headers and semantics — `X-RateLimit-*` plus `Retry-After`. The same backoff library you'd use for Stripe works here unchanged.

Buckets are **per key, not per account**. If a single user has both `mk_user_keyA` and `mk_user_keyB`, each gets its own 60/10,000 budget. This is by design: a misbehaving integration on one key can't starve a well-behaved integration on another key for the same user.
