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

# Rate limits and quotas

> There are three independent controls. A request must pass all of them.

There are **three** independent controls. A request must pass all of them.

| Layer         | Scope                 | Purpose                          | On failure                   |
| ------------- | --------------------- | -------------------------------- | ---------------------------- |
| Per-key       | one API key           | one integration cannot flood us  | `429 RATE_LIMIT_EXCEEDED`    |
| Per-tenant    | all keys in a company | one *company* cannot flood us    | `429 RATE_LIMIT_EXCEEDED`    |
| Monthly quota | the company's plan    | usage stays within what was sold | `429 QUOTA_EXCEEDED` / `402` |

They answer different questions, which is why a key can be well under its own
limit and still be refused: the company around it may not be.

***

## Per-key limits

Set per key on the `api_key` row: `rate_limit_per_minute`, `rate_limit_per_hour`.
The minute window is **per endpoint**; the hour window is across the whole key.

> **`rate_limit_per_day` is stored but not enforced.** The key row (and the
> create-key form) carries a per-day value, but the limiter checks only the minute
> and hour windows — there is no per-key daily window. Daily volume is governed by
> the per-tenant daily cap below and by the monthly quota. Decided under
> : the field stays for forward compatibility and is documented as
> unenforced rather than adding a third Redis round-trip per request. Do not
> advertise it as a limit.

## Per-tenant ceiling

Aggregate across **every key the company holds**. This exists because the per-key
limiter cannot see a tenant spreading the same load over twenty keys — each stays
under its own ceiling while the company as a whole saturates the service.

Resolved from the plan, in this order:

1. `package.entitlements.api_rate_limit_per_minute` / `.api_daily_cap` — explicit override
2. Derived from `package.api_call_cap` — the monthly cap spread over 30 days
3. Default — **600/minute**, no daily cap

Two things worth knowing:

* **An unlimited plan still has a per-minute ceiling.** "Unlimited" describes the
  volume sold, not permission to saturate the service in one burst.
* **The default is deliberately generous.** This layer is a protective backstop,
  not a meter — that is the monthly quota's job. A ceiling set near a plausible
  legitimate burst would turn protection into an outage during a normal bulk import.

## Monthly quota

Counted against `package.api_call_cap` for the billing period. `NULL` means
unlimited / fair use.

***

## Headers

On **every** response:

| Header                  | Meaning                                   |
| ----------------------- | ----------------------------------------- |
| `X-RateLimit-Limit`     | requests allowed in the current window    |
| `X-RateLimit-Remaining` | requests left in that window              |
| `X-RateLimit-Reset`     | epoch milliseconds when the window resets |

The figures describe whichever layer is **closest to rejecting** — the per-key or
the per-tenant window, whichever has less headroom. Reporting the more generous one
would tell a client it had thousands of requests left moments before a 429.

On a **429**, additionally:

| Header        | Meaning                    |
| ------------- | -------------------------- |
| `Retry-After` | seconds to wait. Never `0` |

When the **monthly** quota is exhausted:

| Header              | Meaning                                |
| ------------------- | -------------------------------------- |
| `X-Quota-Limit`     | the plan's monthly cap, or `unlimited` |
| `X-Quota-Used`      | calls used this period                 |
| `X-Quota-Remaining` | calls left this period                 |

These use **different names on purpose**. A client out of monthly allowance must
not read a healthy `X-RateLimit-Remaining` and conclude it can keep going.

You do not have to wait for a refusal to see where you stand: `GET /v1/usage`
(scope `billing:view`) returns the same used / limit / remaining figures, plus the
wallet balance and consumable spend for the period. `GET /v1/subscription` returns
the plan, seats, trial state and entitled modules.

***

## What a 429 does *not* tell you

The body carries `limit`, `remaining`, `reset` and `retry_after` — what a
well-behaved client needs to back off correctly — and nothing about how the limit
is enforced. **Which layer tripped is deliberately not disclosed**, because that
would tell someone probing the limits whether spreading load across more keys
would help.

***

## When things are down

The three layers behave **differently on failure**, on purpose:

* **Rate limiting fails OPEN.** If Redis is unreachable, requests are allowed.
  Taking the API down because a cache is missing would cause exactly the outage the
  limiter exists to prevent. This covers BOTH cases: Redis not configured at all,
  and Redis configured but erroring (network blip, exceeded quota, bad token). Both
  log loudly — a limiter that silently stops limiting is how a dead Redis instance
  went unnoticed for five months.
* **The monthly quota fails CLOSED.** If the quota engine cannot be reached, the
  request is refused with `503 QUOTA_UNAVAILABLE`. This one is about what a tenant
  has paid for; serving requests we cannot account for gives away metered calls with
  no record.

If the limiter answers incoherently — allowing a request while reporting a limit of
`0`, which is what a misconfigured Upstash returns — the API does **not** pass that
through. `X-RateLimit-Limit: 0` on a request that was just served tells a client it
has no quota while it demonstrably does, and a client honouring that header would
back off for no reason. The configured limit is reported instead, and the broken
limiter is logged.

A quota failure also raises a `billing_alert` (`kind: api_quota_unavailable`,
severity `critical`) so an operator is told — while it lasts, **every** request for
that tenant is being refused, and that should not sit unnoticed in a log.

***

## Where the counters live

Counters are kept in Upstash Redis. Every key is namespaced by the Supabase
project ref, so **dev and production can share a single Redis database** without
their counters mixing — which they normally will, since the Upstash free plan
allows one database.

That namespacing is not a convenience. Identifiers happen to be distinct between
environments today, but restore a production backup into dev and every id matches;
and the per-IP limiter keys on IP address, where a developer and a real user in the
same office would already collide.

If you ever split them onto separate databases, nothing needs changing — the
namespace is derived automatically, not configured.

***

## Handling limits as a client

1. Read `Retry-After` and wait that long. Do not retry immediately.
2. Use `X-RateLimit-Remaining` to slow down *before* you are refused.
3. Treat `503 QUOTA_UNAVAILABLE` as retryable — it is our fault, not yours.
4. Treat `429 QUOTA_EXCEEDED` and `402` as **not** retryable without a plan change.
