# Kay-X — Integration Guide

Kay-X turns spoken Krio into structured actions, and structured actions back into spoken Krio.

```
  voice note ──▶ /voice/intent ──▶ { intent, slots } ──▶ your business logic
                      │
                      └── unsure? ──▶ clarification (Krio) ──▶ /voice/resolve
                                                                    │
  spoken reply ◀── /voice/respond ◀── { intent, slots } ◀───────────┘
```

**How this product is meant to be adopted.** You configure a business and get intent
detection right *in the dashboard*, where you can see every confidence score and iterate
for free. Only then do you write code, and the code you write is small: a handful of
runtime endpoints. Part 1 is the dashboard. Part 2 is the API. Do them in that order.

Base URL: `https://kay.geneline-x.net`

## Credentials

| Credential | Header | Use for |
| --- | --- | --- |
| **API key** | `X-API-Key: kay_x<keyId>_<secret>` | Your server, at runtime |
| **JWT** | `Authorization: Bearer <jwt>` | Dashboard and management calls |

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

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

---

# Part 1 — Set up in the dashboard

No code in this part. Each step names the screen it happens on.

## 1. Describe your business

*Screen: Businesses (`/businesses`)*

Create a business and tell Kay-X what your callers can ask for. An intent is one thing a caller wants ("send money"); a slot is one value that intent needs ("amount", "recipient phone"). Everything downstream — detection, clarification, spoken replies — is driven by what you write here, so this is the screen that decides whether the product works.

- Each slot carries text for two different audiences. **Description** is written for the AI, in English, and helps the model find the value. The **recovery prompts** are written for the caller, in Krio, and are spoken aloud.
- There are three recovery situations and they are not the same sentence. **If they never said it**, the caller hears your `prompt` — "Omoch moni yu wan sen?". **If they said it but it was not clear**, they hear your `retry_prompt` — "A no yeri di amount klia. Duya tok am bak." — asking them to say it again rather than to answer yes or no. **If your own systems refuse it** — an unregistered wallet, a meter that will not verify — they hear your `reject_prompt`, which explains rather than merely asking again. Only the slots that were actually unclear are re-asked, so nobody repeats a value you already heard. Leave any of them blank and the caller gets a generic fallback.
- You cannot tell whether a Krio sentence sounds right by reading it. Every prompt field has a play button that synthesizes it exactly as a caller would hear it, with `{value}` spoken properly for the slot type — an amount as words, a phone number digit by digit. Use it before shipping any wording.
- Turn on **spoken recovery** and the clarification comes back on the intent response as ready-to-play audio (`clarification_audio`, base64 WAV). Your integrator plays a file rather than orchestrating a second TTS call — recovery is configured once, here, instead of being rebuilt in every app that talks to you. Recovery wording repeats across callers, so it is served from the synthesis cache after the first time and only costs credits on a miss.
- Set `valid_prefixes` to every mobile prefix you actually serve. It is not cosmetic: a number whose prefix is missing from that list scores lower confidence and gets pushed into clarification even when it was heard perfectly.
- Thresholds are the confidence floor per slot type. Higher means asking to confirm more often, which is safer. Money movement belongs at 0.90 or above.

> **You're done when:** Your business appears in the list with at least one intent that has all its slots defined.

## 2. Tune detection in the Playground

*Screen: Playground (`/playground`)*

Type or record what a real caller would say, and watch what comes back: the transcript, the numeric candidates the parser found, the intent with its confidence, and each slot with its own confidence. This is where you find out that "zero seven four…" is not recognised, or that an intent description is too vague to match. Fix the config, run it again. Iterate here until detection is boring.

- Text mode costs only the detection fee and never enters the training pipeline, so it is the cheap way to iterate.
- When a slot looks wrong, check its raw_text — it tells you which part of the sentence the value came from. A phone number sourced from the amount span means the config, not the model, is the problem.
- The normalizer is free. Use it to check number and phone parsing on its own, without spending a detection fee.

