Speech in, structured data out, speech back. Integration reference for the voice intent endpoints.
POST /voice/intentnext — what to doPOST /voice/resolvePOST /voice/rejectPOST /voice/respondKay-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.
There is one endpoint you send voice to, and three possible answers. Everything else is detail.
| You get | It means | You do | Their 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/resolve — yes 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.
| Credential | Header | Use for |
|---|---|---|
| API key | X-API-Key: kay_x<id>_<secret> | Your server. Runtime calls. |
| JWT | Authorization: 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.
Pick one before you write anything. They are not mixed.
| Message style | Context style | |
|---|---|---|
| You track | message_id | an opaque context token |
| Follow-up | /voice/resolve | next /voice/intent with the token |
| Slots accumulate | no — one turn at a time | yes — across turns |
/voice/reject | not available | available |
| Best for | WhatsApp, one-shot capture | IVR, 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.
multipart/form-data. This is the only endpoint that takes audio.
| Field | Req | Notes |
|---|---|---|
file | yes | Any ffmpeg-supported format. Max 25 MB. |
business_id | yes | Your public business handle. |
session_id | no | Conversation key. Reuse across turns for context. Generated if omitted. |
message_id | no | Idempotency key. Always send it. |
context | no | Context 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.
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" }
{
"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": [ … ]
}
| Field | What it is |
|---|---|
transcript_raw | What was heard. Check this first when anything looks wrong — most problems are transcription, not extraction. |
intent.name | Which of your configured intents matched. |
slots | Detail 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_values | The form-filling view. Flat name → value, present only when the turn resolved. This is what you prefill from. |
next | What to do now. See §6. |
items | Present only for repeating groups. See §10. |
replayed | true when this message_id was already processed. Nothing was re-charged. |
turn | Context style only. Accumulated state plus the next context token. |
normalizer_candidates | Internal 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.
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"]
}
| action | Meaning | What you do |
|---|---|---|
| done | Understood | Act on final_values |
| ask | A value is missing | Say say; send their next voice note to /voice/intent, same session_id |
| confirm | Heard, not confident | Say 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);
}
Where a confirm goes. Message style only. Free — it
costs no credits.
{ "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.
{ "message_id": "wa-0001", "correction": { "duration_days": 7 } }
{ "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.
{ "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.
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."
}
| Field | Req | Notes |
|---|---|---|
context | yes | The opaque token. Without it the call fails. |
slot | yes | Which field you refused. It is cleared and asked again. |
value | no | The refused value. Send it — it stops the caller looping by repeating the same answer. |
reason | no | Recorded for debugging. Never spoken. |
message | no | Your 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.
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
}
intent label is looked up in this business's bindings to find a template. No binding → 404.weighted_random by default), so the assistant does not repeat itself word for word.slots are rendered into the template: "500" becomes faiv hondred leones, a phone number becomes digits one at a time.Send plain values, not Krio words.
"500", not "faiv hondred". The renderer speaks them.
cacheHit: true means it was free.
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.
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.
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.
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 2 is one word, and row #1 is never re-heard or re-written. That only holds if you fold.
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:
| Shape | What you get | Naive merge does |
|---|---|---|
| In place | items[1] now complete | correct — nothing to do |
| Standalone | one items[0] holding just the answer | writes the answer into row #1 |
| Appended | a new items[2] holding just the answer | grows 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.
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 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.
Every error is {"error": "..."}.
| Code | Meaning | Do |
|---|---|---|
| 400 | Bad body, audio too short or silent | Show inline |
| 401 | Missing/expired token | Re-authenticate |
| 402 | Out of credits | Top-up prompt — a billing state, not a failure |
| 403 | Invalid or inactive API key | Check the key |
| 404 | Not found, or not yours | Treat as not found |
| 409 | Already resolved / duplicate id | Terminal — refresh |
| 429 | Rate limit or daily quota | Back off |
| 502 / 504 | Model service down | Retry; credits were refunded |
404 doubles as “not yours” deliberately, so a caller cannot probe
which ids exist. Do not write copy that distinguishes them.
message_id on every /voice/intent, or retries double-charge.session_id across turns, or context is lost.next.action, and treat ask and confirm differently.next.say. Log next.say_en. Never the reverse.final_values, not slots.final_values length against your row count before
saving. After a fold they no longer line up, and pairing by position
writes the wrong entry.normalizer_candidates — strip it server-side so it cannot
reach a UI by accident./voice/respond — "500", not "faiv hondred".402 globally with a top-up prompt.snake_case, the response layer is camelCase. Check the examples per endpoint.