Skip to content

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

TierEndpointsLimitBurst
readGET list/detail, /v1/auth/verify, /v1/user/me/*600 / minute30
uploadPOST /v1/{images,videos,audio,docs}60 / minute5
mutationPATCH / DELETE (incl. batch)120 / minute10

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 window
    • RateLimit-Remaining — what’s left
    • RateLimit-Reset — Unix seconds until the window resets
  • Legacy GitHub-style (Scalar Try-it, older OpenAPI tools):
    • X-RateLimit-Limit
    • X-RateLimit-Remaining
    • X-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 Requests
Retry-After: 12
Content-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:

  1. On the first 429, wait Retry-After seconds (or 1 s if missing). Retry once.
  2. If a second 429 arrives, double the wait. Cap at 4 s.
  3. 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.