> **You're done when:** Your realistic phrases come back with the right intent and the right slot values, and `needs_clarification` is false when it should be.

## 3. Give it something to say

*Screen: Templates (`/templates`)*

Detection gives you structure; templates give you speech. Browse the Krio template library, then bind one template to each intent on the business’s Responses tab. Without a binding, an intent is understood but has no reply.

- Prefer weighted_random or round_robin over fixed. A bot that says the identical sentence every time reads as a machine.
- Slot values are spoken by the renderer, so you pass plain values — "2500", not "tu tausin faiv hondred".

> **You're done when:** Every intent you care about has a bound template, and pressing Test plays back a sentence that sounds right.

## 4. Run the whole loop end to end

*Screen: Responses tab (`/businesses`)*

On the business’s Responses tab, run the full pipeline test: speak or type, watch it detect, then hear the Krio reply. If it asks for clarification, confirm or correct it there and listen to the result. This is the exact sequence your code will perform, so if it works here it will work in your app.

> **You're done when:** You hear a correct spoken Krio response to a realistic request, without touching any code.

## 5. Rehearse a real conversation

*Screen: Conversation tab (`/businesses`)*

Real callers answer in pieces. On the Conversation tab, say something incomplete — "I want to send money" — then give the number and the amount on separate turns, and watch the gathered values build up. It drives the same endpoints and the same context token your integrator will use, so anything that works here works in their code. Press Refuse on a value to rehearse what happens when your own KYC or meter check turns it down.

- This is the loop your integrator writes: send a message, store the returned context, play the question, repeat until ready. Nothing else is theirs to build.
- Text turns cost only the detection fee, so rehearsing is cheap.

> **You're done when:** A conversation answered across several turns reaches `ready` with the right final values, and refusing a value re-asks for that one alone.

## 6. Create an API key

*Screen: API Keys (`/apikeys`)*

Now — and only now — generate a key for your server. Everything above was configuration; the key is for the runtime calls your application makes.

- The key is shown once. The server keeps only a salted hash, so if you lose it you revoke and reissue.
- Never put it in browser JavaScript: it has no origin restriction and it spends credits. Browser → your backend → Kay-X.

> **You're done when:** You have a key starting with `kay_x` stored somewhere safe.

---

# Part 2 — Integrate

Everything above is now configuration the gateway already knows. Your application only
has to do this:

## 1. Send the voice note

Post the audio with your business id. Pass your channel’s own message id — the WhatsApp message id, the IVR call leg — as `message_id`. It is the idempotency key: if the request is retried, you get the stored turn back with `"replayed": true` instead of paying to transcribe it twice. Reuse one `session_id` across the turns of a conversation so the model can see the previous turn.

```js
const fd = new FormData()
fd.append('file', voiceNote)
fd.append('business_id', 'flot')
fd.append('session_id', 'whatsapp-23276123456')   // same value across turns
fd.append('message_id', waMessageId)              // idempotency

const res = await fetch('https://kay.geneline-x.net/api/v1/voice/intent', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.KAYX_KEY },  // server-side only
  body: fd,
})
const turn = await res.json()
```

> This call is synchronous and runs ASR plus an LLM, so it can take several seconds. Show real progress and set a client timeout well above 30s.

## 2. Branch on needs_clarification

That single boolean is the whole contract. False means Kay-X is confident: `final_values` is safe to act on. True means it is not, and guessing would be worse than asking — so speak the Krio question back to the caller and wait for their reply.

```js
if (!turn.needs_clarification) {
  await executeTransfer(turn.final_values)        // safe to act
} else {
  // Turn on "spoken recovery" for the business and the question comes back
  // already synthesized — just play it. No second call, no TTS to orchestrate.
  if (turn.clarification_audio) {
    await playToCaller(Buffer.from(turn.clarification_audio, 'base64'))
  } else {
    // clarification_message is Krio — this is what the caller hears.
    await sendToUser(turn.clarification_message)
  }
  // clarification_message_en is English, for your logs and dashboards only.
  logger.info(turn.clarification_message_en)
  pending[turn.session_id] = turn.message_id
}
```

