To save as PDF: Ctrl+P (Cmd+P) → Destination “Save as PDF” → enable “Background graphics”. This banner will not appear in the export.  ·  ← Back to kay_x

Kay-X Voice API

Speech in, structured data out, speech back. Integration reference for the voice intent endpoints.

Contents

1. The model
2. Authentication
3. Two integration styles
4. POST /voice/intent
5. The response, field by field
6. next — what to do
7. POST /voice/resolve
8. POST /voice/reject
9. POST /voice/respond
10. Repeating groups
11. Follow-up answers
12. Errors
13. Checklist

1. The model

Kay-X answers exactly one question: did I understand the caller? It never decides whether you can act — stock levels, balances and safety checks are yours. The boundary is fixed, and every endpoint sits on one side of it.

voice note ──▶ /voice/intent ──▶ did I understand? │ ┌───────────────┼────────────────┐ │ │ │ ask confirm done ──▶ YOUR BUSINESS LOGIC (say it again) (yes / no) │ │ │ ┌───────┴────────┐ └──▶ /voice/intent │ │ └──▶ /voice/resolve ok refused │ │ /voice/respond /voice/reject

The whole flow, once

There is one endpoint you send voice to, and three possible answers. Everything else is detail.

┌─────────────────────────────────────────────────────────┐ │ caller sends a voice note │ └────────────────────────┬────────────────────────────────┘ ▼ POST /api/v1/voice/intent │ ▼ read next.action │ ┌─────────────────────┼─────────────────────┐ ▼ ▼ ▼ done ask confirm │ │ │ │ play next.audio play next.audio │ (or speak .say) (or speak .say) │ │ │ │ caller says a caller says │ NEW voice note YES or NO │ │ │ │ ▼ ▼ │ POST /voice/intent POST /voice/resolve │ (same session_id) (this message_id) │ │ │ │ └──────► loop ◄───────┘ ▼ read final_values → prefill your form → conversation over
You getIt meansYou doTheir reply goes to
done Understood everything Read final_values — nothing to send
ask Something is missing, or was heard too poorly to trust Play next.audio, else speak next.say /api/v1/voice/intent — a voice note, same session_id
confirm Heard it all, wants a yes Play next.audio, else speak next.say /api/v1/voice/resolveyes or no, this message_id

next.endpoint already contains the right path, so you can route on it directly rather than writing the branch yourself.

Two fields people mix up, every time.

next.say is Krio. It is the only thing the caller should ever hear.

next.say_en is English. It is a log line for your dashboard, so your support staff can read what happened. Never speak it. It is generated from the detection result, not from your config — which is why it reads like a debug message rather than a sentence.

No next.audio in your response? Then speakable_audio is false and the business has not enabled speak clarifications. Turn it on in the dashboard (Basic Info) and the Krio question comes back already synthesized, so you never make a second call. Recovery wording repeats constantly, so after the first time it is served from cache — free and instant.

2. Authentication

CredentialHeaderUse for
API keyX-API-Key: kay_x<id>_<secret>Your server. Runtime calls.
JWTAuthorization: Bearer <jwt>Dashboard, config, testing.

Runtime endpoints accept either. Never put an API key in browser JavaScript — it has no origin restriction and spends credits. Browser → your backend → Kay-X.

3. Two integration styles

Pick one before you write anything. They are not mixed.

Message styleContext style
You trackmessage_idan opaque context token
Follow-up/voice/resolvenext /voice/intent with the token
Slots accumulateno — one turn at a timeyes — across turns
/voice/rejectnot availableavailable
Best forWhatsApp, one-shot captureIVR, multi-question flows

Message style is the simpler start. Use it unless you need slots gathered over several questions. Context style adds /voice/reject, which is the only way to rewind one field after your own validation refuses it.

4. POST/api/v1/voice/intent

multipart/form-data. This is the only endpoint that takes audio.

FieldReqNotes
fileyesAny ffmpeg-supported format. Max 25 MB.
business_idyesYour public business handle.
session_idnoConversation key. Reuse across turns for context. Generated if omitted.
message_idnoIdempotency key. Always send it.
contextnoContext style only. The token from the previous turn.

Always send message_id. Use your channel's own id (WhatsApp message id, IVR leg id). Replaying it returns the stored turn with "replayed": true — nothing is re-transcribed, re-detected or re-charged. WhatsApp retries constantly; without this you pay twice and get duplicate records.

