Back to docs
API Reference

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:

HeaderDescription
X-RateLimit-LimitYour key's per-minute limit
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetISO 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:

EndpointRate-limit headers on success
POST /interviews/startYes
POST /interviews/{interview_id}/messageNo
POST /interviews/{interview_id}/completeNo
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.