API
API reference
Edge WebSocket protocol, Edge HTTP refusals, the customer control-plane API and the CallSummary type.
Derived from the code: Edge crates/tenms-edge/src/{server,session,elevenlabs,limits}.rs,
protocol crates/tenms-proto/src/protocol.rs, control plane
crates/tenms-control/src/{api,licenses,auth,reads,customers,error,validate,accounts,session,keys,plans,usage}.rs.
The ElevenLabs bridge protocol is documented in full in ElevenLabs bridge;
only its HTTP surface is repeated here.
Authentication tokens
| Token | Format | Accepted by |
|---|---|---|
| License | tenms1.<b64url claims>.<b64url sig>, lid = lic_… | Edge (both streams), control: ingest, reads (own customer), whoami, session-tokens |
| Session token | same format, lid = ses_…, parent = lic_… | Edge (both streams), control: ingest, reads, whoami (cannot mint) |
| Dashboard session | __Host-tenms_session cookie (HttpOnly) from POST /v1/login | control: reads (own customer, once approved), usage, keys, account, whoami; not ingest or session tokens |
Bearer header: Authorization: Bearer <token>. Edge streams also accept ?token=<token>
(browsers cannot set WebSocket headers). A request that carries a bearer token is
authenticated by the token alone; cookies are only consulted without one. Self-serve API
keys (POST /v1/keys) are ordinary license tokens.
Edge WebSocket protocol
GET /v1/stream upgraded to WebSocket. Binary messages carry audio; text messages carry
JSON control and events.
Binary messages
| Direction | First byte | Payload |
|---|---|---|
| client → server | 0x01 (TAG_MIC) | caller audio, PCM16 little-endian mono at the negotiated rate, any length (even number of bytes) |
| client → server | 0x02 (TAG_REFERENCE) | audio the agent is playing (echo reference), same format |
| server → client | 0x01 | processed caller audio (Focus output), one message per 10 ms frame; only if Focus is enabled and return_audio is not false |
Limits: binary messages ≤ 256 KiB (max_message_bytes), larger closes the connection;
the reference buffer holds at most 1000 ms ahead of the mic (newest samples dropped).
Client → server JSON
type | Fields | Notes |
|---|---|---|
start | call_id? ([A-Za-z0-9._:-]{1,128}, else a UUID), sample_rate (8000, 16000 (default), 24000, 48000), modules? (["focus","turn","pulse"] subset; default all), return_audio? (default true when Focus is on), focus_profile? ("stt" (default), "listening", "listening_mini", "listening_pro"; see Focus profiles) | Must be the first message, within 10 s. Modules are intersected with the license. focus_profile tunes Focus: stt = echo removal + speaker gate + gentle (-10 dB) noise suppression, the lowest measured STT WER; listening = full suppression for human listeners; the two opt-in listening tiers are never for STT input. An unknown value is bad_message; a tier this server has not loaded is profile_unavailable; a full listening_pro replica is profile_capacity. |
agent_speaking | speaking: bool | Enables interruption classification; each false → true starts a new agent utterance |
agent_text | text | The agent’s TTS text; its own words (echo transcribed) never confirm a barge-in. The ElevenLabs bridge sends this from agent_response automatically |
transcript | text, confidence?, is_final (default false) | Pulse uses finals; while the agent speaks, interim and finals confirm or downgrade interruptions |
end | — | Server sends summary, then closes 1000 |
Text messages ≤ 16 KiB (message_too_large error otherwise; the session continues).
Focus profiles
focus_profile | What | Added latency (measured, end to end) | CPU | When to use | Availability on Edge |
|---|---|---|---|---|---|
stt (default) | DSP: AEC + residual-echo suppression, speaker gate, -10 dB noise floor | 5 ms at 16 kHz, 7 ms at 24/48 kHz, 8 ms at 8 kHz | ~0.01-0.05 ms per 10 ms frame (Edge ~1 ms CPU per call-second without a reference, ~5-8 with one) | anything that goes to a speech recogniser | always |
listening | DSP: the same with full (-20 dB) noise suppression | same as stt | same as stt | audio people listen to, cheapest | always |
listening_mini | listening + the Tenms model’s Focus head (tenms-mini-focus-v1.tnw, native TNW1 runtime) | same as listening | +~0.035 ms per frame (Edge ~4.3 vs ~1.0 ms CPU per call-second for listening) | audio people listen to with music / babble / noise: 2x the DSP background removal at the DSP latency. Never STT input | every image (model at /opt/tenms/models, sha256-verified at startup) |
listening_pro | AEC, then the FastEnhancer-M neural enhancer (ONNX Runtime) | 27 ms at 16 kHz, 29 ms at 48 kHz, 30 ms at 8 kHz | ~0.8 ms per frame (Edge ~112 ms CPU per call-second, ~15x a DSP session) | best listening quality within 30 ms (recordings, listen-in). Never STT input | Edge replicas with the enhancer loaded; a per-replica cap on concurrent sessions |
ready.focus_profile echoes the profile in use (only when Focus is enabled); the call summary’s
processing.algorithmic_delay_ms is the measured end-to-end delay of that profile. The ElevenLabs
bridge accepts stt and listening only. Details and measurements:
Integration guide.
Server → client JSON
type | Fields |
|---|---|
ready | session_id, call_id, modules, sample_rate, frame_ms (10), region, focus_profile (only when Focus is enabled) |
speech_start | at_ms |
turn_prediction | at_ms, probability |
turn_end | at_ms, probability, silence_ms |
interruption | at_ms, kind: barge_in | backchannel | noise | other_speaker |
quality | at_ms, snr_db, mos, asr_risk (each 1 s) |
summary | summary: CallSummary |
error | code, message |
at_ms is stream time at the end of the frame that raised the event.
Error codes (error events)
| Code | When | Session |
|---|---|---|
start_timeout | no start within 10 s | closed 1008 |
expected_start | first message not a JSON start | closed 1008 |
bad_message | invalid JSON (in start: closes; later: continues) | 1008 at start, else continues |
unsupported_sample_rate | rate not 8/16/24/48 kHz | closed 1008 (bad_start) |
no_modules | none of the requested modules are licensed | closed 1008 (bad_start) |
invalid_call_id | call_id outside the charset | closed 1008 (bad_start) |
profile_unavailable | focus_profile listening_mini / listening_pro but this server has not loaded its model (fatal: use another profile or server) | closed 1008 (bad_start) |
profile_capacity | focus_profile listening_pro and the replica already runs the server limit of them (retryable, or use listening_mini) | closed 1008 (bad_start) |
engine_error | engine construction failed | closed 1008 |
already_started | second start | continues |
bad_audio | empty binary, odd byte count, unknown tag | continues |
message_too_large | text message > 16 KiB | continues |
idle_timeout | no audio for 30 s | summary, closed 1008 |
license_revoked | license (or session token’s parent) revoked mid-session | summary, closed 1008 |
license_expired | a license’s exp passed mid-session (not session tokens) | summary, closed 1008 |
max_session_duration | 4 h reached | summary, closed 1008 |
server_shutdown | Edge draining past the server limit | summary, closed 1001 |
Close codes
| Code | Reason strings |
|---|---|
| 1000 | end (normal, after summary) |
| 1001 | server_shutdown |
| 1008 | start_timeout, expected_start, bad_start, engine_error, idle_timeout, license_revoked, license_expired, max_session_duration |
A client that does not read for 10 s is disconnected without a close handshake.
Pacing
Caller audio may arrive at up to 1.5 × real time after an
initial 5 s allowance. Beyond that Edge stops reading the socket
until the client is back within budget (no error). The summary’s duration_ms is the
processed audio, clamped to wall_time × factor + burst.
Edge HTTP endpoints
Upgrade refusals (both streams)
Body {"error": "<code>", "message": "..."}.
| Status | Code | Cause |
|---|---|---|
| 400 | not_websocket | not a WebSocket upgrade |
| 401 | missing_license | no Bearer header and no ?token= |
| 401 | invalid_license | malformed or bad signature |
| 401 | license_expired | exp passed |
| 403 | license_revoked | revocation_id() on the revocation list |
| 429 | rate_limited | per-license connect rate (10/s, burst 20) |
| 429 | license_session_limit | per-license concurrent sessions on this replica (max_sessions claim or 100) |
| 503 | draining | shutting down |
| 503 | not_ready | revocation list not loaded yet |
| 503 | at_capacity | replica at the server limit |
/v1/elevenlabs/stream takes ?focus_profile=stt|listening (default stt: the caller
audio goes to ElevenLabs’ recogniser; an unknown value, listening_mini or listening_pro is 400 bad_request) and adds: 400 missing_agent_id / invalid_agent_id; 403
agent_not_allowed (agent not in elevenlabs_agents) / no_modules; 503
elevenlabs_not_configured (no ELEVENLABS_API_KEY). In-call tenms_error codes are in
ElevenLabs bridge.
Control-plane HTTP API
Base: https://app.tenms.cloud. Edge: wss://edge.tenms.cloud. JSON in and out; request bodies may
be gzip-compressed; responses are compressed when the client accepts it. Every request
times out after 30 s (408).
Errors
{"error": {"code": "unauthorized", "message": "license revoked"}}
| Status | code | Extras |
|---|---|---|
| 400 | bad_request | |
| 401 | unauthorized | WWW-Authenticate: Bearer; messages: missing bearer token, invalid token, license expired, license revoked |
| 403 | forbidden | another customer’s data, or a session token minting a session token |
| 404 | not_found | |
| 408 | — | request timeout (30 s) |
| 409 | conflict | account already approved / not pending; active-key cap reached |
| 413 | payload_too_large | > 1000 summaries in a batch; auth bodies > 16 KiB |
| 422 | unprocessable | error.details = {rejected, results} |
| 429 | rate_limited | Retry-After; signup / login rate limits |
| 503 | unavailable | Retry-After (1 s queue full / pool busy / lock timeout; 5 s statement timeout) |
| 500 | internal |
Note the shape differs from Edge ({"error": "<code>"}).
Endpoints
| Method & path | Auth | Request | Success |
|---|---|---|---|
GET / , /index.html, /login, /signup | none | — | dashboard HTML (strict CSP; the path picks the screen) |
GET /healthz | none | — | 200 {"status":"ok"} |
GET /readyz | none | — | 200 {"status":"ready"}; 503 {"status":"db_error"|"db_timeout"} |
GET /v1/public-key | none | — | {"public_key": "<b64url>"} |
GET /v1/revocations | none | — | {"revoked": ["lic_…", …]} (revoked and not yet expired, sorted) |
GET /v1/whoami | license, session token | — | {role, customer_id, license_id, modules, expires_at, parent, elevenlabs_agents, max_sessions}; session: {role: "account", account_id, email, status, customer_id} |
POST /v1/session-tokens | license | empty, or {"ttl_secs"?}; unknown fields → 400 | {"token","expires_at","session_id","parent","customer_id"} |
POST /v1/calls:batch | license, session token | CallBatch, ≤ 1000 summaries, ≤ 16 MiB | 202 ingest result |
GET /v1/calls | license (own customer) | query below | {calls: [CallItem], next_before, next_before_id, since} |
GET /v1/calls/{call_id} | license (own customer) | — | {call_id, customer_id, started_at_ms, ingested_at_ms, summary} |
GET /v1/stats | license, session (own customer) | hours (1..min(retention×24, 9600), default 24), include_test (false) | StatsResponse |
GET /v1/usage | license, session (own customer) | from, to (YYYY-MM-DD UTC, inclusive; default last 30 days; ≤ 400 days) | UsageResponse |
POST /v1/signup | none (same-origin Origin) | {email, password, company, use_case} | 202 {"status": "received", "signup_mode", "message"}, identical for new and existing emails; 403 when signups are closed |
POST /v1/login | none (same-origin Origin) | {email, password} | 200 account (as GET /v1/account) + Set-Cookie session and CSRF cookies; 401 invalid email or password otherwise |
POST /v1/logout | session + CSRF | — | 204, cookies cleared |
GET /v1/account | session | — | {role: "account", account_id, email, company, status, customer_id, customer_name, created_at, plan, signup_mode, csrf_token} |
GET /v1/keys | session (approved) | — | {"keys": [KeyView], "plan": Plan}: every license of the customer, newest first, never tokens |
POST /v1/keys | session (approved) + CSRF | KeyRequest or empty | 201 {"token", "key": KeyView} (the only response with the token); 400 beyond the plan; 403 no plan; 409 > 25 active keys |
POST /v1/keys/{id}/revoke | session (approved) + CSRF | — | {"key": KeyView} (idempotent; 404 for another customer’s key); listed in /v1/revocations |
Session-token errors: 403 if the caller is itself a session token; 401 if the license is revoked or expired; 400 for ttl_secs: 0 or unknown fields. ttl_secs is capped at the server’s session-token TTL (300 s).
GET /v1/calls query: since (unix ms, default now − 7 days, clamped to the retention
floor), before + before_id (keyset cursor from the previous page), cause
(none|audio|transcription|llm|failed = not none), region (unknown matches
null), source, limit (1-500, default 50). Values not on the
region/source allowlists return an empty page.
Accounts, sessions and CSRF
- Signup:
approval(default) creates a pending account that an admin approves (POST /v1/signups/{id}/approve: creates the customer, or links an existing one withcustomer_id, and sets its plan, defaulttrial) or rejects;openapproves at once with thetrialplan;closedrefuses (403). No email is sent: tell approved partners yourself. - Validation: email (ASCII, ≤ 254, stored lower-case), password 10-256 characters, company 1-200, use_case 1-2000; unknown fields → 400; bodies > 16 KiB → 413.
- Enumeration-safe answers: signup is
202with the same body whether or not the email exists; login is401 invalid email or passwordfor unknown emails, wrong passwords and rejected accounts. A pending account with the right password gets a session that can only read/v1/account(everything else403 account is pending approval). - Rate limits (per replica, fixed windows): signup 10/hour per client IP and 3/hour per
email; login 30/15 min per client IP and 10/15 min per email →
429+Retry-After. - Cookies:
__Host-tenms_session(HttpOnly) and__Host-tenms_csrf(readable by the page), bothSecure; SameSite=Lax; Path=/,Max-Age= session lifetime. - Every cookie-authenticated
POST(keys, logout) needsX-CSRF-Tokenequal to the CSRF cookie and to the session’s token (also returned by/v1/loginand/v1/account), and anOrigin(orReferer) equal to an allowed origin. Signup and login check the origin too. Bearer callers are not subject to these checks.
Plan
{name: "trial"|"partner", modules, max_sessions, ttl_days, elevenlabs_agents, listening_pro, monthly_minutes}
| trial | partner (admin-set limits, defaults) | |
|---|---|---|
| modules | focus, turn, pulse | limits.modules (all three) |
| max_sessions per key | 10 | limits.max_sessions (Edge replica default) |
| key lifetime | ≤ 30 days | ≤ limits.ttl_days (365) |
| ElevenLabs bridge | off | limits.elevenlabs_agents (off) |
| listening_pro | off | limits.listening_pro (on) |
| monthly minutes | 1000, display only | none |
PlanLimits = {modules?, elevenlabs_agents?, max_sessions?, ttl_days?, listening_pro?}.
listening_pro is recorded and shown but not enforced (license tokens have no profile
claim yet). Customers that predate self-serve have no plan (null) and cannot create
keys.
KeyRequest
{name?, modules?, ttl_days?, elevenlabs_agents?, max_sessions?}; every field defaults to
the plan’s value and may only narrow it (e.g. ttl_days ≤ the plan’s, agents ⊆ the
plan’s). The key is a license: lid = lic_…, sub = the customer, exp ≤ now + ttl_days.
KeyView = {license_id, name, token_hint ("tenms1…" + last 6 characters), modules, elevenlabs_agents, max_sessions, created_at, expires_at, revoked_at, last_used_at, status, self_serve}. last_used_at is the last control-plane request authenticated by the key or
a session token derived from it (Edge verifies offline and does not report use), written
at most every 60 s.
UsageResponse
customer_id null = all customers (admin)
from, to "YYYY-MM-DD" (UTC days, inclusive)
plan "trial" | "partner" | null
totals {calls, duration_ms, minutes}
daily [{day, calls, duration_ms, minutes}] # every day of the window
by_source [{key, calls, duration_ms, minutes, share}] # edge, pipecat, livekit, elevenlabs, sdk, ...
by_focus_profile [{key, ...}] # stt, listening, listening_mini, listening_pro,
# unknown (Focus on, not reported), none (Focus off), other
quota trial only: {plan, period "YYYY-MM", minutes_included, minutes_used, fraction_used, enforced: false}
Served from the usage_daily rollup, which ingest maintains in the same transaction as
the dedup insert, so resends never count twice. Minutes are duration_ms / 60000, rounded
to 0.01. The trial quota is informational: nothing is blocked when it is exceeded.
Ingest result
{
"accepted": 2, "duplicates": 1,
"rejected": [{"index": 3, "call_id": "c4", "reason": "out_of_range"}],
"results": [
{"index": 0, "call_id": "c1", "status": "accepted"},
{"index": 1, "call_id": "c2", "status": "accepted"},
{"index": 2, "call_id": "c1", "status": "duplicate"},
{"index": 3, "call_id": "c4", "status": "rejected", "reason": "out_of_range"}
],
"customer_id": "acme"
}
accepted= queued in memory (at-most-once from here);duplicate= already stored for this customer or repeated in the batch (do not resend);rejected= permanently invalid, exceptout_of_range(the day has no partition: maintenance failing).- 422 when nothing in the batch was accepted or duplicate; 503 +
Retry-Afterwhen the queue is full (nothing queued: resend the whole batch). - Validation (
validate.rs):call_id1-128 bytes;started_at_msnot more than 1 day in the future and inside retention;duration_ms≤ 7 days; ≤ 8 modules; ≤ 8 failure reasons of ≤ 200 chars; finite numbers; compute times ≤ 10 s; serialised summary ≤ 8 KiB. Regions / sources outside the server limit / the server limit are stored asother.
StatsResponse
window {hours, from_ms, to_ms, customer_id, include_test}
totals {calls, failed, failure_rate, duration_ms, mean_duration_ms}
failure_causes [{cause, calls, share}]
quality {calls, mos {mean,p10,p50,p90}, snr_db_mean, asr_risk {mean,p50,p90,high_risk_calls,high_risk_share}}
turns {calls, user_turns, barge_ins, backchannels, ignored_noise, ignored_other_speaker, eot_delay_ms_p50_mean, ...}
processing {compute_us_p99 {mean,p50,p95,p99,max}} # distribution across calls of per-call p99
regions [{region, calls, failed, failure_rate, mean_mos, ...}]
hourly [{hour_ms, calls, failed, audio, transcription, llm, mean_mos, ...}]
Served from the hourly rollups, never from raw calls. A license token’s stats always include its own test traffic.
Data types
CallSummary
crates/tenms-proto/src/summary.rs:
{
"call_id": "c1", "started_at_ms": 1793847800519, "duration_ms": 30120,
"source": "edge", "region": "asia-southeast1-eqsg3a", "modules": ["focus","turn","pulse"],
"quality": {"mos": 4.16, "snr_db": 30.1, "clipping_ratio": 0, "dropout_ratio": 0,
"narrowband_ratio": 0, "overlap_ratio": 0.06, "echo_ratio": 0.29,
"speech_ratio": 0.34, "asr_risk": 0.004},
"turns": {"user_turns": 4, "barge_ins": 1, "backchannels": 1, "ignored_noise": 0,
"ignored_other_speaker": 0, "eot_delay_ms_p50": 360, "eot_delay_ms_p95": 610},
"failure": {"cause": "none", "confidence": 0.6, "reasons": []},
"processing": {"frames": 3012, "compute_us_p50": 20.9, "compute_us_p99": 102.9,
"compute_us_max": 347.6, "algorithmic_delay_ms": 5.0}
}
(Values from python/tests/live_reports/pipecat_daily_latest.json, region illustrative.)
quality, turns, failure and region are optional (null when the module was off).
focus_profile (optional, omitted when unset) is the Focus profile the call ran with:
stt, listening, listening_mini or listening_pro, set by Engine::finish from the
engine configuration (absent when Focus is off or the settings are custom). It is a string
so older control planes and newer profiles stay compatible; ingest accepts
[A-Za-z0-9_.:-]{1,32} and meters unknown names as other.
source values in use: edge, elevenlabs, pipecat, livekit, sdk (allowlist:
the server limit).
CallBatch
{"customer_id": "acme", "calls": [CallSummary, ...]}
customer_id must match the license’s customer.