Text variant, for testing

POST/api/v1/voice/intent/text — same pipeline without transcription. Cheaper, instant, and it does not write to the training pipeline, so test data stays out.

{ "business_id": "clinic", "session_id": "t1",
  "text": "Take Amoxicillin three times a day for five days" }

5. The response, field by field

{
  "business_id":   "clinic",
  "message_id":    "wa-0001",
  "session_id":    "whatsapp-23276123456",
  "audio_ref":     "gs://…",
  "transcript_raw":"Take Amoxicillin three times a day for five days",

  "intent":  { "name": "new_prescription", "confidence": 0.95 },

  "slots": {
    "medicine_name": { "name":"medicine_name", "value":"Amoxicillin",
                       "raw_text":"Amoxicillin", "confidence":1.0 },
    "duration_days": { "name":"duration_days", "value":5,
                       "raw_text":"five days",  "confidence":0.95 }
  },

  "needs_clarification": false,
  "resolved": true,
  "final_values": { "medicine_name":"Amoxicillin", "duration_days":5 },

  "next": { "action": "done" },

  "normalizer_candidates": [ … ]
}
FieldWhat it is
transcript_rawWhat was heard. Check this first when anything looks wrong — most problems are transcription, not extraction.
intent.nameWhich of your configured intents matched.
slotsDetail view. Per field: value, confidence, and raw_text — the words the value came from. Use for debugging and for showing confidence in a UI.
final_valuesThe form-filling view. Flat name → value, present only when the turn resolved. This is what you prefill from.
nextWhat to do now. See §6.
itemsPresent only for repeating groups. See §10.
replayedtrue when this message_id was already processed. Nothing was re-charged.
turnContext style only. Accumulated state plus the next context token.
normalizer_candidatesInternal parser debug. Every reading the number parser considered. You will see odd entries — a phone-number reading of “14” — because it offers all interpretations and lets the model choose. Ignore this field.

slots and final_values hold the same values in different shapes. Read final_values to act; read slots to explain.

6. next — what to do

"next": {
  "action":   "ask",
  "say":      "Fo Ciprofloxacin, omoch tem pa dey?",
  "say_en":   "For Ciprofloxacin, how many times per day?",
  "audio":    "UklGRj…",
  "endpoint": "POST /api/v1/voice/intent (same session_id)",
  "item":     1,
  "missing":  ["frequency", "duration_days"]
}
actionMeaningWhat you do
doneUnderstoodAct on final_values
askA value is missingSay say; send their next voice note to /voice/intent, same session_id
confirmHeard, not confidentSay say; send their yes/no to /voice/resolve

ask and confirm are not interchangeable. ask expects the caller to say something new; confirm expects yes or no. Wire them the same way and the conversation deadlocks the first time someone answers “yes” to “how many days?”.

say is Krio and is meant to be spoken. say_en is English and is for your logs — never show it to a caller. audio is say already synthesized as base64 WAV, present when the business has speak clarifications enabled. Recovery wording repeats, so after the first time it is served from cache and costs nothing.

const { next, final_values } = await postVoiceNote(audio);

switch (next.action) {
  case 'done':    return fillForm(final_values);
  case 'ask':
  case 'confirm': await play(next.audio) ?? speak(next.say);
                  return waitForReply(next.action);
}

7. POST/api/v1/voice/resolve

Where a confirm goes. Message style only. Free — it costs no credits.

Confirm

{ "message_id": "wa-0001", "reply": "yes" }

Affirmative: yes y yeah yep confirm ok okay correct sure fine. Negative: no n nope cancel wrong reject stop.

Correct a value

{ "message_id": "wa-0001", "correction": { "duration_days": 7 } }

Correct one entry of a repeating group

{ "message_id": "wa-0001", "item": 1,
  "correction": { "frequency": "2x daily", "duration_days": 7 } }

Only that entry changes. Fixing the third medicine never makes the caller re-dictate the first two.

Response

{ "message_id":"wa-0001", "status":"resolved",
  "intent":"new_prescription",
  "final_values": { "medications": [ … ] } }

A rejection returns "status":"rejected" and no final_values — do not act on it. 409 means already resolved; treat as terminal.

