{"site":{"name":"Koji","description":"AI-native customer research platform that helps teams conduct, analyze, and synthesize customer interviews at scale.","url":"https://www.koji.so","contentTypes":["blog","documentation"],"lastUpdated":"2026-08-16T17:38:47.751Z"},"content":[{"type":"documentation","id":"5ef076ae-c4ed-405f-aaef-438b5fa9d76c","slug":"rate-limits-and-cors","title":"Rate Limits and CORS","url":"https://www.koji.so/docs/rate-limits-and-cors","summary":"Explains Koji Headless API rate limiting: a fixed per-minute window per API key, the X-RateLimit response headers, correct 429 handling with retry_after, the difference between rate limits and interview quotas, and the CORS behavior of the /api/v1 endpoints.","content":"# Rate Limits and CORS\n\nThe Headless API rate-limits per API key. The defaults are generous for interview traffic, but a completion poller or a batch job can hit them, so it pays to understand exactly how the window works and how to back off.\n\n---\n\n## How rate limiting works\n\n- Each API key allows 60 requests per minute by default (configurable per key).\n- The window is a fixed 1-minute window aligned to the clock minute. Not a sliding window.\n- All /interviews endpoints count against the same per-key limit.\n- Requests made while over the limit still count, so a client that keeps hammering stays limited until it backs off past the window boundary.\n\nThe default is 60 requests per minute. Because the window is fixed and aligned to the clock minute, a burst at the end of one minute and another at the start of the next can both succeed. The important half of that fact is the last bullet: requests made while limited still count, so a retry loop without a delay keeps itself limited indefinitely.\n\n---\n\n## Rate limit headers\n\nSuccessful responses tell you where you stand before you ever hit a 429:\n\n| Header | Description |\n|---|---|\n| `X-RateLimit-Limit` | Your key's per-minute limit |\n| `X-RateLimit-Remaining` | Requests left in the current window |\n| `X-RateLimit-Reset` | ISO timestamp when the window resets |\n\nRate-limit headers are included on successful responses from the start and get endpoints. The message endpoint streams SSE and omits them on success.\n\nCoverage per endpoint:\n\n| Endpoint | Rate-limit headers on success |\n|---|---|\n| `POST /interviews/start` | Yes |\n| `POST /interviews/{interview_id}/message` | No |\n| `POST /interviews/{interview_id}/complete` | No |\n| `GET /interviews/{interview_id}` | Yes |\n\nA batch job can use the headers to pace itself instead of reacting to failures:\n\n```javascript\nconst res = await fetch('https://www.koji.so/api/v1/interviews/start', {\n  method: 'POST',\n  headers: {\n    'Authorization': 'Bearer ' + process.env.KOJI_API_KEY,\n    'Content-Type': 'application/json',\n  },\n  body: JSON.stringify({}),\n})\n\nconst remaining = Number(res.headers.get('X-RateLimit-Remaining'))\nconst reset = res.headers.get('X-RateLimit-Reset') // ISO timestamp\n\nif (remaining < 5) {\n  // Nearly out of budget: pause the batch until the window resets\n  const waitMs = new Date(reset).getTime() - Date.now()\n  await new Promise(resolve => setTimeout(resolve, Math.max(waitMs, 0)))\n}\n```\n\n---\n\n## Handling a 429\n\nA rate-limited request returns HTTP 429 with:\n\n```json\n{\n  \"error\": \"Rate limit exceeded\",\n  \"retry_after\": 42\n}\n```\n\n`retry_after` is the number of seconds until the window resets. Respect it:\n\n```javascript\nasync function fetchWithBackoff(url, options, maxRetries = 5) {\n  for (let attempt = 0; attempt <= maxRetries; attempt++) {\n    const res = await fetch(url, options)\n    if (res.status !== 429) return res\n\n    const body = await res.json()\n    if (body.error === 'interview_limit_reached') {\n      // Quota, not rate limiting. Retrying will not help.\n      throw new Error(body.message)\n    }\n\n    const waitSeconds = body.retry_after ?? 2 ** attempt\n    await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000))\n  }\n  throw new Error('Still rate limited after retries')\n}\n```\n\n---\n\n## Rate limits vs interview quotas\n\nDo not confuse request rate limits with interview quotas. A 429 with `error: \"Rate limit exceeded\"` means slow down and retry after `retry_after` seconds. A 429 with `error: \"interview_limit_reached\"` from the start endpoint means the study has reached its response limit or the owner's plan quota. Retrying will not help.\n\nThe quota error from the start endpoint carries extra context fields: `message`, `current`, `limit`. Surface `message` to a human; `current` and `limit` tell you where the study stands.\n\n---\n\n## CORS\n\n- All /api/v1 endpoints answer CORS preflight (OPTIONS) and echo the request Origin, so browser calls work from any site by default.\n- To restrict which sites can use a key from the browser, configure the key's allowed origins. See Authentication.\n- Allowed request headers: Content-Type, Authorization, X-Session-Token.\n\nIn practice: browser calls to the API work out of the box. Before a cross-origin `fetch`, the browser sends an `OPTIONS` preflight; the API answers it and echoes the request `Origin`, so the actual request proceeds from any site. If your browser call fails, the cause is almost never CORS. Check for a 403 `Origin not allowed` (an origin-restricted key) or a header the API does not accept: only `Content-Type`, `Authorization`, and `X-Session-Token` are allowed.\n\nCORS is not the mechanism that restricts browser usage; origin allowlists on the key are.\n\n---\n\n## Restricting browser callers\n\nIf a key ships to the browser, configure its allowed origins so only your sites can use it. Origin checks apply to requests that carry an `Origin` header; server-to-server calls always pass. The full matching rules (exact origins, `*.example.com` wildcards, and the `*` entry) are documented in [API Authentication](/docs/api-authentication).\n\n---\n\n## FAQ\n\n### Is the rate limit window sliding?\n\nNo. It is a fixed 1-minute window aligned to the clock minute.\n\n### Do requests over the limit count against me?\n\nYes. A client that keeps hammering stays limited until it backs off past the window boundary. Always wait `retry_after` seconds before retrying.\n\n### Do all endpoints share one limit?\n\nAll `/interviews` endpoints count against the same per-key limit.\n\n### Can the limit be changed?\n\nThe default is 60 requests per minute, configurable per key.\n\n### Why did my 429 not go away after waiting?\n\nCheck the `error` string. `interview_limit_reached` is a quota error from the start endpoint: the study hit its response limit or the owner's plan quota. Waiting and retrying will not help.\n\n### Does the embed bootstrap endpoint count against the limit?\n\nThe per-key limit covers the `/interviews` endpoints, which all share it. The embed bootstrap endpoint sits outside that group and does not return rate-limit headers.","category":"API Reference","lastModified":"2026-07-14T13:56:42.676151+00:00","metaTitle":"Rate Limits and CORS | Koji Headless API","metaDescription":"Koji API rate limiting explained: 60 requests per minute per key, fixed windows, X-RateLimit headers, 429 backoff, quota vs rate limit, and CORS.","keywords":["rate limiting","cors","api limits","rate limit headers","cross origin"],"aiSummary":"Explains Koji Headless API rate limiting: a fixed per-minute window per API key, the X-RateLimit response headers, correct 429 handling with retry_after, the difference between rate limits and interview quotas, and the CORS behavior of the /api/v1 endpoints.","aiPrerequisites":["api-authentication"],"aiLearningOutcomes":["Explain the fixed-window per-key rate limit","Read the X-RateLimit response headers","Handle 429s with retry_after and exponential backoff","Distinguish rate limits from interview quota errors","Understand CORS behavior and origin restrictions"],"aiDifficulty":"intermediate","aiEstimatedTime":"7 min"}],"pagination":{"total":1,"returned":1,"offset":0}}