> Never speak clarification_message_en to a caller. Showing English to a Krio speaker is the fastest way to make this feel foreign. clarification_audio is present only when the business has spoken recovery enabled and synthesis succeeded, so always keep the text path as a fallback.

## 3. Multi-turn: let Kay-X gather the slots

Real conversations arrive in pieces — "I want to send money", then a number, then an amount. Send back the `context` token from the previous response and Kay-X accumulates: it merges each turn into what it already has, tells you what is still missing, and gives you the next question with audio. You write a loop, not a state machine. The token is opaque and yours to store — we hold no session, so your flow keeps a single source of truth.

```js
let context = null                                  // first turn

while (true) {
  const turn = await detectIntent(userMessage, { context })
  context = turn.turn.context                       // store it, send it back

  if (turn.turn.state === 'cancelled') return abandon()
  if (turn.turn.ready) {
    await executeTransfer(turn.turn.final_values)   // your business logic
    break
  }

  // Still collecting: play the question and wait for the next message.
  await playToCaller(turn.turn.say.audio)
  userMessage = await nextMessage()
}

// Your own validation refused a value? Hand it back and we re-ask for
// that one slot — everything else the caller already gave is kept.
const next = await fetch('https://kay.geneline-x.net/api/v1/voice/reject', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.KAYX_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ context, slot: 'recipient_phone',
                         value: '+23276123456', reason: 'not_registered' }),
})
```

> The context token is signed and versioned — store it and send it back, never build or parse it. A rejected value is remembered, so a caller repeating the same wrong number cannot loop forever. Kay-X also reports control words (cancel, menu, help, repeat) in Krio and English as turn.control, so you do not have to guess at the synonyms yourself.

## 4. Confirm or correct

When the caller replies, send it to /voice/resolve. "yes" confirms what was detected; a correction object fixes a specific slot. A rejection comes back with no `final_values`, so there is nothing to act on. This call is free, and its confirmations and corrections are the labelled data that makes the Krio models better — never suppress it to save money.

```js
// Confirm
await fetch('https://kay.geneline-x.net/api/v1/voice/resolve', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.KAYX_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ message_id: pending[sessionId], reply: 'yes' }),
})

// …or correct a slot
body: JSON.stringify({ message_id, correction: { amount: 700000 } })

// → { "status": "resolved", "intent": "send_money",
//     "final_values": { "amount": 700000, "recipient_phone": "+23276123456" } }
```

> Affirmative words: yes y yeah yep confirm ok okay correct sure fine. Negative: no n nope cancel wrong reject stop. A 409 means the turn was already resolved — treat it as terminal and re-fetch the session.

## 5. Speak back

Send the intent and its final values to /voice/respond. Kay-X renders the template you bound in the dashboard and returns base64 WAV. Slot values go in as plain strings; the renderer is what turns 2500 into spoken Krio.

```js
const res = await fetch('https://kay.geneline-x.net/api/v1/voice/respond', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.KAYX_KEY, 'Content-Type': 'application/json' },
  // note: camelCase here, unlike the snake_case intent layer
  body: JSON.stringify({
    businessId: 'flot',
    intent: 'send_money',
    slots: { amount: '2500', recipient_phone: '076123456' },
    speakerId: 'jojo',
  }),
})
const { text, audio, cacheHit } = await res.json()   // audio is base64 WAV
```

> cacheHit: true means the synthesis was reused and cost nothing.

## 6. Play the audio

Decode the base64 WAV and play it. Revoke the object URL when it finishes, or long-running processes leak blob URLs.

```js
function playBase64Wav(b64) {
  const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
  const url = URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' }))
  const el = new Audio(url)
  el.onended = () => URL.revokeObjectURL(url)
  el.play()
}
```

---

# Runtime API reference

The endpoints your application calls.

## The voice loop

### POST /api/v1/voice/intent

