A live socket outlives a lot of things that want to kill it: a phone changing networks, a laptop lid, a rolling deploy, a five-minute silence while somebody reads. This page is everything about a session’s life apart from the audio inside it.

Credentials on a socket

The gateway reads four credential channels. Send one. When more than one is present, this is the order that decides which is used: An api_key beats a header, and a header beats token — which is why a ticket sent alongside either of them is never redeemed. If you can set a header, set the header and stop reading this section.

Tickets, for clients that cannot set headers

A browser cannot put a header on a WebSocket handshake, so the credential has to ride the URL — and URLs are written to access logs. The fix is not to avoid the URL; it is to put something in it that is worthless once used.
Mint it on your server, where the key never leaves your infrastructure, and hand the browser only the ticket:
The ticket goes in token. A ticket under any other name — ticket is the tempting one — is not a credential channel, and the handshake is refused.What you get is an HTTP 403. Not a 401, and not a close frame: the gateway decides before it accepts the socket, and a refusal at that point cannot carry a close code or a reason, so the text it computed — Missing authentication — never reaches you. A browser reports a failed connection and a close with code 1006. Two native clients shipped that spelling and every socket they opened failed, in every build, with nothing in the client to say why.
Tickets are single-use and expire in 30 seconds. Redemption is one atomic read-and-delete, so of two connections presenting the same ticket exactly one is admitted and a replay out of a log finds nothing. Mint one per connection, immediately before opening the socket, and mint a fresh one for every reconnect. A ticket is only redeemed when it is the sole credential presented; a header alongside it wins.

How a session is refused or ended

A connect-time refusal and a mid-call close do not look alike, and the reason a reader arrives here — a 403 nothing explains — is not a close code at all.

Refused at the handshake

Everything the gateway checks before accept() is answered by closing the socket while it is still an HTTP request, which uvicorn turns into a 403 handshake rejection. There is no close code and no reason text, whatever the gateway computed internally. A browser surfaces it as a failed connection and a close with code 1006; websockets raises InvalidStatus: server rejected WebSocket connection: HTTP 403. Because a 403 carries nothing, check these against the HTTP API before you go looking in the socket: the same key on GET /v1/audio/models tells you whether the credential works and whether the model exists, and answers a proper JSON error when it does not.

Closed after it opened

Once the socket is accepted, every ending is a close code.
1008 and 1011 reach a client identically and mean opposite things. 1008 is a limit, and “try again in a moment” is sometimes honest. 1011 is a failure, and telling a user the service is busy sends them into a retry loop that cannot succeed while hiding an outage from you. Render them differently.

A reconnect policy that works

The policy every EESI client shares, and a reasonable default for yours:
  • Do not redial a session that never established — that first failure is yours to report, not to paper over.
  • Do not redial after 1000, 1002, 1003 or 1008.
  • Otherwise redial with equal-jitter exponential backoff: a window doubling from 1 second to a maximum of 8, wait half the window plus a random half. The fixed half keeps a returning deploy from being hammered; the random half stops every dropped session from coming back in lockstep.
  • Give up after six attempts.

Keep a quiet session open

A session ends by itself at three hours of wall clock, or after five minutes without a frame from you. The idle timer watches your frames only. Upstream traffic does not renew it, because a backend happily talking into a silent socket is exactly the case being reclaimed. A session streaming microphone audio renews itself continuously and never reaches the timeout. A text-only session does not: nothing goes up the socket while the person reads, and five minutes later the slot is reclaimed from someone who was still using it. Send a beat instead:
The gateway consumes it. Nothing reaches the model, nothing enters the transcript, the recording or the checkpoint, and there is no reply. Send it well inside the window — the console uses 60 seconds — so a few lost beats do not end the call, and stop sending it when nobody is there, because an unattended session is what the reclaim is for. Check the greeting advertises it before you send the first one:
A gateway that omits keepalive relays the event to the model instead of consuming it, and the model answers with an unknown_or_invalid_event error.

Resume a lost session

Every session’s greeting carries a gateway-private eesi.session event with a resumable identity. Keep both values.
When the socket drops, redial with them:
A browser still needs a fresh ticket on the same URL — mint one per redial. The gateway checks the token, the organization, the original user and the original API key before it opens anything. It then dials a fresh pipeline and replays the conversation into it — the merged session configuration first, then one conversation item per completed turn — before your first frame is relayed. At most the turn in flight is lost. A resumed session repeats the same session_id and token, so it can be resumed again, and adds "resumed": { "replayed_items": N }.
Only /v1/realtime reads these parameters. /v1/realtime/translations declares neither resume nor resume_token, so a redial carrying them opens a new session instead of recovering one. It sends the same greeting, resume.supported: true included, and no field on the new session says the request was dropped. Read resumed — its absence is the only signal you get, on any endpoint. See Live translation.
A resume that misses is not a refusal. An expired checkpoint, a token that does not match, or a session belonging to someone else all get you a fresh session with a new handle in the greeting, because clients treat a 1008 as final and would abandon a conversation the person is still trying to have. Read resumed to know which one you got.

What is kept, and what is not

Checkpoints live for 15 minutes after the last turn. A keepalive extends that while somebody is there. A session closed deliberately (1000 from your client) is not resumable — only a lost one is. Kept: the merged session.update configuration, and a tail of complete transcript entries — at most 200 entries and 256 KiB of text, oldest whole entries dropping first. Retained entries are never sliced. Not kept: earlier context outside that window, the turn in flight, audio, images, and tool outputs. If one entry exceeds 64 KiB, or the configuration exceeds 64 KiB, resumption is disabled rather than silently altering the words a future model would see. The gateway clears the checkpoint, sends eesi.session with resume.supported: false, and emits a recoverable error:
The live call continues. A later connection has to start fresh.

The frame that says why a call ended

The balance and the free-tier day are re-checked once a minute while a session is up, not only at connect, so a 1008 close can arrive minutes into an established conversation. Immediately before it does, the gateway sends:
resume is withdrawn because a resume would be refused for the same reason — drop the handle instead of redialling into it. limit.code is free_tier_daily_limit or insufficient_quota. The same text becomes the close frame’s reason, where it is cut to 120 characters — so read it from the frame, not from the close. Show that message. It is the difference between a user who tops up and a user who thinks your product is broken. A re-check that cannot decide — the ledger or the ceiling store unreachable — refuses a new session but never cuts a live one.

Surviving a deploy

A rolling deploy is the most common way a healthy session dies. The orchestrator drains a worker first: it polls the worker’s live-session count and waits for zero before sending SIGTERM. A session still up when the signal lands is force-closed with 1012, which is exactly what resume is for — redial with the handle and the conversation continues on a new pipeline. Sessions longer than an hour need care in a cluster that caps connection draining at an hour. Resume covers the gap; a client that does not implement it will drop those calls.

Next

Authentication

Keys, what they cannot do, and the console surfaces that manage them.

Errors

Every HTTP and socket failure in one table.

Build a voice agent in a browser

Ticket minting and the reconnect path in working code.

Pricing and limits

What the ceilings above cost you when you reach them.