Never skip the confirmation step to save money. It is free, and every confirmation and correction becomes gold-label training data that improves the Krio models. Corrections are worth more than confirmations.

8. POST/api/v1/voice/reject

Context style only. For when the caller was understood perfectly and your validation refuses the value — an unregistered wallet, a meter that will not verify, a drug you do not stock.

This is a different message from “I did not hear you”. A caller who gave a clear answer needs to know it was understood and still will not work. Without this endpoint you would have to reach into the context token you are meant to treat as opaque, just to undo one field.

{
  "context": "<token from the previous turn>",
  "slot":    "recipient_phone",
  "value":   "+23276123456",
  "reason":  "not registered on Orange Money",
  "message": "Da nomba de no de na Orange Money. Duya gi wan oda nomba."
}
FieldReqNotes
contextyesThe opaque token. Without it the call fails.
slotyesWhich field you refused. It is cleared and asked again.
valuenoThe refused value. Send it — it stops the caller looping by repeating the same answer.
reasonnoRecorded for debugging. Never spoken.
messagenoYour own Krio wording. Beats the slot's configured prompt, because you know why you refused it.

Returns a turn state whose say holds the question to ask next, with audio when spoken recovery is on. If you omit message, the slot's configured reject_prompt is used; if that is also empty, the caller simply hears the slot's normal question again.

9. POST/api/v1/voice/respond

The output half. Turns your own event label plus values into spoken Krio. Nothing to do with understanding — you call it when you have acted.

{
  "businessId": "clinic",
  "intent":     "prescription_saved",
  "slots":      { "medicine": "Amoxicillin", "amount": "500" },
  "sessionId":  "whatsapp-23276123456",
  "speakerId":  "jojo"
}
{
  "text":       "Yu prescripshon don rich. Amoxicillin faiv hondred leones.",
  "audio":      "UklGRj…",
  "templateId": "…", "variantId": "…",
  "cacheHit":   false,
  "requiresQa": false
}

What actually happens

  1. Your intent label is looked up in this business's bindings to find a template. No binding → 404.
  2. One variant of that template is chosen by the binding's policy (weighted_random by default), so the assistant does not repeat itself word for word.
  3. Your slots are rendered into the template: "500" becomes faiv hondred leones, a phone number becomes digits one at a time.
  4. The result is synthesized to speech, or served from cache.
  5. The assembly is logged for QA.

Send plain values, not Krio words. "500", not "faiv hondred". The renderer speaks them. cacheHit: true means it was free.

Free text, no template

POST/api/v1/voice/respond/text — raw Krio straight to speech. Max 30 words / 300 characters. Never cached, always flagged for QA. Use it for one-offs; use templates for anything repeated.

10. Repeating groups

When one utterance names several things — “Amoxicillin three times a day, and Paracetamol twice” — the response gains items, one entry per thing, each judged on its own.

"items": [
  { "index":0, "label":"Amoxicillin",
    "needs_clarification": false,
    "final_values": { "medicine_name":"Amoxicillin", "duration_days":5 } },

  { "index":1, "label":"Ciprofloxacin",
    "needs_clarification": true,
    "missing_slots": ["frequency","duration_days"] }
],
"next": { "action":"ask", "item":1,
          "say":"Fo Ciprofloxacin, omoch tem pa dey?" }

Amoxicillin passed and stays passed. Only the incomplete entry is asked about, by name. One entry per turn — “for Amoxicillin how many days and for Paracetamol how many times” is not answerable out loud.

When everything resolves, the rows appear under the group's own name:

"final_values": {
  "medications": [
    { "medicine_name":"Amoxicillin", "frequency":"3x daily", "duration_days":5 },
    { "medicine_name":"Paracetamol", "frequency":"2x daily", "duration_days":3 }
  ]
}

One field to prefill a form from. One entry or ten, same shape — no branching.

11. Follow-up answers: fold, don't merge

Read this section even if you skip the rest. It is where integrations quietly corrupt data.

Every voice note is its own round trip. We hold no conversation state — the session_id groups turns for context, but each response describes that utterance, not your accumulated form. Statelessness is deliberate: it means you can retry, replay, load-balance and scale without us holding a lock on your conversation. The cost is that stitching turns together is yours.

What it buys the caller

The whole point is that nobody re-dictates. A clinician forced to repeat an entire prescription because one name did not come through has been handed something worse than a pen.

