Rate limits
rate_limit_buckets, with a per-API hourly override and a Retry-After header on every refusal. Everything below is that sentence plus the machinery the other docs record around it — and a plain statement of which numbers are platform defaults rather than somebody's configuration.- Per key, per hour
- 1000 requests
- Per key, per day
- 10000 requests
- Counting
- Fixed windows in rate_limit_buckets
- Override
- api_configurations.rate_limit_per_hour
- On refusal
- 429 rate_limited + Retry-After seconds
7.1The documented policy
docs/backend-api-contract.md §1.4, as written
Default per key: 1000 req/hour, 10000 req/day (fixed windows, rate_limit_buckets); per-API override rate_limit_per_hour. 429 carries Retry-After seconds. Limits are documented in /docs and surfaced in the dashboard key UI.
| Term | Applies to | What the documents say |
|---|---|---|
1000 req / hour | per key | §1.4's default, and docs/database-schema.md puts NULL in api_configurations.rate_limit_per_hour squarely on it: “NULL = platform default (1000/h, 10000/day)”. |
10000 req / day | per key | The other half of the same sentence in §1.4. Nothing in the docs gives a per-API day override, so the hourly column is the only documented knob. |
Fixed windows | how they are counted | §1.4 names them and docs/database-schema.md shows why: the counter is a single-round-trip upsert keyed by {scope}:{id}:{window} with a window_start that resets once the interval has passed. |
rate_limit_per_hour | per API override | A nullable integer on the API row, exposed as ApiConfig.rateLimitPerHour in the dashboard payload, not on key metadata. Null uses the platform default; §1.4 does not specify how this override composes with the per-key buckets. |
429 + Retry-After | the refusal | §1.4 states the wait in seconds is carried by the Retry-After header, and §1.2 says the same from the error side. It is the only rate-limit response header the contract documents. |
§1.4 requires these limits to be surfaced in the dashboard key UI. These are documented defaults, not measured usage or a reading of your account's current configuration.
7.2When you are limited
docs/backend-api-contract.md §1.2 · §1.4 · §4.4
A refused request is not executed: rate_limit is checked in the pipeline before the spreadsheet is read, so a 429 costs you no rows and no Google call. The code is rate_limited, the category is rate_limit, and the wait is in the header.
curl 'https://sheet.zaber.tech/api/v1/<api-id>?limit=1000' \ -H "Authorization: Bearer sz_live_…"
HTTP/1.1 429 Too Many Requests
Retry-After: <seconds>
{
"error": {
"code": "rate_limited",
"message": "<text — not specified by the contract>",
"category": "rate_limit"
}
}Only the status, the code, the category and the presence of Retry-After are documented; the message text is a placeholder, so branch on the code.
- Wait for the header, not for a guess. §1.4 puts the seconds in
Retry-After. Internally, §4.4 has the same number asfields.retry_afterso a route and a server action read one value; on/api/v1there is no documentedfieldsmember, so read the header. - There are no remaining-quota headers to read. The contract documents
Retry-Afterand nothing else — noX-RateLimit-*set, no remaining/limit/reset triple. Build no backoff logic on numbers you cannot get. - Retrying inside the window repeats the refusal.
rate_limitis one of the two documented retryable categories, and both of them mean “later”, not “again now”.
7.3How the counting works
docs/backend-api-contract.md §1.4 · docs/database-schema.md · docs/plan.md D13
The counters are a Postgres table, not a cache. docs/database-schema.md lists rate_limit_buckets among the standalone counters: its primary key is a composed text string rather than a foreign key to a key or API row, so the bucket a request spends is named by that string alone.
rate_limit_buckets
bucket_key text PK "{scope}:{id}:{window}" e.g. key:<keyId>:h
count int NOT NULL
window_start timestamptz NOT NULL`h` is the hour window in the documented example key. Nothing in the docs names the day window's tag, so this page does not either.
Each request is one upsert: insert the bucket, or on conflict add one to count and reset window_start only when the interval has already elapsed. docs/database-schema.md calls that a single round-trip fixed-window counter and docs/plan.md D13 records the reasoning — a Postgres counter table with atomic upsert for the hour and day windows, per key and per public API, chosen over a Redis vendor at this scale, with request logs already available for analytics. The same D13 row names Upstash as the documented scale-up path, and docs/security.md §6 repeats it as the answer to the burst trade-off below.
7.4These are defaults, not your configuration
docs/backend-api-contract.md §1.4 · §4.10 · docs/database-schema.md
The numbers on this page are the platform's documented defaults. They are not a customer's settings, and today there is no documented way to raise a default: theupdatePlatformSettings action that would take defaultRateLimits is listed in §2 and marked blocked in §4.10 — no platform-settings table exists among the eleven, and the only rate-limit column in the schema is the per-API rate_limit_per_hour.
- What you can set: a lower hourly ceiling for one endpoint, via
rate_limit_per_hour. docs/database-schema.md describes write verbs on the API row separately, and the dashboard surfaces the count of endpoints that override. - What you cannot set, per the docs: the day ceiling, a per-key override, or a platform-wide change. Nothing in §1.4, §4.6 or docs/database-schema.md offers them, and §4.10 is explicit that the settings action is not typeable until a storage decision exists.
- What this page will not do: print a “contact us for higher limits” promise, a tier table, or a number that is not in the contract. No documented route to an increase exists in §1.4, §4.6 or §4.10, so this page offers none either.
7.5The other ceilings, next to the request limits
docs/backend-api-contract.md §1.2 · §1.3 · §1.5 · docs/architecture.md · docs/security.md §2
A 429 means you asked too often. These mean you asked for too much at once, and they refuse with different codes — worth separating before you write a retry loop.
| Ceiling | Scope | Documented behaviour |
|---|---|---|
1 MB | request body | §1.2 refuses anything larger with 413 body_too_large, and docs/architecture.md's engine pipeline makes it step 1: parse the body under a 1 MB ceiling before authentication even runs. |
limit ≤ 1000 | page size | §1.3 bounds the list limit parameter to 1–1000 rows with all rows as the documented default. A big page is a big response, not a way around the request counter. |
≤ 500 objects | create array | §1.3 caps one POST at 500 records. Read the note on the Operations page first: docs/google-integration.md §4 documents v1 writes as one record per request, so the array form is the part of the contract the two documents disagree about. |
5 per 15 min | invitation attempts | Not a /api/v1 limit: contract §2 and §4.5 require a DB-backed fixed-window bucket inside the invitation service, per pending cookie and per IP, answered 429 rate_limited. The contract explicitly supersedes the earlier in-memory description in the security and authentication documents. |
Google's own quota | upstream | Google quota is separate from Sheet Zaber's request windows. §1.5 states the engine maps an upstream Google 429 to 503 google_quota_exceeded with Google's Retry-After rather than inventing a number. |
7.6Trade-offs the docs state out loud
docs/security.md §6 · docs/plan.md D13
- A fixed window can burst roughly twice at its edges. docs/security.md §6 lists this as an accepted trade-off, with the reasoning that the windows are small and the Upstash upgrade path is documented. In practice: 1000 requests just before the hour turns over and 1000 just after is a legal burst of 2000 in an hour of wall-clock time — so the day ceiling, not the hour one, is what bounds a runaway client.
- The in-memory invitation note is superseded. docs/security.md §6 still describes a cold-instance reset, but contract §2 explicitly requires a DB fixed-window invitation bucket shared across instances. That older trade-off is not the current contract.
- “Sliding-window” appears in one document and not in the others. docs/architecture.md’s engine walkthrough describes step 4 as sliding-window counters, while §1.4 and docs/database-schema.md both say fixed windows and describe a fixed-window upsert. This page follows §1.4; the discrepancy is recorded for the maintainers rather than smoothed over.
- Buckets are pruned, limits are not journalled. docs/database-schema.md notes
rate_limit_bucketsis pruned nightly alongside the logs, so there is no historical record of when you were limited.api_request_logsis where a 429 shows up — with theerror_categorycolumn that storesrate_limitas one of its six documented values.
7.7Calling within the limits
Guidance derived from §1.3 · §1.4 · §1.5 — the contract itself does not prescribe a client
This section is advice, and it says so. What the contract gives you is fixed windows, a documented page size, and one honest response header; the rest follows from those.
- Page instead of polling.
limitandoffset(orpage/per_page, which §1.3 defines asoffset = (page-1)*per_page) get a large tab through 1000 requests an hour. Mixing the two families is a documented400. - Ask for the columns you use.
fields/selectis applied after filtering, so it shrinks the response, not the counter. - One in-flight request per key, and back off on 429 by Retry-After.
rate_limitandgoogleare the two retryable categories; treat the other four as “changing nothing”. - Remember whose quota you are spending. Every read and write runs on the spreadsheet owner's Google grant, so a client that hammers the API spends a person's upstream quota as well as its own bucket.