**Detect Intent — Audio** — Send a voice note, get back structured intent and slots. This is the call your app makes on every voice message.

- Auth: `X-API-Key`
- Cost: ASR by audio duration + a flat detection fee.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `file` | string | yes | multipart/form-data. Any ffmpeg-supported format, max 25 MB. |
| `business_id` | string | yes | — |
| `session_id` | string | no | Conversation key. Reuse across turns to give the model the previous turn as context. |
| `message_id` | string | no | Idempotency key — pass your channel’s own id. A retry returns the stored turn with "replayed": true, uncharged. |
| `context` | string | no | Opt into slot accumulation: send back the token from the previous turn and the response gains a `turn` view with everything gathered so far. |
| `turn` | enum | no | Ask for the turn view on the first turn, before there is a token to send. One of: 1, 0 |

Response:

```json
{
  "business_id": "flot",
  "message_id": "wamid.HBgLMjMy…",
  "session_id": "whatsapp-23276123456",
  "transcript_raw": "sen tu tausin faiv hondred to zero seven six …",
  "normalizer_candidates": [
    { "raw_span": "tu tausin faiv hondred", "value": "2500",
      "value_type": "amount", "confidence": 0.95 }
  ],
  "intent": { "name": "send_money", "confidence": 0.94 },
  "slots": {
    "amount":          { "name": "amount", "value": 2500,
                         "raw_text": "tu tausin faiv hondred", "confidence": 0.95 },
    "recipient_phone": { "name": "recipient_phone", "value": "+23276123456",
                         "raw_text": "zero seven six …", "confidence": 0.93 }
  },
  "needs_clarification": false,
  "resolved": true,
  "final_values": { "amount": 2500, "recipient_phone": "+23276123456" }
}

// When needs_clarification is true, and the business has spoken recovery on:
{
  "needs_clarification": true,
  "clarification_message": "A no yeri di nomba klia. Duya tok am bak.",
  "clarification_message_en": "I did not hear the number clearly. Please say it again.",
  "clarification_audio": "<base64 WAV — play this straight back>",
  "clarification_audio_cached": true,
  "clarification_details": { "low_confidence_slots": ["recipient_phone"] }
}
```

> Body is multipart/form-data, not JSON. Branch on needs_clarification in the response — that is the whole contract. In the dashboard, the Playground is where you send real audio.

### POST /api/v1/voice/intent/text

**Detect Intent — Text** — Same pipeline, but you supply the transcript. Use it when your channel is text, or to test a config without spending ASR credits.

- Auth: `X-API-Key`
- Cost: Detection fee only — the cheapest way to exercise the pipeline.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `business_id` | string | yes | Your business config ID |
| `session_id` | string | yes | Conversation thread ID — reuse to continue a turn |
| `text` | string | yes | Krio or English transcription |
| `message_id` | string | no | Your channel’s own message id. Needed to correlate the turn and to call /voice/resolve. Replay protection applies to the audio endpoint, not this one. |
| `context` | string | no | Opt into slot accumulation: send back the token from the previous turn. |

Request:

```json
{
  "business_id": "flot",
  "session_id": "test-001",
  "text": "sen wan tausin to zero seven six one two three four five six"
}
```

> Text turns are not written to the training pipeline, so test traffic never pollutes the flywheel.

### POST /api/v1/voice/resolve

**Confirm or Correct** — Call this with the caller’s reply after a clarification question. Confirms, corrects a slot, or cancels the turn.

- Auth: `X-API-Key`
- Cost: Free.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `message_id` | string | yes | message_id from the intent response |
| `reply` | enum | no | User reply One of: yes, no |
| `correction` | string | no | Slot corrections as JSON object (alternative to reply) |

Request:

```json
{
  "message_id": "msg_abc123",
  "reply": "yes"
}
```

Response:

```json
{
  "message_id": "msg_abc123",
  "session_id": "whatsapp-23276123456",
  "status": "resolved",
  "intent": "send_money",
  "final_values": { "amount": 700000, "recipient_phone": "+23276123456" }
}
```

