Rate Limits
Picora applies tiered rate limits per API Key (and per OAuth token) to keep the platform fair and the Workers Free Plan within budget. Rate limits are per-account, not per-IP — sharing a Key across machines doesn’t multiply the budget.
Tiers
| Tier | Endpoints | Limit | Burst |
|---|---|---|---|
| read | GET list/detail, /v1/auth/verify, /v1/user/me/* | 600 / minute | 30 |
| upload | POST /v1/{images,videos,audio,docs} | 60 / minute | 5 |
| mutation | PATCH / DELETE (incl. batch) | 120 / minute | 10 |
Plus a 5000 requests / hour global ceiling per Key. Admin users get a ×10 multiplier.
Response headers
Every successful API response carries the rate-limit signal in two header families so you can pick whichever your client library understands:
- RFC 9239 form (recommended for new clients):
RateLimit-Limit— total budget for the current windowRateLimit-Remaining— what’s leftRateLimit-Reset— Unix seconds until the window resets
- Legacy GitHub-style (Scalar Try-it, older OpenAPI tools):
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
Both expose the same numbers; pick whichever your stack already understands. Browsers can read both via the CORS Access-Control-Expose-Headers allowlist.
When you hit the limit
HTTP/1.1 429 Too Many RequestsRetry-After: 12Content-Type: application/json
{ "success": false, "error": "Rate limit exceeded", "code": "RATE_LIMITED", "meta": { "retryAfterSec": 12 }}Retry-After is always set to the integer number of seconds you should wait before retrying.
Backoff strategy
The official SDK does this for you (retryOnRateLimit: true, default). If you build your own client, follow these rules:
- On the first 429, wait
Retry-Afterseconds (or 1 s if missing). Retry once. - If a second 429 arrives, double the wait. Cap at 4 s.
- After three failed retries, surface the error to the user — don’t loop forever.
async function withBackoff<T>(fn: () => Promise<T>, maxAttempts = 3): Promise<T> { for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { return await fn() } catch (err) { if (!(err instanceof Response) || err.status !== 429 || attempt === maxAttempts - 1) throw err const retryAfter = Number(err.headers.get('Retry-After') ?? 0) const wait = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 1000 await new Promise((r) => setTimeout(r, wait)) } } throw new Error('unreachable')}Header coverage CI
Picora runs a CI gate (apps/api/scripts/audit-rate-limit-coverage.ts) that fails any change to openapi.json if a non-exempt endpoint forgets to declare the RateLimit-* header triplet. Exempt endpoints are:
/health/openapi.json,/openapi-public.json,/openapi-internal.json/.well-known/**/webhooks/**(signature-protected, not throttled)
If you spot a public endpoint that should expose the headers but doesn’t, please file a bug — the audit script catches new omissions but the legacy spec is being remediated incrementally.