turn 1 ▶ "Amoxicillin three times a day for five days, and another one twice a day for seven days" #1 Amoxicillin 3x daily 5 days ✓ #2 — 2x daily 7 days ⚠ medicine_name ◀ ask · item 1 · "Wetin na di medisin fo di sekon wan?" turn 2 ▶ "Ciprofloxacin" ← one word. Not the whole prescription. #1 Amoxicillin 3x daily 5 days ✓ ← untouched #2 Ciprofloxacin 2x daily 7 days ✓ ← completed in place ◀ done

Turn 2 is one word, and row #1 is never re-heard or re-written. That only holds if you fold.

Why merging by index breaks

When the missing field is the one that identifies an entry — medicine_name — there is no label to hang the reply on. So the answer comes back one of three ways:

ShapeWhat you getNaive merge does
In placeitems[1] now completecorrect — nothing to do
Standaloneone items[0] holding just the answerwrites the answer into row #1
Appendeda new items[2] holding just the answergrows a phantom row #3

Two of the three corrupt the form. Merge by index and the clinician watches Amoxicillin turn into Ciprofloxacin — at which point they will start over, and the form has cost them time rather than saved it.

The rule

Remember what you asked. Recognise a reply that carries only the answer. Fold it into the row that was waiting.

1. Remember the question — on every ask:

pending = { item: next.item, missing: next.missing };

2. Classify each returned item. It is an answer fragment when all three hold:

function isAnswerFragment(item, pending, target) {
  const filled = FIELDS.filter((f) => isFilled(item.slots?.[f]?.value));
  if (filled.length === 0) return false;

  // A different medicine is a different medicine, not an answer.
  if (isFilled(item.slots?.medicine_name?.value) &&
      isFilled(target.medicine_name) &&
      !sameName(item.slots.medicine_name.value, target.medicine_name)) return false;

  // Everything it carries is either what we asked for, or a gap in the row.
  return filled.every((f) => pending.missing.includes(f) || !isFilled(target[f]));
}

That middle guard earns its keep: it stops "and also add Paracetamol" being swallowed into the row you were asking about.

3. Fold fragments, merge the rest.

const fragments = items.filter((i) => isAnswerFragment(i, pending, rows[pending.item]));
const rest      = items.filter((i) => !fragments.includes(i));

let next = mergeByIndex(rows, rest);              // real medicines, as usual
next = foldInto(next, pending.item, union(fragments));

When folding: fill gaps, overwrite only fields you actually asked about, and never overwrite a value the user typed by hand.

4. Recompute completeness yourself. After a fold, the response's needs_clarification, missing_slots and next.item describe our item list, which no longer lines up with yours.

rows = rows.map((row) => {
  const missing = REQUIRED.filter((f) => !isFilled(row[f]));
  return { ...row, missing, needsClarification: missing.length > 0 };
});

If every row is complete you are done — enable Save even though the response said ask. If something is still open, re-point the question at your first incomplete row.

5. Do not trust final_values after a fold. It will hold a different number of entries than your rows, and pairing them off by position writes the wrong medicine.

const base = final_values.medications.length === rows.length
  ? final_values.medications   // aligned — use it verbatim
  : null;                      // folded — the form is the source of truth

Tell the user which happened. Silently saving a mismatched payload is the worst outcome available.

Test these four

  1. Two medicines, the second nameless. Answer with the name only. → two rows, row 1 byte-identical, row 2 completed.
  2. Same, but the answer arrives as a third item. → still two rows.
  3. Answer with a different, complete medicine. → three rows; it was not folded.
  4. The answer arrives in place. → no fold, nothing changed.

Test 1 is the one that matters. If answering about the second medicine disturbs the first, the fold is wrong — and everything else can look fine while that is still broken.

12. Errors

Every error is {"error": "..."}.

CodeMeaningDo
400Bad body, audio too short or silentShow inline
401Missing/expired tokenRe-authenticate
402Out of creditsTop-up prompt — a billing state, not a failure
403Invalid or inactive API keyCheck the key
404Not found, or not yoursTreat as not found
409Already resolved / duplicate idTerminal — refresh
429Rate limit or daily quotaBack off
502 / 504Model service downRetry; credits were refunded

404 doubles as “not yours” deliberately, so a caller cannot probe which ids exist. Do not write copy that distinguishes them.

13. Checklist