> Free, and the single most valuable call in your integration: confirmations and corrections become labelled training data, and corrections are worth more than confirmations. Never skip it to save credits. Send reply OR correction, not both. A rejection returns no final_values — do not act on it. 409 means already resolved.

### POST /api/v1/voice/reject

**Reject a Value** — Turn down a value your own systems refused — an unregistered wallet, a meter that will not verify — and get the question to ask instead.

- Auth: `Authorization: Bearer <jwt>`
- Cost: Free (except clarification audio, which is cached).

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `context` | string | yes | The token from the previous turn |
| `slot` | string | yes | Which value you are refusing |
| `value` | string | no | The refused value. Supplying it stops the caller looping by repeating the same answer. |
| `reason` | string | no | Recorded for your debugging; never spoken |
| `message` | string | no | Optional wording of your own, in the caller’s language. Left empty, the slot’s configured reject_prompt is used. |

Request:

```json
{
  "context": "<token>",
  "slot": "recipient_phone",
  "value": "+23276123456",
  "reason": "not_registered_on_orange"
}
```

> Real flows rewind because your validation failed, not because confidence was low. This clears the slot, keeps everything else the caller already gave, and remembers the refused value so repeating it cannot loop forever. Author the wording once as reject_prompt on the slot instead of passing message every time.

### POST /api/v1/voice/respond

**Speak Back** — Render the template bound to an intent and synthesize Krio audio. This is how your app replies out loud.

- Auth: `Authorization: Bearer <jwt>`
- Cost: TTS by rendered length — free on a cache hit.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | string | yes | — |
| `intent` | string | yes | — |
| `slots` | string | no | All required intent slots must be provided |
| `speakerId` | enum | no | One of: jojo, richard |
| `sessionId` | string | no | — |

Response:

```json
{
  "text": "Di moni don rich Aminata na tu tausin faiv hondred leones",
  "audio": "<base64-encoded WAV>",
  "templateId": "clxyz…",
  "variantId": "clabc…",
  "cacheHit": false,
  "requiresQa": false
}
```

> Slot values go in as plain strings — "2500", not "tu tausin faiv hondred". The renderer speaks them. Note the camelCase body here, unlike the snake_case intent layer. Requires a binding for the intent; missing required slots are a 400.

### POST /api/v1/voice/respond/text

**Speak Arbitrary Text** — Synthesize any Krio string as speech, with no template and no intent. For notifications, IVR messages and broadcasts.

- Auth: `Authorization: Bearer <jwt>`
- Cost: TTS by text length.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | string | yes | — |
| `text` | string | yes | — |
| `speakerId` | enum | no | One of: jojo, richard |
| `sessionId` | string | no | — |

> Still needs a businessId for billing, but no intents, slots or templates.

### POST /api/v1/voice/normalize

**Normalizer (debug)** — Run the deterministic number parser on raw text. Shows exactly which amounts and phone numbers Kay-X can see in a phrase.

- Auth: `X-API-Key`
- Cost: Free — no model call.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `business_id` | string | no | Optional — uses this business phone locale |
| `text` | string | yes | Raw Krio or English text |

Request:

```json
{
  "business_id": "flot",
  "text": "wan tausin faiv hondred"
}
```

> Free and instant, so it is safe to call on every keystroke. If a phone number comes back with low confidence here, add its operator prefix to the business’s valid_prefixes — otherwise real numbers get sent to clarification.

## Speech & language

### POST /api/v1/transcribe_url

**Transcribe Audio (URL)** — Krio speech to text, from an audio URL. Use it standalone when you only need a transcript.

- Auth: `X-API-Key`
- Cost: ASR by audio duration.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `url` | string | yes | MP3, WAV, OGG supported |

Request:

```json
{
  "url": "https://example.com/audio.wav"
}
```

### POST /api/v1/tts/generate

**Text to Speech** — Turn Krio text into spoken audio. The raw synthesis primitive underneath /voice/respond.

