Skip to main content
There are three independent controls. A request must pass all of them. 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: 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: When the monthly quota is exhausted: 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.