Back to docs
API Reference

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

HeaderValueRequired
AuthorizationBearer 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-Typeapplication/jsonYes: 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). Each data: line is a JSON frame.
  • chunk frames carry incremental reply text; concatenate them in order.
  • The final done frame includes interview_complete. When true, 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

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:chat permissionThe key was created without the interview:chat 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:
401Missing X-Session-Token headerThe session token header was not sent:
401Invalid session tokenThe token does not match this interview's respondent:
404Interview not foundUnknown interview id (or the interview was deleted):
403Interview does not belong to this projectThe interview belongs to a different study than the API key:
400Interview is not activeThe interview was already completed or abandoned:
400Message content is requiredcontent 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.