- Auth: `X-API-Key`
- Cost: TTS by text length.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `text` | string | yes | — |
| `speaker_id` | string | no | — |

> Returns audio. Synthesis is GPU work and can take tens of seconds — set a generous client timeout.

### POST /api/v1/translate

**Translate** — Translate between English and Krio.

- Auth: `X-API-Key`
- Cost: 2 credits per 100 characters.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `text` | string | yes | — |
| `source_lang` | enum | yes | One of: en, kri |
| `target_lang` | enum | yes | One of: kri, en |

Request:

```json
{
  "text": "Hello, how are you?",
  "source_lang": "en",
  "target_lang": "kri"
}
```

## Credits & pricing

### GET /api/v1/pricing

**Price List** — What every call costs, in credits. Public — no auth.

- Auth: None (public)
- Cost: Free.

Response:

```json
{
  "credit_value_usd": 0.01,
  "asr":          { "credits_per_block": 8,  "block_seconds": 10 },
  "tts":          { "credits_per_block": 10, "block_chars": 100 },
  "voice_intent": { "asr_credits_per_block": 8, "asr_block_seconds": 10,
                    "detection_credits": 5 },
  "translation":  { "credits_per_block": 2, "block_chars": 100 },
  "free": ["/api/v1/voice/resolve", "/api/v1/voice/normalize"],
  "signup_bonus_credits": 200
}
```

> Read costs from here rather than hardcoding them: rates are tunable at runtime with no redeploy. The `free` array lists endpoints that never charge.

### GET /api/v1/credits

**Credit Balance** — Current balance plus recent transactions. Poll it from your backend to alarm before you run dry.

- Auth: `Authorization: Bearer <jwt>`
- Cost: Free.

Response:

```json
{
  "balance": 1847,
  "transactions": [
    { "id": "…", "amount": -13, "balanceAfter": 1847,
      "txType": "voice_intent", "description": "Voice intent (8.2s audio)",
      "createdAt": "2026-08-20T10:14:02Z" }
  ]
}
```

> Credits are charged before the upstream call and refunded automatically when it fails, so you never handle refunds yourself — just refresh the balance after a failure.

---

# Management API reference

You normally do all of this in the dashboard. These endpoints exist so you *can*
automate setup — provisioning many businesses, syncing intents from your own system —
but a first integration does not need them.

## Business config

### GET /api/v1/businesses

**List Businesses** — Returns all business configs associated with your account.

- Auth: `Authorization: Bearer <jwt>`

### POST /api/v1/businesses

**Create Business** — Create a business config in code. The Businesses screen does the same thing with validation and a live preview.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `business_id` | string | yes | Lowercase kebab, must be unique |
| `display_name` | string | yes | — |
| `language` | enum | yes | One of: krio, en |
| `currency` | string | no | Optional |

> Two ids come back: the internal `id` and the public `business_id`. Always pass `business_id` to every other endpoint. Each slot takes `description` (English, for the model), `prompt` (Krio, asked when the value is missing) and `retry_prompt` (Krio, asked when the value was heard but not clearly — use {value} to read back what was heard).

### GET /api/v1/businesses/{businessId}

**Get Business** — Fetch the full config for a single business.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | path | yes | — |

### PUT /api/v1/businesses/{businessId}

**Update Business** — Replace a business config.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | path | yes | — |

> This is a full replace, not a patch. Fetch the config, change what you need, and send the whole thing back — anything you omit is erased. `business_id` in the body must match the URL.

### DELETE /api/v1/businesses/{businessId}

**Delete Business** — Permanently delete a business config and all its session history.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | path | yes | — |

### GET /api/v1/businesses/{businessId}/sessions

**List Sessions** — Paginated list of voice sessions. The Sessions tab renders this turn by turn.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | path | yes | — |
| `limit` | number | no | Max results (default 20, caps at 200) |
| `offset` | number | no | Pagination offset |

### GET /api/v1/businesses/{businessId}/analytics

