Skip to main content
The ingestion WebSocket is how audio reaches a session. You get its coordinates from POST /api/sessions or POST /api/sessions/{id}/reconnect - the ingestion object carries url, wsToken, and ready.

Connecting

  • URL: ingestion.url, in the form wss://<worker-host>/ws/{session_id}.
  • Auth: offer the token as a WebSocket subprotocol - Sec-WebSocket-Protocol: facemode.<wsToken>. The server echoes that subprotocol in the handshake.
  • Query fallback: clients that cannot set subprotocols may pass ?token=<wsToken> on the URL instead.
  • Compression: do not negotiate permessage-deflate; it is disabled.
Token rules:
  • wsToken is a signed credential with a 15 minute expiry and is single-use - the first successful connection consumes it.
  • Only one WebSocket may be active per session; a second concurrent connect is rejected.
  • After it expires or is consumed, call reconnect for a fresh one.

Pre-upgrade failures

If the handshake is rejected, the socket is closed before any application traffic. The rejection carries an application close code - your WebSocket client surfaces it as a failed upgrade or an immediate close, depending on the library:

Handshake

The first application message must be start; the server answers started. Until started arrives, do not send audio, utterance control, or pings - anything else first fails with NOT_NEGOTIATED.
server_sample_rate/server_channels describe FaceMode’s internal format - informational only; keep sending audio in the format you negotiated.

Client -> server messages

All control messages are JSON text frames. PCM audio may also travel as raw binary frames.

start field details

  • audio_encoding: pcm_s16le, pcm_f32le, or base64_json.
  • sample_rate: 8000-48000 Hz. Preferred values: 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000. Other in-range rates work but are resampled.
  • channels: 1 or 2.
  • metadata: free-form object. For base64_json, metadata.inner_encoding: "pcm_f32le" opts into float PCM inside the base64 payload (default is 16-bit PCM).
  • utterance_silence_ms: 0 disables silence-based utterance detection; 100-5000 tunes the gap. See below.

Server -> client messages

Sequence numbers

  • seq is a non-negative integer maintained by the client.
  • Sequences must not move backwards: every sequenced message (audio, start_utterance, end_utterance, cancel_utterance, end_session) must have seq >= the last one sent. Violations fail with INVALID_FORMAT.
  • Binary PCM frames carry no seq.
  • utterance_ended.seq echoes the end_utterance seq it answers - use it to match requests to drains. In inferred mode (below), the seq is assigned by the server.

Utterance boundaries: inferred vs explicit

FaceMode needs to know where one utterance ends so it can flush speech to avatar video. Two modes:
  • Inferred (default): the first audio frame opens an utterance; a silence gap of utterance_silence_ms (server default ~800 ms) closes it and produces utterance_ended. Best for simple pipelines - just stream audio.
  • Explicit: drive start_utterance/end_utterance/cancel_utterance yourself for tight control (for example, aligning with your own TTS events).
Sending any explicit utterance message latches the session into explicit mode permanently - do not mix the two styles on one connection. Sessions created with inputProvider deepgram or gemini start in provider-driven (explicit) mode.
Set utterance_silence_ms in start to tune inferred mode: 0 turns inference off (you must then use explicit control), 100-5000 sets the silence gap in milliseconds.

Heartbeats and timeouts

  • After started, send ping periodically (for example every 15 s) and expect pong. One ping in flight at a time is enough.
  • The server closes idle sockets: prolonged silence with no frames ends the connection (close code 1000, reason Inactivity timeout).
  • The REST-level POST /api/sessions/{id}/heartbeat is separate bookkeeping - use both.

Errors and close semantics

error frames carry {code, message, fatal}: Fatal errors are followed by a server-initiated close. Close codes used after upgrade (for handshake-time rejections see Pre-upgrade failures above - note 4000/4001/4004 are reused with different meanings): ended acknowledges end_session at the protocol level only - it does not prove session cleanup finished. To confirm the session is fully torn down, poll GET /api/sessions/{id} until session.status is ENDED, or call POST /api/sessions/{id}/end.

Reconnecting

When the socket dies mid-session:
  1. Call POST /api/sessions/{id}/reconnect for a fresh ingestion.url + wsToken (old tokens cannot be reused).
  2. Open the new WebSocket with the facemode.<wsToken> subprotocol.
  3. Re-send start - the new connection performs a fresh handshake. If audio was already flowing, audio_ready follows started again.
  4. Resume your seq counter or restart at 0 - sequence state is per connection.