Sending Messages via API
Send respondent messages to POST /interviews/{id}/message and parse the Server-Sent Events reply stream frame by frame.
Sending Messages via API
Once an interview is started, the conversation happens on the message endpoint. You send the respondent's message as JSON, and the AI interviewer's reply streams back as Server-Sent Events so you can render it token by token.
The endpoint
POST https://www.koji.so/api/v1/interviews/{interview_id}/message
Sends the respondent's message and streams the AI interviewer's reply back as Server-Sent Events.
Note the path segment: /message, singular.
Headers
| Header | Value | Required |
|---|---|---|
Authorization | Bearer pk_live_... | Yes: Your project API key |
X-Session-Token | <session_token from start> | Yes: The session token returned by the start endpoint. Proves the request belongs to this respondent session |
Content-Type | application/json | Yes: JSON request body |
The X-Session-Token header is the respondent-side credential. Send the exact session_token the start endpoint returned for this interview. See API Authentication.
Request body
{
"content": "I signed up last January after a colleague recommended it."
}
content must be a string. A missing or non-string content fails with 400 Message content is required.
The SSE stream
- The response is an SSE stream (
Content-Type: text/event-stream). Eachdata:line is a JSON frame. chunkframes carry incremental reply text; concatenate them in order.- The final
doneframe includesinterview_complete. Whentrue, the AI decided the interview is finished and you should call the complete endpoint. - On a mid-stream failure you receive
{"type":"error","error":"Stream processing failed"}as the last frame.
A full reply stream looks like this on the wire:
data: {"type":"chunk","content":"That's "}
data: {"type":"chunk","content":"great to hear. What was the "}
data: {"type":"chunk","content":"first thing you tried?"}
data: {"type":"done","message_id":"msg_1720950000000_assistant","interview_complete":false}
Frame-by-frame walkthrough
1. chunk frames. Each carries a fragment of the AI's reply in content. Concatenate them in order to build the full message. Render them as they arrive for a live typing effect.
2. The done frame. Always the last frame of a successful stream. It carries:
message_id: the id of the stored assistant message.interview_complete: whether the AI decided the interview is finished.
3. error frames. If processing fails mid-stream, the last frame is {"type":"error","error":"Stream processing failed"}. By then the HTTP status is already committed as 200, so your parser must treat an error frame as terminal for that message rather than relying on the status code.
Try it with curl
curl -N -X POST https://www.koji.so/api/v1/interviews/9f4c1e2a-7b3d-4e8f-a1c5-2d6b8e0f4a7c/message \
-H "Authorization: Bearer pk_live_wJalrXUtnFEMI4K7MDENGbPxRfiCYz2K" \
-H "X-Session-Token: 5b1f0c7d-9e42-4a68-b3c1-d7f28a904e5b" \
-H "Content-Type: application/json" \
-d '{ "content": "I signed up last January." }'
The -N flag disables curl's output buffering so you see frames as they stream.
A complete JavaScript parser
This function sends a message, invokes a callback per chunk, and returns the done frame:
async function sendMessage(interviewId, sessionToken, content, onChunk) {
const response = await fetch(
`https://www.koji.so/api/v1/interviews/${interviewId}/message`,
{
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_wJalrXUtnFEMI4K7MDENGbPxRfiCYz2K',
'X-Session-Token': sessionToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({ content }),
}
)
if (!response.ok) {
const err = await response.json()
throw new Error(err.error)
}
// Parse the SSE stream frame by frame
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let done = null
while (true) {
const { value, done: streamDone } = await reader.read()
if (streamDone) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() // keep the trailing partial line
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const frame = JSON.parse(line.slice(6))
if (frame.type === 'chunk') onChunk(frame.content)
if (frame.type === 'done') done = frame
if (frame.type === 'error') throw new Error(frame.error)
}
}
// done.interview_complete === true → call the complete endpoint
return done
}
Two details worth copying: the buffer keeps the trailing partial line between reads, and non-data: lines are skipped. Both are required for correct SSE parsing.
When interview_complete is true
The AI signals the natural end of the interview through the done frame:
const done = await sendMessage(interviewId, sessionToken, text, renderChunk)
if (done?.interview_complete) {
// The AI decided the interview is finished
await completeInterview(interviewId, sessionToken)
}
Call the complete endpoint at that point to finalize the interview and trigger analysis. See Completing Interviews via API.
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:chat permission | The key was created without the interview:chat 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 | : |
| 401 | Missing X-Session-Token header | The session token header was not sent | : |
| 401 | Invalid session token | The token does not match this interview's respondent | : |
| 404 | Interview not found | Unknown interview id (or the interview was deleted) | : |
| 403 | Interview does not belong to this project | The interview belongs to a different study than the API key | : |
| 400 | Interview is not active | The interview was already completed or abandoned | : |
| 400 | Message content is required | content was missing or not a string | : |
Successful message responses stream SSE and omit the X-RateLimit-* headers, but the endpoint still counts against your key's per-minute limit. See Rate Limits and CORS.
FAQ
Why don't I see rate limit headers on this endpoint?
Rate-limit headers are included on successful responses from the start and get endpoints. The message endpoint streams SSE and omits them on success.
Can I send messages without the session token?
No. The endpoint returns 401 Missing X-Session-Token header. The token proves the request belongs to this respondent's session.
The stream returned 200 but I got an error. How?
Mid-stream failures arrive as a final {"type":"error","error":"Stream processing failed"} frame after the 200 status is already committed. Always check frame types, not just the HTTP status.
What do I do after interview_complete is true?
Call POST /interviews/{id}/complete. Sending more messages to a completed interview fails with 400 Interview is not active.
Related Articles
Completing Interviews via API
Finalize interviews with POST /interviews/{id}/complete, then poll the read endpoint for the asynchronous AI analysis.
Starting Interviews via API
Create interview sessions programmatically with POST /interviews/start: request fields, the 201 response, CRM linking, and voice mode.
Structured Questions in AI Interviews
Mix quantitative data collection — scales, ratings, multiple choice, ranking — with AI-powered conversational follow-up in a single interview.
API Authentication
How Koji Headless API authentication works: pk_live_ API keys, permissions, session tokens, and origin allowlists.
Embed Widget Reference
Embed the Koji interview widget with an iframe: URL parameters, koji:* postMessage events, React integration, and prefilled respondents.
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.