**Business Analytics** — Resolution rate, correction count, clarification rate, intent distribution.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | path | yes | — |

> Resolution rate and correction rate are the real health metrics. A high clarification rate means thresholds are too strict or ASR is struggling; a high correction rate means the model is confidently wrong — go read those transcripts.

## Templates & bindings

### GET /api/v1/templates

**List Templates** — Browse the shared Krio response template library.

- Auth: None (public)

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `category` | string | no | Optional — category code filter |
| `source` | enum | no | Optional — starter vs custom templates One of: starter, custom |

### GET /api/v1/templates/categories

**Template Categories** — Response shapes, not industries: confirmation, success, failure, prompt, reminder, greeting, info.

- Auth: None (public)

### GET /api/v1/templates/{id}/preview

**Preview Template** — Render a template into spoken Krio. Returns WAV, with the rendered text in the X-Rendered-Text header.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | path | yes | — |
| `audio` | enum | no | Set to 0 to skip TTS and get JSON text One of: 0, 1 |

> Audio preview runs real synthesis: it requires auth and costs TTS credits. Text-only (?audio=0) is free and open — render gallery cards from that and spend credits only when someone presses Play. Never pre-fetch audio for a grid. Override sample values with query params, e.g. ?amount=500&name=Aminata.

### POST /api/v1/templates

**Create Template** — Add a custom Krio response template.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `categoryId` | string | yes | — |
| `name` | string | yes | — |
| `body` | string | yes | — |
| `language` | enum | yes | One of: krio, en |
| `slots` | string | no | Optional — array of slot objects |
| `variants` | string | no | Optional — array of alternative body texts |

> Custom templates start at qaStatus "pending" and set requiresQa: true on every response until reviewed. Slots and variants must be valid JSON arrays.

### GET /api/v1/businesses/{businessId}/bindings

**List Bindings** — The intent → template bindings for a business.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | string | yes | Sent as a query param |

### POST /api/v1/businesses/{businessId}/bindings

**Create Binding** — Bind a template to one of your intents. Without a binding, /voice/respond has nothing to say.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | string | yes | Sent as a query param |
| `intent` | string | yes | — |
| `templateId` | string | yes | — |
| `policy` | enum | no | One of: weighted_random, round_robin, fixed |

> Policy controls variant selection. Prefer weighted_random or round_robin — a bot that says the identical sentence every time reads as a machine.

### PUT /api/v1/businesses/{businessId}/bindings/{intent}

**Update Binding** — Change the template or selection policy for an intent binding.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | string | yes | Sent as a query param |
| `intent` | path | yes | — |
| `templateId` | string | no | — |
| `policy` | enum | no | One of: weighted_random, round_robin, fixed |

### DELETE /api/v1/businesses/{businessId}/bindings/{intent}

**Delete Binding** — Remove a binding. The business then has no spoken response for that intent.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `businessId` | string | yes | Sent as a query param |
| `intent` | path | yes | — |

## Credits & pricing

### GET /api/v1/billing/packages

**Credit Packages** — Purchasable credit tiers. Public pricing info.

- Auth: None (public)

### POST /api/v1/billing/checkout

**Start Checkout** — Begin a credit purchase. Returns a checkout_url to redirect to.

- Auth: `Authorization: Bearer <jwt>`

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `package_id` | string | yes | id from /billing/packages |
| `success_url` | string | no | — |
| `cancel_url` | string | no | — |

Request:

```json
{
  "package_id": "pkg_growth"
}
```

> Credits land via webhook, which can arrive just after the redirect — poll /api/v1/credits on your success page rather than assuming the balance is updated.

---

# Errors

