call.started and the end-of-call report, or stream the full event set over the WebSocket. Both approaches work.
How It Works
Connection lifecycle
1
We connect
We open your WebSocket URL, sending your token in the request header.
2
You send call.started
Your first text frame. The call is live from here, and this is where we learn your
callId.3
Media and events
Binary frames for audio in both directions, plus optional
speech.state and transcript events from you.4
The call ends
You send
call.ended, then close. Or we close, if our caller hung up first.5
The report
Minutes later, you POST the end-of-call report over HTTP.
Steps 1 to 4 happen on one WebSocket. Step 5 is an ordinary HTTP request that can arrive any time after, which is why it carries the
callId rather than relying on a connection that no longer exists.The whole call happens on one WebSocket. Binary frames carry audio, JSON text frames carry events, and one connection is one call — so concurrent simulations are simply concurrent connections, sharing nothing. No call id is negotiated up front; call.started tells us yours.Configuration
Register your agent under Library → Agents in the Netra dashboard. Select Voice as the modality and Websocket as the platform.
In return Netra gives you a webhook URL for end-of-call reports and an API key to authenticate them. Both are static — the same for every call — which is why the report carries
call.id.
Protocol basics
One WebSocket carries two frame types:
Rules for the socket
- Send
call.startedas your first text frame, as soon as the call is live. We hold our caller’s audio until it arrives. - Both directions use the same audio format for the whole call. It is stated in
call.startedand never renegotiated. - Silence is fine — send zero-filled frames rather than stopping. Gaps confuse voice detection on our side.
- Ignore any JSON
typeyou do not recognise. We do the same, so new events never break an older build. - One call produces one
call.ended. If the socket dies without it, we treat the close as the end.
Audio format
Recommended chunk size is about 20 ms — 640 bytes at 16 kHz. We send our caller’s speech as binary frames; you send your agent’s speech the same way.
Event envelope
Every JSON text frame uses this shape:Events
The samples below are taken from a real call against a booking agent.call.started (Required)
Sent once, as soon as the WebSocket is open and the call is live. We hold our caller’s audio until this arrives. This event is required, as it allows us to identify your simulation runs.callId is the one identifier that matters. It repeats on every later event and on the end-of-call report, and it is how we tie that report back to the run that asked for the call. It only has to be unique within your organisation — any opaque string works, as above.call.ended
Sent once, when the call is over: agent hangup, caller hangup, silence timeout, duration limit or error. The last meaningful event on the socket.
A reason we do not recognise falls back to
agent_ended and agent rather than failing the run. That is safe, but it loses the difference between a deliberate ending and a crash — which is the difference between a passing run and one flagged for investigation. Keep the vocabulary small and stable.
speech.state
Optional. Who is speaking right now. We run our own voice detection, so this is a cross-check rather than something we depend on.Transcripts
Real-time speech-to-text for both sides. The eventtype says whether the text is streaming or committed; there is no nested type field.
partial.transcript is a streaming update, which later partials for the same turn may overwrite. final.transcript is the committed text for that utterance.
The two roles mean different things, and the difference matters when we score the call:
role: user— your speech-to-text of our caller’s audio. This is what your agent believed it heard.role: agent— the text your agent is speaking, taken from the LLM or TTS input. Not a second transcription pass over your own audio.
The end-of-call report
When the call is over, POST one report to the webhook URL we gave you. This is the record of the call: what we build traces from, slice audio with, and score against.Retries are safe. We deduplicate on
call.id, so a report delivered twice is accepted once. Send it as soon as the call ends — there is no deadline, but a report arriving days later lands after the run has been reported on.Top-level fields
Set a recommended or optional field to
null when you do not have it, rather than leaving the key out.
The conversation
artifact.messages does the most work in the whole report. It drives turn grouping, span creation, transcripts, audio clip boundaries and cost attribution.
Turn latencies
artifact.performanceMetrics.turnLatencies carries the per-turn breakdown. Entries are in call order and any field you cannot measure is null.
Session costs
Three entries incosts, one per provider type.
Three things people get wrong
Timings are not decoration.secondsFromStart, duration and endTime are what we cut per-turn audio clips with. Approximate values give clips that drift out of sync with the words in them, and the drift compounds across the call. Take them from your real speech timestamps, not from estimates — and check that endTime is never earlier than time.
call.id must match call.started. It is both the key that finds the run and the key that deduplicates retries. A report whose id does not match any call we placed cannot be attached to anything.
Quick reference
Enough to build from. Everything after this is detail.Sample session
One call from start to finish.Related
- Voice Simulation Agents — Register your agent in Netra
- Voice Simulations — Create test scenarios for your voice agent
- Vapi Integration — Hosted alternative, no protocol work
- LiveKit Integration — Room-based alternative
