Two kinds of state
Every session response exposes two fields:status- the session’s own state. It isACTIVEfrom creation until the session is fully cleaned up, when it becomesENDED.workerStatus- the state of the avatar session provisioned for it.
ENDED and FAILED are terminal. Once workerStatus reaches either, the
ingestion socket is gone for good - create a new session instead of retrying.Sequence of a healthy session
Step by step
1
Create the session
POST /api/sessions with avatarId, a room object, and an optional
inputProvider. A healthy create returns 201 and usually includes
ingestion.ready: true with url and wsToken immediately - session
creation is connect-first.2
Wait for ingestion readiness
If
ingestion.ready is false, poll GET /api/sessions/:id with bounded
backoff until ingestion.ready is true. A practical budget is about 240
seconds total for cold starts, starting near 250 ms and capping the retry
interval around 2 seconds. Abort early if workerStatus becomes FAILED
or ENDED.3
Open the ingestion WebSocket
Connect to
ingestion.url with the subprotocol facemode.<wsToken>. The
token is a one-time, 15-minute JWT: a consumed token is never accepted
again, so each socket needs its own token. Clients that cannot set
subprotocols may pass the same token as a ?token= query parameter. If the
response includes ingestion.headers, forward them on the upgrade request.Budget up to about 240 seconds for the handshake so a cold-starting session has time
to accept the connection, and disable permessage-deflate on the socket.
The official plugins use a 240-second open budget.4
Negotiate with start
The first application message on the socket must be The session answers
start. It pins the
audio format for the life of the session.started:5
Stream utterances
Send audio as binary PCM frames, or as
audio JSON messages when you
negotiated base64_json. See Utterance flow below for
inferred vs explicit mode.6
Keep the connection healthy
After
started, send an application ping roughly every 15 seconds and
expect pong back. This is a transport heartbeat only - it does not count
as session activity. A POST /api/sessions/:id/heartbeat call is also
available to refresh the session’s activity timestamp from the control
plane.7
End the session
Send
end_session, wait for ended, then close the socket and call
POST /api/sessions/:id/end. See Ending and cleanup
for why the ended message alone is not enough.Utterance flow
The protocol supports two utterance modes. Pick one per session - sending any explicit utterance control permanently latches the session into explicit mode.Inferred mode (recommended for simple pipelines)
Inferred mode (recommended for simple pipelines)
Send
start, then stream audio. FaceMode watches for a silence gap and
closes the utterance itself, emitting audio_ready when speech begins to
render and utterance_ended after the gap. Tune the gap with
utterance_silence_ms in start: 0 disables inference, 100 to 5000
sets the silence window, and the default is 800 ms. Never send
start_utterance, end_utterance, or cancel_utterance in this mode.Explicit mode
Explicit mode
Bracket each utterance yourself:
start_utterance { seq }, audio frames,
then end_utterance { seq }. FaceMode replies
utterance_ended { seq, cycles } when the utterance has drained - seq
matches your end_utterance and cycles counts the inference cycles
consumed. Use cancel_utterance { seq } to abort an in-flight utterance, for
example on barge-in. Keep seq monotonically increasing across the session,
including across reconnects.Message reference
Client to server:
Server to client:
Error codes
Errors carry
fatal: true when the socket is about to close; treat fatal
errors as end-of-session.
Reconnecting a dropped transport
If the WebSocket drops mid-session, do not create a new session - callPOST /api/sessions/:id/reconnect instead:
- The response returns a fresh
ingestionblock with a new one-timewsToken. Reuse the same reconnect budget you used for the first connect. roomis optional and only needed if you are rotating room credentials. Itstypemust match the session’s original room type, andinputProvider(if sent) must match the value used at creation.- Open a new socket, resend the identical
startmessage, and wait forstartedbefore resuming audio. Keepseqnumbers monotonic across the reconnect. - Bound your attempts (two or three retries with short backoff is typical) and end the session if reconnects are exhausted.
A dropped socket does not end the session. FaceMode keeps the session warm
briefly so a reconnect can reattach cleanly - but audio sent during the gap
is lost, so pause upstream until
started arrives on the new socket.Ending and cleanup
Ending has two layers, and conflating them causes leaked sessions:- Protocol layer -
end_sessionon the socket is answered byended. This is only an acknowledgement that FaceMode received your request. It is not proof that backend cleanup finished. - Control layer -
POST /api/sessions/:id/endfinalizes the session. It returns200withpending: falsewhen cleanup completed inline, or202withpending: truewhile session cleanup is still in flight. A400withSESSION_ALREADY_ENDEDmeans a previous request already finalized it - treat that as success, not failure.
202, poll GET /api/sessions/:id every half-second or so until
status is ENDED (or workerStatus reaches ENDED/FAILED). A practical
budget is about 45 seconds.
FaceMode can also end the session on its own: it sends session_ending with
a reason, then closes the socket with code 1000. Sessions with no audio
activity for an extended idle period are ended automatically, so explicit
cleanup on your side is always the safer path. The reason is available as
workerAssignment.terminationReason on status reads.
A session in ENDING no longer blocks a new POST /api/sessions - the
concurrency slot is freed when termination is requested.
Related pages
- How FaceMode works - the architecture this lifecycle drives.
- Input providers - choosing
audio_encoding,sample_rate, andchannelsforstart. - Rooms and egress - the
roomobject used at create and reconnect.