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
| Header | Value | Required |
|---|---|---|
Authorization | Bearer pk_live_... | Yes: Your project API key |
Content-Type | application/json | Yes: 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_idlinks the interview to a user in your system (CRM correlation). You can find it again in the GET response.respondent.metadatais free-form JSON stored on the respondent record.modeis'text'(default) or'voice'. Voice mode returnsvoice_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_tokenis a bare UUID. Send it asX-Session-Tokenon the message and complete endpoints. Treat it as a per-interview secret.initial_messageis 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 viasigned_urlusing 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_idis your identifier for the user (for exampleuser_8271). It comes back asrespondent.external_idin the GET response, so a completion worker can join interview results to the right CRM record.metadatais free-form JSON stored on the respondent record. Use it for plan, cohort, signup date, or anything your analysis pipeline wants later.display_nameis the respondent's name. It comes back asrespondent.display_namewhen 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:
| 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. Details in Rate Limits and CORS.
Errors
| Status | error | When | Extra fields |
|---|---|---|---|
| 401 | Missing or invalid authorization header | No Authorization: Bearer header sent | : |
| 401 | Invalid API key | The key does not exist, is malformed, is inactive, or has expired | : |
| 403 | API key does not have interview:start permission | The key was created without the interview:start scope | : |
| 403 | Origin not allowed | The browser's Origin header does not match the key's allowed origins | : |
| 403 | Headless 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 | : |
| 429 | Rate limit exceeded | The key exceeded its per-minute request limit | retry_after |
| 500 | Internal server error | Unexpected server failure. Safe to retry with backoff | : |
| 403 | This study is no longer accepting responses | The study is paused or closed | closed |
| 403 | unavailable | The study cannot accept responses right now (owner account state) | message, reason |
| 429 | interview_limit_reached | The study hit its response limit or the owner's interview quota | message, 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.
Related Articles
Amplitude + Koji: Trigger AI Interviews from Product Analytics and Pipe Insights Back as Events
How to close the loop between Amplitude product analytics and Koji AI interviews — fire interview links to users in specific Amplitude cohorts, and pipe interview themes, sentiment, and quality scores back into Amplitude as user properties and custom events.
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.
Sending Messages via API
Send respondent messages to POST /interviews/{id}/message and parse the Server-Sent Events reply stream frame by frame.
Structured Questions in AI Interviews
Mix quantitative data collection — scales, ratings, multiple choice, ranking — with AI-powered conversational follow-up in a single interview.
User Research API: Embed AI Interviews into Any Product or Workflow
How to use Koji's User Research API to run AI-moderated interviews from your own backend. Covers REST endpoints, the embed widget, webhooks, authentication, rate limits, and headless interview patterns.