{"site":{"name":"Koji","description":"AI-native customer research platform that helps teams conduct, analyze, and synthesize customer interviews at scale.","url":"https://www.koji.so","contentTypes":["blog","documentation"],"lastUpdated":"2026-09-22T23:51:57.532Z"},"content":[{"type":"documentation","id":"f04a883e-a2b2-480d-9aa0-c9f4b47009d7","slug":"sending-messages-via-api","title":"Sending Messages via API","url":"https://www.koji.so/docs/sending-messages-via-api","summary":"Reference for the message endpoint of the Koji Headless API. The respondent message goes up as JSON; the AI reply streams back as Server-Sent Events. Covers each SSE frame type, a complete JavaScript stream parser, handling interview_complete, and all errors.","content":"# Sending Messages via API\n\nOnce 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.\n\n---\n\n## The endpoint\n\n```\nPOST https://www.koji.so/api/v1/interviews/{interview_id}/message\n```\n\nSends the respondent's message and streams the AI interviewer's reply back as Server-Sent Events.\n\nNote the path segment: `/message`, singular.\n\n### Headers\n\n| Header | Value | Required |\n|---|---|---|\n| `Authorization` | `Bearer pk_live_...` | Yes: Your project API key |\n| `X-Session-Token` | `<session_token from start>` | Yes: The session token returned by the start endpoint. Proves the request belongs to this respondent session |\n| `Content-Type` | `application/json` | Yes: JSON request body |\n\nThe `X-Session-Token` header is the respondent-side credential. Send the exact `session_token` the start endpoint returned for this interview. See [API Authentication](/docs/api-authentication).\n\n---\n\n## Request body\n\n```json\n{\n  \"content\": \"I signed up last January after a colleague recommended it.\"\n}\n```\n\n`content` must be a string. A missing or non-string `content` fails with `400 Message content is required`.\n\n---\n\n## The SSE stream\n\n- The response is an SSE stream (`Content-Type: text/event-stream`). Each `data:` line is a JSON frame.\n- `chunk` frames carry incremental reply text; concatenate them in order.\n- The final `done` frame includes `interview_complete`. When `true`, the AI decided the interview is finished and you should call the complete endpoint.\n- On a mid-stream failure you receive `{\"type\":\"error\",\"error\":\"Stream processing failed\"}` as the last frame.\n\nA full reply stream looks like this on the wire:\n\n```\ndata: {\"type\":\"chunk\",\"content\":\"That's \"}\n\ndata: {\"type\":\"chunk\",\"content\":\"great to hear. What was the \"}\n\ndata: {\"type\":\"chunk\",\"content\":\"first thing you tried?\"}\n\ndata: {\"type\":\"done\",\"message_id\":\"msg_1720950000000_assistant\",\"interview_complete\":false}\n```\n\n---\n\n## Frame-by-frame walkthrough\n\n**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.\n\n**2. The `done` frame.** Always the last frame of a successful stream. It carries:\n\n- `message_id`: the id of the stored assistant message.\n- `interview_complete`: whether the AI decided the interview is finished.\n\n**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.\n\n---\n\n## Try it with curl\n\n```bash\ncurl -N -X POST https://www.koji.so/api/v1/interviews/9f4c1e2a-7b3d-4e8f-a1c5-2d6b8e0f4a7c/message \\\n  -H \"Authorization: Bearer pk_live_wJalrXUtnFEMI4K7MDENGbPxRfiCYz2K\" \\\n  -H \"X-Session-Token: 5b1f0c7d-9e42-4a68-b3c1-d7f28a904e5b\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"content\": \"I signed up last January.\" }'\n```\n\nThe `-N` flag disables curl's output buffering so you see frames as they stream.\n\n---\n\n## A complete JavaScript parser\n\nThis function sends a message, invokes a callback per chunk, and returns the `done` frame:\n\n```javascript\nasync function sendMessage(interviewId, sessionToken, content, onChunk) {\n  const response = await fetch(\n    `https://www.koji.so/api/v1/interviews/${interviewId}/message`,\n    {\n      method: 'POST',\n      headers: {\n        'Authorization': 'Bearer pk_live_wJalrXUtnFEMI4K7MDENGbPxRfiCYz2K',\n        'X-Session-Token': sessionToken,\n        'Content-Type': 'application/json',\n      },\n      body: JSON.stringify({ content }),\n    }\n  )\n\n  if (!response.ok) {\n    const err = await response.json()\n    throw new Error(err.error)\n  }\n\n  // Parse the SSE stream frame by frame\n  const reader = response.body.getReader()\n  const decoder = new TextDecoder()\n  let buffer = ''\n  let done = null\n\n  while (true) {\n    const { value, done: streamDone } = await reader.read()\n    if (streamDone) break\n    buffer += decoder.decode(value, { stream: true })\n\n    const lines = buffer.split('\\n')\n    buffer = lines.pop() // keep the trailing partial line\n\n    for (const line of lines) {\n      if (!line.startsWith('data: ')) continue\n      const frame = JSON.parse(line.slice(6))\n      if (frame.type === 'chunk') onChunk(frame.content)\n      if (frame.type === 'done') done = frame\n      if (frame.type === 'error') throw new Error(frame.error)\n    }\n  }\n\n  // done.interview_complete === true → call the complete endpoint\n  return done\n}\n```\n\nTwo 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.\n\n---\n\n## When interview_complete is true\n\nThe AI signals the natural end of the interview through the `done` frame:\n\n```javascript\nconst done = await sendMessage(interviewId, sessionToken, text, renderChunk)\n\nif (done?.interview_complete) {\n  // The AI decided the interview is finished\n  await completeInterview(interviewId, sessionToken)\n}\n```\n\nCall the complete endpoint at that point to finalize the interview and trigger analysis. See [Completing Interviews via API](/docs/completing-interviews-via-api).\n\n---\n\n## Errors\n\n| Status | `error` | When | Extra fields |\n|---|---|---|---|\n| 401 | `Missing or invalid authorization header` | No `Authorization: Bearer` header sent |: |\n| 401 | `Invalid API key` | The key does not exist, is malformed, is inactive, or has expired |: |\n| 403 | `API key does not have interview:chat permission` | The key was created without the interview:chat scope |: |\n| 403 | `Origin not allowed` | The browser's Origin header does not match the key's allowed origins |: |\n| 403 | `Headless API access is not enabled for this account.` | The study owner's account cannot use the headless API. Rare: API access is included on every plan, so this appears only for restricted accounts |: |\n| 429 | `Rate limit exceeded` | The key exceeded its per-minute request limit | `retry_after` |\n| 500 | `Internal server error` | Unexpected server failure. Safe to retry with backoff |: |\n| 401 | `Missing X-Session-Token header` | The session token header was not sent |: |\n| 401 | `Invalid session token` | The token does not match this interview's respondent |: |\n| 404 | `Interview not found` | Unknown interview id (or the interview was deleted) |: |\n| 403 | `Interview does not belong to this project` | The interview belongs to a different study than the API key |: |\n| 400 | `Interview is not active` | The interview was already completed or abandoned |: |\n| 400 | `Message content is required` | `content` was missing or not a string |: |\n\nSuccessful 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](/docs/rate-limits-and-cors).\n\n---\n\n## FAQ\n\n### Why don't I see rate limit headers on this endpoint?\n\nRate-limit headers are included on successful responses from the start and get endpoints. The message endpoint streams SSE and omits them on success.\n\n### Can I send messages without the session token?\n\nNo. The endpoint returns `401 Missing X-Session-Token header`. The token proves the request belongs to this respondent's session.\n\n### The stream returned 200 but I got an error. How?\n\nMid-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.\n\n### What do I do after interview_complete is true?\n\nCall `POST /interviews/{id}/complete`. Sending more messages to a completed interview fails with `400 Interview is not active`.","category":"API Reference","lastModified":"2026-08-19T21:52:49.745445+00:00","metaTitle":"Sending Messages via API | Koji Headless API","metaDescription":"Reference for the Koji message endpoint: X-Session-Token auth, SSE chunk and done frames, interview_complete handling, and a full JavaScript parser.","keywords":["api messages","message flow","chat api","voice websocket","interview transcript"],"aiSummary":"Reference for the message endpoint of the Koji Headless API. The respondent message goes up as JSON; the AI reply streams back as Server-Sent Events. Covers each SSE frame type, a complete JavaScript stream parser, handling interview_complete, and all errors.","aiPrerequisites":["starting-interviews-via-api"],"aiLearningOutcomes":["Send messages with the API key plus X-Session-Token headers","Parse the SSE stream: chunk, done, and error frames","React to interview_complete by calling the complete endpoint","Handle mid-stream failures that arrive after HTTP 200"],"aiDifficulty":"advanced","aiEstimatedTime":"12 min"}],"pagination":{"total":1,"returned":1,"offset":0}}