Camlin Speech API

Speech-to-Text

Recognition tuned for Australian English — the accents, the agencies, the surnames, and the reference numbers that generic engines mishear. There are two ways in: a batch endpoint that takes a WAV and returns a JSON transcript, and a live streaming session that commits words as it becomes confident in them. Both apply your tenant's lexicon, which is how names come back spelled correctly instead of phonetically mangled.

Endpoints

POST/api/v1/asr/recognize
POST/api/v1/asr/token
GET/api/v1/asr/healthz
GET/api/v1/asr/lexicon/{tenant}
PUT/api/v1/asr/lexicon/{tenant}
DELETE/api/v1/asr/lexicon/{tenant}
GET/api/v1/asr/lexicon

Authentication

The launch mechanism is API keys. A key looks like cs_live_… (production) or cs_test_… (sandbox / time-boxed pilots) and is sent on every request as Authorization: Bearer cs_live_… (canonical) or the x-api-key header. A workspace owner or admin creates keys in the Architect console (Speech → API keys, alongside usage and request logs); Camlin issues them directly for pilots that have no workspace yet. Either way a key is shown once, stored only as a hash, and rotation is create-new / move-caller / revoke-old.

ScopeGrants
sttPOST /api/v1/asr/recognize
stt:streamThe live Socket.IO streaming session
ttsPOST /api/v1/tts/synthesize
visemeThe viseme/blendshape track on synthesis responses
lexiconRead/write your own tenant lexicon
opsOperator overrides (backend headers, non-default models, debug) — not issued to customers

Rate limits are enforced per key (token bucket, per product). Responses carry RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset; a limited request receives 429 with Retry-After. Tier numbers are part of your plan.

Demo tier. Disabled in production. There is no keyless carve-out for external callers: the public site's own demo pages go through the site's server, which holds its own key. Every direct API call needs a key.

Enablement status. Live. Authentication has been enforced in production since 5 August 2026. An unkeyed request is refused with 401 and body {"detail": "API key required; send Authorization: Bearer cs_…"}. Send the key as Authorization: Bearer cs_live_… (or the x-api-key alias) on every request.

