Docs API reference

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

TokenFormatAccepted by
Licensetenms1.<b64url claims>.<b64url sig>, lid = lic_…Edge (both streams), control: ingest, reads (own customer), whoami, session-tokens
Session tokensame format, lid = ses_…, parent = lic_…Edge (both streams), control: ingest, reads, whoami (cannot mint)
Dashboard session__Host-tenms_session cookie (HttpOnly) from POST /v1/logincontrol: 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

DirectionFirst bytePayload
client → server0x01 (TAG_MIC)caller audio, PCM16 little-endian mono at the negotiated rate, any length (even number of bytes)
client → server0x02 (TAG_REFERENCE)audio the agent is playing (echo reference), same format
server → client0x01processed 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

typeFieldsNotes
startcall_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_speakingspeaking: boolEnables interruption classification; each false → true starts a new agent utterance
agent_texttextThe agent’s TTS text; its own words (echo transcribed) never confirm a barge-in. The ElevenLabs bridge sends this from agent_response automatically
transcripttext, 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_profileWhatAdded latency (measured, end to end)CPUWhen to useAvailability on Edge
stt (default)DSP: AEC + residual-echo suppression, speaker gate, -10 dB noise floor5 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 recogniseralways
listeningDSP: the same with full (-20 dB) noise suppressionsame as sttsame as sttaudio people listen to, cheapestalways
listening_minilistening + 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 inputevery image (model at /opt/tenms/models, sha256-verified at startup)
listening_proAEC, 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 inputEdge 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

typeFields
readysession_id, call_id, modules, sample_rate, frame_ms (10), region, focus_profile (only when Focus is enabled)
speech_startat_ms
turn_predictionat_ms, probability
turn_endat_ms, probability, silence_ms
interruptionat_ms, kind: barge_in | backchannel | noise | other_speaker
qualityat_ms, snr_db, mos, asr_risk (each 1 s)
summarysummary: CallSummary
errorcode, message

at_ms is stream time at the end of the frame that raised the event.

Error codes (error events)

CodeWhenSession
start_timeoutno start within 10 sclosed 1008
expected_startfirst message not a JSON startclosed 1008
bad_messageinvalid JSON (in start: closes; later: continues)1008 at start, else continues
unsupported_sample_raterate not 8/16/24/48 kHzclosed 1008 (bad_start)
no_modulesnone of the requested modules are licensedclosed 1008 (bad_start)
invalid_call_idcall_id outside the charsetclosed 1008 (bad_start)
profile_unavailablefocus_profile listening_mini / listening_pro but this server has not loaded its model (fatal: use another profile or server)closed 1008 (bad_start)
profile_capacityfocus_profile listening_pro and the replica already runs the server limit of them (retryable, or use listening_mini)closed 1008 (bad_start)
engine_errorengine construction failedclosed 1008
already_startedsecond startcontinues
bad_audioempty binary, odd byte count, unknown tagcontinues
message_too_largetext message > 16 KiBcontinues
idle_timeoutno audio for 30 ssummary, closed 1008
license_revokedlicense (or session token’s parent) revoked mid-sessionsummary, closed 1008
license_expireda license’s exp passed mid-session (not session tokens)summary, closed 1008
max_session_duration4 h reachedsummary, closed 1008
server_shutdownEdge draining past the server limitsummary, closed 1001

Close codes

CodeReason strings
1000end (normal, after summary)
1001server_shutdown
1008start_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": "..."}.

StatusCodeCause
400not_websocketnot a WebSocket upgrade
401missing_licenseno Bearer header and no ?token=
401invalid_licensemalformed or bad signature
401license_expiredexp passed
403license_revokedrevocation_id() on the revocation list
429rate_limitedper-license connect rate (10/s, burst 20)
429license_session_limitper-license concurrent sessions on this replica (max_sessions claim or 100)
503drainingshutting down
503not_readyrevocation list not loaded yet
503at_capacityreplica 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"}}
StatuscodeExtras
400bad_request
401unauthorizedWWW-Authenticate: Bearer; messages: missing bearer token, invalid token, license expired, license revoked
403forbiddenanother customer’s data, or a session token minting a session token
404not_found
408—request timeout (30 s)
409conflictaccount already approved / not pending; active-key cap reached
413payload_too_large> 1000 summaries in a batch; auth bodies > 16 KiB
422unprocessableerror.details = {rejected, results}
429rate_limitedRetry-After; signup / login rate limits
503unavailableRetry-After (1 s queue full / pool busy / lock timeout; 5 s statement timeout)
500internal

Note the shape differs from Edge ({"error": "<code>"}).

Endpoints

Method & pathAuthRequestSuccess
GET / , /index.html, /login, /signupnone—dashboard HTML (strict CSP; the path picks the screen)
GET /healthznone—200 {"status":"ok"}
GET /readyznone—200 {"status":"ready"}; 503 {"status":"db_error"|"db_timeout"}
GET /v1/public-keynone—{"public_key": "<b64url>"}
GET /v1/revocationsnone—{"revoked": ["lic_…", …]} (revoked and not yet expired, sorted)
GET /v1/whoamilicense, 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-tokenslicenseempty, or {"ttl_secs"?}; unknown fields → 400{"token","expires_at","session_id","parent","customer_id"}
POST /v1/calls:batchlicense, session tokenCallBatch, ≤ 1000 summaries, ≤ 16 MiB202 ingest result
GET /v1/callslicense (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/statslicense, session (own customer)hours (1..min(retention×24, 9600), default 24), include_test (false)StatsResponse
GET /v1/usagelicense, session (own customer)from, to (YYYY-MM-DD UTC, inclusive; default last 30 days; ≤ 400 days)UsageResponse
POST /v1/signupnone (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/loginnone (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/logoutsession + CSRF—204, cookies cleared
GET /v1/accountsession—{role: "account", account_id, email, company, status, customer_id, customer_name, created_at, plan, signup_mode, csrf_token}
GET /v1/keyssession (approved)—{"keys": [KeyView], "plan": Plan}: every license of the customer, newest first, never tokens
POST /v1/keyssession (approved) + CSRFKeyRequest or empty201 {"token", "key": KeyView} (the only response with the token); 400 beyond the plan; 403 no plan; 409 > 25 active keys
POST /v1/keys/{id}/revokesession (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 with customer_id, and sets its plan, default trial) or rejects; open approves at once with the trial plan; closed refuses (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 202 with the same body whether or not the email exists; login is 401 invalid email or password for unknown emails, wrong passwords and rejected accounts. A pending account with the right password gets a session that can only read /v1/account (everything else 403 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), both Secure; SameSite=Lax; Path=/, Max-Age = session lifetime.
  • Every cookie-authenticated POST (keys, logout) needs X-CSRF-Token equal to the CSRF cookie and to the session’s token (also returned by /v1/login and /v1/account), and an Origin (or Referer) 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}
trialpartner (admin-set limits, defaults)
modulesfocus, turn, pulselimits.modules (all three)
max_sessions per key10limits.max_sessions (Edge replica default)
key lifetime≤ 30 days≤ limits.ttl_days (365)
ElevenLabs bridgeofflimits.elevenlabs_agents (off)
listening_proofflimits.listening_pro (on)
monthly minutes1000, display onlynone

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, except out_of_range (the day has no partition: maintenance failing).
  • 422 when nothing in the batch was accepted or duplicate; 503 + Retry-After when the queue is full (nothing queued: resend the whole batch).
  • Validation (validate.rs): call_id 1-128 bytes; started_at_ms not 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 as other.

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.