Back to docs
API Reference

Starting Interviews via API

Create interview sessions programmatically with POST /interviews/start: request fields, the 201 response, CRM linking, and voice mode.

Starting Interviews via API

The start endpoint is the first call every headless integration makes. It creates a new interview session and hands back everything your client needs for the rest of the conversation: the interview id, the per-session token, and the AI's opening question.


The endpoint

POST https://www.koji.so/api/v1/interviews/start

Creates a new interview session and returns the credentials your client needs for the rest of the conversation. The study is derived from the API key. You never send a project id.

Headers

HeaderValueRequired
AuthorizationBearer pk_live_...Yes: Your project API key
Content-Typeapplication/jsonYes: JSON request body

Request body

Every field is optional. This is the full shape:

{
  "respondent": {
    "external_id": "user_8271",
    "display_name": "Jamie",
    "metadata": {
      "plan": "pro",
      "signup_date": "2026-01-15"
    }
  },
  "mode": "text",
  "locale": "en-US"
}
  • All fields are optional. An empty body {} starts an anonymous text interview.
  • respondent.external_id links the interview to a user in your system (CRM correlation). You can find it again in the GET response.
  • respondent.metadata is free-form JSON stored on the respondent record.
  • mode is 'text' (default) or 'voice'. Voice mode returns voice_credentials.

Response

A successful call returns HTTP 201:

{
  "interview_id": "9f4c1e2a-7b3d-4e8f-a1c5-2d6b8e0f4a7c",
  "respondent_id": "c81d5b3e-2f6a-49c0-b7d4-8e1a3c5f9b27",
  "session_token": "5b1f0c7d-9e42-4a68-b3c1-d7f28a904e5b",
  "status": "active",
  "mode": "text",
  "project": {
    "id": "3f2c8a14-6b9d-4e07-a852-1c5f9d3b7e60",
    "name": "Churn Interview Study",
    "slug": "churn-interview-study"
  },
  "initial_message": "Hi! Thanks for taking the time to chat today. To start, could you tell me a bit about how you first came across the product?"
}
  • Returns HTTP 201.
  • session_token is a bare UUID. Send it as X-Session-Token on the message and complete endpoints. Treat it as a per-interview secret.
  • initial_message is the AI's opening question. It can be absent if greeting generation fails. Render your own opener in that case.
  • In voice mode the response also includes voice_credentials: { signed_url, agent_id }. Connect to the ElevenLabs Conversational AI session via signed_url using their client SDK.

Store interview_id and session_token for the rest of the session. The message and complete endpoints need both.


Try it with curl

curl -X POST https://www.koji.so/api/v1/interviews/start \
  -H "Authorization: Bearer pk_live_wJalrXUtnFEMI4K7MDENGbPxRfiCYz2K" \
  -H "Content-Type: application/json" \
  -d '{
    "respondent": { "external_id": "user_8271", "display_name": "Jamie" },
    "mode": "text"
  }'

Start from JavaScript

const response = await fetch('https://www.koji.so/api/v1/interviews/start', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pk_live_wJalrXUtnFEMI4K7MDENGbPxRfiCYz2K',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    respondent: { external_id: 'user_8271', display_name: 'Jamie' },
    mode: 'text',
  }),
})

const interview = await response.json()
// Keep these for the rest of the session:
const { interview_id, session_token, initial_message } = interview

From here, the conversation continues on the message endpoint. See Sending Messages via API.


Linking interviews to your CRM

The optional respondent object is how you connect interviews to users in your own system:

  • external_id is your identifier for the user (for example user_8271). It comes back as respondent.external_id in the GET response, so a completion worker can join interview results to the right CRM record.
  • metadata is free-form JSON stored on the respondent record. Use it for plan, cohort, signup date, or anything your analysis pipeline wants later.
  • display_name is the respondent's name. It comes back as respondent.display_name when you read the interview.

Starting anonymous interviews is fine too: an empty body {} starts an anonymous text interview.


Voice mode

Pass "mode": "voice" to start a voice interview. The response then also includes:

"voice_credentials": {
  "signed_url": "...",
  "agent_id": "..."
}

Connect to the ElevenLabs Conversational AI session via signed_url using their client SDK. Text mode is the default when mode is omitted.


Rate limit headers

Successful start responses carry the standard rate-limit headers:

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. Details in Rate Limits and CORS.


Errors

StatuserrorWhenExtra fields
401Missing or invalid authorization headerNo Authorization: Bearer header sent:
401Invalid API keyThe key does not exist, is malformed, is inactive, or has expired:
403API key does not have interview:start permissionThe key was created without the interview:start scope:
403Origin not allowedThe browser's Origin header does not match the key's allowed origins:
403Headless API access requires an Interviews plan or higher.The study owner's account cannot use the headless API. Rare: API access is included on all current plans, so this appears only for restricted accounts:
429Rate limit exceededThe key exceeded its per-minute request limitretry_after
500Internal server errorUnexpected server failure. Safe to retry with backoff:
403This study is no longer accepting responsesThe study is paused or closedclosed
403unavailableThe study cannot accept responses right now (owner account state)message, reason
429interview_limit_reachedThe study hit its response limit or the owner's interview quotamessage, current, limit

One row deserves special attention: interview_limit_reached is a quota error, not a rate limit. It means the study hit its response limit or the owner's interview quota, and retrying will not help. A plain Rate limit exceeded 429, by contrast, resolves itself after retry_after seconds.


FAQ

Do I need to send a project id?

No. The study is derived from the API key. You never send a project id.

Can I start an interview with an empty body?

Yes. An empty body {} starts an anonymous text interview. All request fields are optional.

What if initial_message is missing?

It can be absent if greeting generation fails. Render your own opener in that case; the conversation works normally from the first message you send.

Can I reuse a session token for another interview?

No. The token is scoped to a single interview session. Each start call issues a fresh one.