Browser ASR tokens. A web page must never hold a real API key. If your product runs recognition in the browser (as the Camlin avatar SDK's mic path does), your server calls POST /api/v1/asr/token with its real key (stt scope, optional body {"ttl_s": 300}, max 600) and hands the page the returned cat_… token. The page then sends Authorization: Bearer cat_… to /api/v1/asr/recognize directly. The token is ASR-only (nothing else, and it cannot mint tokens), tenant-bound (your lexicon biasing applies and cannot be re-pointed by the page), short-lived, and rate-limited per visitor IP at the demo tier.

Data Retention

  • Usage/request metadata (timestamps, route, status, audio seconds, character counts, key/tenant ids) is retained for 90 days, then purged automatically.
  • No payload retention: audio sent for recognition and transcripts produced are not stored unless your tenant explicitly opts in. Lexicon contents you store are tenant data, retained until you delete them.

Recognize Audio

Send RIFF/WAV bytes in the request body — the bytes are the body, not a multipart form.

Audio format, and what it costs you. Mono 16 kHz PCM is what the model is trained on and what we recommend. Other WAV sample rates are accepted and resampled — including 8 kHz telephony audio, which is the one worth knowing about: on our internal evaluation set, G.711-grade 8 kHz narrowband costs about 1.3 WER points absolute against the same audio at 16 kHz. That is usable for IVR work (it is what phone lines are), but if you control the capture path, capture at 16 kHz — do not downsample and give accuracy away for nothing. Narrowband-specific training is in the current model roadmap.

HeaderRequiredDescription
Authorization: Bearer cs_…Yes (keyed callers)Your API key; x-api-key is an accepted alias.
Content-Type: audio/wavRecommendedIdentifies the body as WAV audio.
x-camlin-asr-languageNoLanguage hint. Use en-AU unless directed otherwise. ISO 639-1 codes and BCP-47 tags are accepted; the region subtag is ignored for recognition (en-AU → en). An unsupported language is refused with 400.
x-camlin-asr-modelNoRecognition profile. camlin-au is the production default; non-default profiles require the ops scope.
x-camlin-tenant-idNoWith a key, your own tenant lexicon is applied automatically and this header is ignored (operator keys may select a tenant).
x-camlin-asr-backendNoOperational override; requires the ops scope. Omit for normal production traffic.
bash
curl -X POST "$CAMLIN_SPEECH_URL/api/v1/asr/recognize"   -H "Authorization: Bearer $CAMLIN_API_KEY"   -H "Content-Type: audio/wav"   -H "x-camlin-asr-language: en-AU"   --data-binary @sample.wav

Response Schema

json
{
  "text": "good morning, thanks for calling Camlin",
  "confidence": 0.93,
  "duration_s": 2.18,
  "backend": "local",
  "model": "camlin-au",
  "asr_model": "camlin-au",
  "words": [
    { "word": "good", "start": 0.12, "end": 0.38, "confidence": 0.96 }
  ]
}
FieldTypeDescription
textstringFull transcript. An empty string means no speech was recognized — it is not an error, so decide what your application does with silence.
confidencenumberOverall confidence in the range 0..1 when available.
duration_snumberAudio duration in seconds.
backendstringOperational route used by the service. Diagnostics only — the value varies by deployment and will change without notice; never branch on it.
modelstringThe recognition profile that served the request (camlin-au for default traffic). Matches asr_model unless an operator routed the call to a hosted provider.
asr_modelstringRecognition profile requested or selected by default. This one is stable — it echoes the contract-level profile name.
wordsarrayWord-level timing and confidence entries, when the decode produced them. May be null; treat it as optional.

Errors

StatusMeaningTypical Fix
400Empty body, invalid WAV, unknown profile, unsupported language, or invalid override.Re-encode to WAV, use a supported profile, and send a supported language tag.
401Missing, malformed, revoked, or expired API key.Check the Authorization header; rotate the key if revoked.
403Key lacks the required scope, or an operator-only feature was requested.Ask Camlin to add the scope, or drop the override header.
413Request too large.Split long recordings before submitting.
429Rate limit exceeded for the key.Honour Retry-After; request a higher tier if sustained.
503Requested route unavailable.Retry with default routing or fall back to queued processing.
500Recognition failed unexpectedly.Retry once, then capture request context for support.

Tenant Lexicons

The lexicon is the mechanism behind the "gets Australian names right" claim, so it is worth thirty seconds. Every tenant can store a vocabulary — customer surnames, agency names, suburbs, product terms — and once it exists it is applied automatically to every recognition call made with that tenant's key. No per-request configuration, no client code change: update the lexicon and the next call benefits.

Self-service is keyed and own-tenant-only. A key carrying the lexicon scope can read, write, and delete the lexicon for its own tenant — and no other; cross-tenant access returns 403. Enumerating tenants is operator-only.

Availability note. The /api/v1/asr/lexicon* paths are live on the public hostname for keyed callers (the 2 August edge rule lifted with the 5 August launch configuration). If a call is refused, check the key carries the lexicon scope.

json
{
  "tenant_id": "acme-au",
  "names": ["Sione Tuilagi", "Aanya Reddy", "Hayden Vu"],
  "context_words": ["Centrelink", "NDIS", "BPAY"],
  "pronunciations": { "Tuilagi": "too-ee-LAH-ngee" },
  "boost": 5.0
}
bash
curl -X PUT "$CAMLIN_SPEECH_URL/api/v1/asr/lexicon/acme-au"   -H "Authorization: Bearer $CAMLIN_API_KEY"   -H "Content-Type: application/json"   -d '{"names":["Sione Tuilagi"],"context_words":["NDIS"]}'

Unknown tenant IDs are ignored for recognition, so lexicons can be rolled out incrementally.

Streaming Recognition

Live streaming recognition powers the public /live demo. It is served over Socket.IO, websocket transport only, on the /socket.io path of the same host — it is not a plain WebSocket endpoint, so use a Socket.IO client library configured with transports: ["websocket"]. HTTP long-polling is refused at the handshake: the service scales horizontally and a polled session cannot be guaranteed to keep landing on the task that holds its decode state, so websocket-only keeps the whole session on a single connection pinned to one task.

Credentials travel in the connect payload, the Socket.IO-native place for them (never the query string, which ends up in access logs):

javascript
const socket = io(CAMLIN_SPEECH_URL, {
  path: "/socket.io",
  transports: ["websocket"],
  auth: { key: process.env.CAMLIN_API_KEY },  // requires the stt:stream scope
});

An Authorization: Bearer header on the connect request is also accepted for non-browser clients. Connections without a key are refused at connect, as is a key that is invalid or lacks stt:stream. Streamed audio is metered in seconds against your tenant when the session ends.

Event (client → server)PayloadPurpose
live_audio_chunkraw 16 kHz mono int16 PCM bytes (Int16Array.buffer; base64 also accepted)Stream microphone audio. A session opens on the first chunk.
live_set_asr_model{ "model": "camlin-au" } or a bare stringOptional: select a recognition profile. Confirmed with asr_model_selected.
live_set_streaming_paramstuning objectOptional per-session latency/stability tuning.
live_stop—End of stream; flushes a final decode and commits remaining words.

The session distinguishes committed text — words the decoder has confirmed, which will never change — from tentative text, the unstable tail that may be revised or vanish on the next decode. They arrive on different events, and getting this distinction right is most of building a good live-caption UI:

Event (server → client)Payload (key fields)Meaning
transcription{ text, committed, committed_full, is_final, confidence, commit_reason }A batch of newly committed words. committed_full is the whole committed transcript so far, so a client that missed an event can resynchronise. is_final: true marks the flush triggered by live_stop.
partial_transcription{ tentative, committed_full }The current unstable tail. Replaces the previous tentative text entirely — render it greyed out, never append it. Emitted even when empty, so your UI can clear the tail when speech stops.
live_streaming_status{ … }Decode-window housekeeping (e.g. a window rejected for silence or low confidence). Informational.

Ordering guarantees worth building against:

  • Within one decode cycle, transcription (if any words committed) arrives before the partial_transcription that carries the new tail — so apply committed text first, then overwrite the tentative display.
  • Committed words never retract. Tentative words carry no such promise; treat them as display-only and never hand them to downstream logic.
  • commit_reason says why words committed: local_agreement (two consecutive decodes agreed — the normal path) or forced_tail (the tail cap pushed them through to keep latency bounded; slightly less certain).
  • After live_stop, one final decode flushes the remaining tail as a transcription with is_final: true. Wait for it before treating the transcript as complete.

Planned. A productised plain-WebSocket streaming API (no Socket.IO dependency), with credentials carried at connection time, is on the launch roadmap for contact-centre platform integrations.

Implementation Recipe

javascript
export async function recognizeWav(wavBuffer) {
  const headers = {
    authorization: `Bearer ${process.env.CAMLIN_API_KEY}`,
    "content-type": "audio/wav",
    "x-camlin-asr-language": "en-AU",
  };

  const res = await fetch(`${process.env.CAMLIN_SPEECH_URL}/api/v1/asr/recognize`, {
    method: "POST",
    headers,
    body: wavBuffer,
  });
  if (!res.ok) throw new Error(`recognize failed: HTTP ${res.status}`);
  return res.json();
}

Your tenant's lexicon is applied automatically from the key — no per-request tenant header is needed.

Operational Notes

  • Production callers should omit backend overrides and let the service route to the fastest healthy path with CPU fallback.
  • Near-silent audio returns an empty text with confidence: 0 — the service gates on signal energy before decoding, so silence is never transcribed as hallucinated speech. Treat an empty transcript as "no speech", not an error.
  • camlin-au is the customer-facing default recognition profile.
  • Lexicon data can contain customer information; keep exports out of source control and support tickets unless approved.

See also