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
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.
| Scope | Grants |
|---|---|
stt | POST /api/v1/asr/recognize |
stt:stream | The live Socket.IO streaming session |
tts | POST /api/v1/tts/synthesize |
viseme | The viseme/blendshape track on synthesis responses |
lexicon | Read/write your own tenant lexicon |
ops | Operator 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.
| Header | Required | Description |
|---|---|---|
Authorization: Bearer cs_… | Yes (keyed callers) | Your API key; x-api-key is an accepted alias. |
Content-Type: audio/wav | Recommended | Identifies the body as WAV audio. |
x-camlin-asr-language | No | Language 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-model | No | Recognition profile. camlin-au is the production default; non-default profiles require the ops scope. |
x-camlin-tenant-id | No | With a key, your own tenant lexicon is applied automatically and this header is ignored (operator keys may select a tenant). |
x-camlin-asr-backend | No | Operational override; requires the ops scope. Omit for normal production traffic. |
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.wavResponse Schema
{
"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 }
]
}| Field | Type | Description |
|---|---|---|
text | string | Full transcript. An empty string means no speech was recognized — it is not an error, so decide what your application does with silence. |
confidence | number | Overall confidence in the range 0..1 when available. |
duration_s | number | Audio duration in seconds. |
backend | string | Operational route used by the service. Diagnostics only — the value varies by deployment and will change without notice; never branch on it. |
model | string | The 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_model | string | Recognition profile requested or selected by default. This one is stable — it echoes the contract-level profile name. |
words | array | Word-level timing and confidence entries, when the decode produced them. May be null; treat it as optional. |
Errors
| Status | Meaning | Typical Fix |
|---|---|---|
400 | Empty body, invalid WAV, unknown profile, unsupported language, or invalid override. | Re-encode to WAV, use a supported profile, and send a supported language tag. |
401 | Missing, malformed, revoked, or expired API key. | Check the Authorization header; rotate the key if revoked. |
403 | Key lacks the required scope, or an operator-only feature was requested. | Ask Camlin to add the scope, or drop the override header. |
413 | Request too large. | Split long recordings before submitting. |
429 | Rate limit exceeded for the key. | Honour Retry-After; request a higher tier if sustained. |
503 | Requested route unavailable. | Retry with default routing or fall back to queued processing. |
500 | Recognition 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.
{
"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
}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):
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) | Payload | Purpose |
|---|---|---|
live_audio_chunk | raw 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 string | Optional: select a recognition profile. Confirmed with asr_model_selected. |
live_set_streaming_params | tuning object | Optional 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 thepartial_transcriptionthat 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_reasonsays why words committed:local_agreement(two consecutive decodes agreed — the normal path) orforced_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 atranscriptionwithis_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
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
textwithconfidence: 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-auis the customer-facing default recognition profile.- Lexicon data can contain customer information; keep exports out of source control and support tickets unless approved.