Sheet Zaber · Reference

/api/v1 documentation

The contract in docs/backend-api-contract.md §1, rendered from the same snippet builders the playground uses. Private platform, public documentation — no signup on these pages.

Docs · 07 Rate limits

Rate limits

§1.4 of docs/backend-api-contract.md is the whole policy in two sentences: 1000 requests an hour and 10000 requests a day, per key, counted in fixed windows in 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

§1.4
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.
TermApplies toWhat the documents say
1000 req / hourper 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 / dayper keyThe 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 windowshow 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_hourper API overrideA 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-Afterthe 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
curl 'https://sheet.zaber.tech/api/v1/<api-id>?limit=1000' \
  -H "Authorization: Bearer sz_live_…"
429 · §1.2 shape, §1.4 header
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 as fields.retry_after so a route and a server action read one value; on /api/v1 there is no documented fields member, so read the header.
  • There are no remaining-quota headers to read. The contract documents Retry-After and nothing else — no X-RateLimit-* set, no remaining/limit/reset triple. Build no backoff logic on numbers you cannot get.
  • Retrying inside the window repeats the refusal. rate_limit is 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.

docs/database-schema.md
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.

CeilingScopeDocumented behaviour
1 MBrequest 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 ≤ 1000page 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 objectscreate 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 mininvitation attemptsNot 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 quotaupstreamGoogle 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_buckets is pruned nightly alongside the logs, so there is no historical record of when you were limited. api_request_logs is where a 429 shows up — with the error_category column that stores rate_limit as 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. limit and offset (or page/per_page, which §1.3 defines as offset = (page-1)*per_page) get a large tab through 1000 requests an hour. Mixing the two families is a documented 400.
  • Ask for the columns you use. fields/select is 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_limit and google are 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.