Rate Limits and CORS
Per-key rate limits, X-RateLimit headers, 429 handling with backoff, quota errors, and how CORS works on the Koji API.
Rate Limits and CORS
The 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.
How rate limiting works
- Each API key allows 60 requests per minute by default (configurable per key).
- The window is a fixed 1-minute window aligned to the clock minute. Not a sliding window.
- All /interviews endpoints count against the same per-key limit.
- Requests made while over the limit still count, so a client that keeps hammering stays limited until it backs off past the window boundary.
The 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.
Rate limit headers
Successful responses tell you where you stand before you ever hit a 429:
| Header | Description |
|---|---|
X-RateLimit-Limit | Your key's per-minute limit |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | ISO timestamp when the window resets |
Rate-limit headers are included on successful responses from the start and get endpoints. The message endpoint streams SSE and omits them on success.
Coverage per endpoint:
| Endpoint | Rate-limit headers on success |
|---|---|
POST /interviews/start | Yes |
POST /interviews/{interview_id}/message | No |
POST /interviews/{interview_id}/complete | No |
GET /interviews/{interview_id} | Yes |
A batch job can use the headers to pace itself instead of reacting to failures:
const res = await fetch('https://www.koji.so/api/v1/interviews/start', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.KOJI_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
})
const remaining = Number(res.headers.get('X-RateLimit-Remaining'))
const reset = res.headers.get('X-RateLimit-Reset') // ISO timestamp
if (remaining < 5) {
// Nearly out of budget: pause the batch until the window resets
const waitMs = new Date(reset).getTime() - Date.now()
await new Promise(resolve => setTimeout(resolve, Math.max(waitMs, 0)))
}
Handling a 429
A rate-limited request returns HTTP 429 with:
{
"error": "Rate limit exceeded",
"retry_after": 42
}
retry_after is the number of seconds until the window resets. Respect it:
async function fetchWithBackoff(url, options, maxRetries = 5) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch(url, options)
if (res.status !== 429) return res
const body = await res.json()
if (body.error === 'interview_limit_reached') {
// Quota, not rate limiting. Retrying will not help.
throw new Error(body.message)
}
const waitSeconds = body.retry_after ?? 2 ** attempt
await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000))
}
throw new Error('Still rate limited after retries')
}
Rate limits vs interview quotas
Do 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.
The 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.
CORS
- All /api/v1 endpoints answer CORS preflight (OPTIONS) and echo the request Origin, so browser calls work from any site by default.
- To restrict which sites can use a key from the browser, configure the key's allowed origins. See Authentication.
- Allowed request headers: Content-Type, Authorization, X-Session-Token.
In 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.
CORS is not the mechanism that restricts browser usage; origin allowlists on the key are.
Restricting browser callers
If 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.
FAQ
Is the rate limit window sliding?
No. It is a fixed 1-minute window aligned to the clock minute.
Do requests over the limit count against me?
Yes. A client that keeps hammering stays limited until it backs off past the window boundary. Always wait retry_after seconds before retrying.
Do all endpoints share one limit?
All /interviews endpoints count against the same per-key limit.
Can the limit be changed?
The default is 60 requests per minute, configurable per key.
Why did my 429 not go away after waiting?
Check 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.
Does the embed bootstrap endpoint count against the limit?
The 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.
Related Articles
API Authentication
How Koji Headless API authentication works: pk_live_ API keys, permissions, session tokens, and origin allowlists.
Headless API Overview
Manage interviews programmatically with the Koji REST API — start, message, and complete interviews from your own code.
Completing Interviews via API
Finalize interviews with POST /interviews/{id}/complete, then poll the read endpoint for the asynchronous AI analysis.
Embed Widget Reference
Embed the Koji interview widget with an iframe: URL parameters, koji:* postMessage events, React integration, and prefilled respondents.
Sending Messages via API
Send respondent messages to POST /interviews/{id}/message and parse the Server-Sent Events reply stream frame by frame.
Starting Interviews via API
Create interview sessions programmatically with POST /interviews/start: request fields, the 201 response, CRM linking, and voice mode.