| Status | Meaning | What your app should do |
| --- | --- | --- |
| 400 | Bad body, missing field, audio too short or silent | Show the message inline |
| 401 | Missing or expired token | Redirect to login |
| 402 | Out of credits | Open a top-up prompt — this is a billing state, not an error |
| 403 | Invalid or inactive API key | Send them to key management |
| 404 | Not found — or not yours | Treat as not found; ownership failures return 404 on purpose, so never distinguish the two in copy |
| 409 | Already resolved, or duplicate message id | Refresh the session; treat as terminal |
| 429 | Rate limit or daily quota | Back off and retry |
| 502 / 504 | Model service down or timed out | Retry — credits were refunded automatically |
| 503 | Database or dependency unavailable | Retry later |

# Credits

1 credit = $0.01. Read live rates from `GET /api/v1/pricing` rather than hardcoding them;
an admin can retune them at runtime. Charges are taken before the upstream call and
refunded automatically if it fails.

| Call | Charge |
| --- | --- |
| `POST /api/v1/voice/intent` | ASR by audio duration + a flat detection fee. |
| `POST /api/v1/voice/intent/text` | Detection fee only — the cheapest way to exercise the pipeline. |
| `POST /api/v1/voice/resolve` | Free. |
| `POST /api/v1/voice/reject` | Free (except clarification audio, which is cached). |
| `POST /api/v1/voice/respond` | TTS by rendered length — free on a cache hit. |
| `POST /api/v1/voice/respond/text` | TTS by text length. |
| `POST /api/v1/voice/normalize` | Free — no model call. |
| `POST /api/v1/transcribe_url` | ASR by audio duration. |
| `POST /api/v1/tts/generate` | TTS by text length. |
| `POST /api/v1/translate` | 2 credits per 100 characters. |
| `GET /api/v1/pricing` | Free. |
| `GET /api/v1/credits` | Free. |

# Gotchas

- **Never put an API key in browser JavaScript.** It has no origin restriction and it spends credits. Browser → your backend → Kay-X. Runtime endpoints also accept a session JWT, which is what this dashboard uses.
- **Send message_id on every audio /voice/intent.** It is the idempotency key: a retry with the same value replays the stored turn as "replayed": true instead of re-transcribing and re-charging. WhatsApp retries constantly. On /voice/intent/text the field is accepted for correlation but does not replay.
- **The context token is opaque — store it, do not parse it.** It is signed and versioned, so a hand-built one is rejected and the shape can change without breaking you. Keep it beside your own transaction record and echo it back each turn. We deliberately hold no session state: your flow stays the single source of truth, and there is nothing of ours to drift from it.
- **Reuse session_id across turns.** It is how the model sees the previous turn. A fresh id every message throws that context away.
- **Speak clarification_message, log clarification_message_en.** Krio to the caller, English to your staff. Never the reverse.
- **/voice/resolve is free — never skip it to save money.** Confirmations and corrections are the training signal that improves the Krio models, and corrections are worth more than confirmations.
- **PUT /businesses/{id} fully replaces the config.** Send the whole config back, or you erase the fields you left out.
- **Casing is inconsistent between the two halves of the API.** The intent layer is snake_case (business_id, needs_clarification); the response layer is camelCase (businessId, templateId). This is a wart, not a rule you can infer — check the example for the endpoint you are calling.
- **A business has two ids.** The internal `id` (a UUID) and the public `business_id` handle. Every endpoint takes `business_id`.
- **402 is a UI state, not a failure.** Handle it once, globally, with a top-up prompt. Nothing is charged for the call that returned it.
- **Charges are refunded automatically.** Credits are taken before the upstream call and returned when it fails, so you never implement refund handling — just refresh the balance after a failure.
- **Do not pre-fetch preview audio for a gallery.** Every card would bill. Render cards from ?audio=0, which is free, and spend credits only when someone presses Play.
- **Set your CORS origins.** GATEWAY_CLIENT_CORS is a comma-separated allowlist; only listed origins get CORS headers. X-Rendered-Text is already exposed.

# Known limits

- No webhook or callback mode yet — /voice/intent holds the connection until it is done.
- No sandbox mode yet — testing spends real credits. /voice/intent/text is the cheapest path and stays out of the training pipeline.
- Rate limiting is per-instance, so behind a load balancer the effective limit is higher than configured.
