> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getnetra.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Websockets

> Connect any voice agent to Netra over a WebSocket you control. Stream PCM audio, emit call events, and POST an end-of-call report — no hosted platform required.

<span style={{display:'inline-block',padding:'2px 10px',borderRadius:'9999px',fontSize:'0.75rem',fontWeight:600,letterSpacing:'0.02em',background:'rgba(222,137,0,0.15)',color:'#de8900',border:'1px solid rgba(222,137,0,0.35)'}}>Coming soon</span>

Custom Websockets is for voice agents that do not run on a hosted platform. Instead of Netra driving provider specific setups on your behalf, you expose a WebSocket, Netra's simulated caller connects to it, and the two sides hold a real conversation — our caller speaks, your agent answers, both hear each other live. Nothing is pre-recorded.

You build two things: a **WebSocket your agent answers on**, and an **end-of-call report you POST** once the call is over.

## How It Works

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant N as Netra
    participant Y as Your agent
    N->>Y: open websocket + auth
    Y-->>N: call.started
    loop the conversation
        N->>Y: audio, binary PCM
        Y-->>N: audio, binary PCM
        Y-->>N: events<br/>(partial.transcript, final.transcript, speech.state)
    end
    Y-->>N: call.ended
    Y-->>N: socket closed
    Note over Y: minutes later, over HTTP
    Y->>N: POST end-of-call report
```

1. You register your agent in the Netra dashboard with a WebSocket URL
2. Netra opens that WebSocket — one connection is one call
3. Your agent sends `call.started`, then both sides stream binary PCM audio and json events.
4. Your agent sends `call.ended` and closes the socket
5. Your backend POSTs the end-of-call report to Netra's webhook, authenticated with a Netra API key.
6. Netra converts the report into spans, slices the audio, and runs evaluators

<Note>
  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.
</Note>

## Quick reference

Enough to build from. Everything after this is detail.

|                    |                                                                                         |
| ------------------ | --------------------------------------------------------------------------------------- |
| **Audio**          | `pcm_s16le`, mono, raw. About 20 ms per binary frame — 640 bytes at 16 kHz              |
| **Auth**           | A token inside the request header                                                       |
| **First event**    | `call.started`, carrying your `callId` and the audio format. We send no audio before it |
| **Last event**     | `call.ended`, carrying `reason`, `endedBy` and `duration`. Then close the socket        |
| **In between**     | Binary audio both ways. `speech.state` and transcripts are optional                     |
| **Unknown events** | Ignored, never errors. Yours and ours alike                                             |
| **After the call** | POST the end-of-call report, keyed on the same `callId`                                 |

## Configuration

Register your agent under **Library → Agents** in the Netra dashboard. Select **Voice** as the modality and **Custom** as the platform.

| Field             | Required | Description                                                  |
| ----------------- | -------- | ------------------------------------------------------------ |
| **WebSocket URL** | Yes      | `wss://` endpoint Netra connects to, one per agent           |
| **Auth**          | Yes      | A token inside the request header                            |
| **Sample rate**   | Yes      | The rate your side expects. Netra resamples its caller to it |

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:

| Frame type | Direction                                    | Content                   |
| ---------- | -------------------------------------------- | ------------------------- |
| **Binary** | Both ways                                    | Raw PCM audio, never JSON |
| **Text**   | Mostly you to us; we may send control events | JSON events               |

**Rules for the socket**

* Send `call.started` as 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.started` and never renegotiated.
* Silence is fine — send zero-filled frames rather than stopping. Gaps confuse voice detection on our side.
* Ignore any JSON `type` you 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

| Field        | Values                            | Default     |
| ------------ | --------------------------------- | ----------- |
| `format`     | `pcm_s16le`                       | `pcm_s16le` |
| `sampleRate` | `8000`, `16000`, `24000`, `48000` | `16000`     |
| `channels`   | `1` (mono)                        | `1`         |
| `container`  | `raw`, with no WAV or Ogg header  | `raw`       |

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.

## Connection lifecycle

<Steps>
  <Step title="We connect">
    We open your WebSocket URL, sending your token in the request header.
  </Step>

  <Step title="You send call.started">
    Your first text frame. The call is live from here, and this is where we learn your `callId`.
  </Step>

  <Step title="Media and events">
    Binary frames for audio in both directions, plus optional `speech.state` and transcript events from you.
  </Step>

  <Step title="The call ends">
    You send `call.ended`, then close. Or we close, if our caller hung up first.
  </Step>

  <Step title="The report">
    Minutes later, you POST the end-of-call report over HTTP.
  </Step>
</Steps>

<Note>
  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.
</Note>

## Event envelope

Every JSON text frame uses this shape:

<CodeGroup>
  ```json envelope theme={null}
  {
    "type": "event_type",
    "callId": "85fb7bd968274f4680cfbb2912bd5e07",
    "timestamp": "2026-09-22T12:38:22.000Z"
  }
  ```
</CodeGroup>

| Field       | Required | Description             |
| ----------- | -------- | ----------------------- |
| `type`      | Yes      | Event name              |
| `callId`    | Yes      | Call id, on every event |
| `timestamp` | Yes      | ISO-8601, your clock    |

## Events

The samples below are taken from a real call against a booking agent.

### call.started

Sent once, as soon as the WebSocket is open and the call is live. We hold our caller's audio until this arrives.

<CodeGroup>
  ```json call.started theme={null}
  {
    "type": "call.started",
    "callId": "85fb7bd968274f4680cfbb2912bd5e07",
    "timestamp": "2026-09-22T12:38:22.000Z",
    "config": {
      "audioFormat": {
        "format": "pcm_s16le",
        "container": "raw",
        "sampleRate": 48000,
        "channels": 1
      },
      "agentId": "Apollo Clinic",
      "sessionId": "85fb7bd968274f4680cfbb2912bd5e07"
    }
  }
  ```
</CodeGroup>

| Field                | Description                                                                |
| -------------------- | -------------------------------------------------------------------------- |
| `config.audioFormat` | The format for this call, both directions, for its whole length            |
| `config.agentId`     | Which agent is serving the call                                            |
| `config.sessionId`   | Your own session id, for correlating against your logs. May equal `callId` |

<Note>
  **`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.
</Note>

### 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.

<CodeGroup>
  ```json call.ended theme={null}
  {
    "type": "call.ended",
    "callId": "85fb7bd968274f4680cfbb2912bd5e07",
    "timestamp": "2026-09-22T12:39:31.000Z",
    "reason": "client_ended",
    "endedBy": "client",
    "duration": 69.36
  }
  ```
</CodeGroup>

| Field      | Values                                                                                                           |
| ---------- | ---------------------------------------------------------------------------------------------------------------- |
| `reason`   | `agent_ended`, `client_ended`, `silence_timeout`, `max_duration`, `error`, `platform_error`, `agent_transferred` |
| `endedBy`  | `agent`, `client`, `system`                                                                                      |
| `duration` | Wall-clock seconds since `call.started`                                                                          |

<Warning>
  `call.ended` and the end-of-call report use **different reason vocabularies** — `client_ended` on the socket is `customer-ended-call` in the report. Both are listed here; do not carry a value from one into the other.
</Warning>

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.

<CodeGroup>
  ```json speech.state theme={null}
  {
    "type": "speech.state",
    "callId": "85fb7bd968274f4680cfbb2912bd5e07",
    "timestamp": "2026-09-22T12:38:46.000Z",
    "role": "agent",
    "status": "started",
    "turn": 3
  }
  ```
</CodeGroup>

| Field    | Values               | Description                        |
| -------- | -------------------- | ---------------------------------- |
| `role`   | `agent`, `user`      | Who is speaking                    |
| `status` | `started`, `stopped` | Which edge of the utterance        |
| `turn`   | integer              | Turn index. Groups related updates |

<Warning>
  Never read "a binary frame arrived" as "someone is speaking." Audio streams continuously, silence included. That is what this event is for. It may also fire more than once per turn when speech is chunked, so a long agent reply can produce several `started` and `stopped` pairs — and the `started` and `stopped` of one utterance must carry the **same** `turn`.
</Warning>

### Transcripts

Real-time speech-to-text for both sides. The event `type` 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.

<CodeGroup>
  ```json partial.transcript theme={null}
  {
    "type": "partial.transcript",
    "callId": "85fb7bd968274f4680cfbb2912bd5e07",
    "timestamp": "2026-09-22T12:38:31.000Z",
    "role": "user",
    "text": "Yeah, I want",
    "turn": 1,
    "language": "en"
  }
  ```

  ```json final.transcript theme={null}
  {
    "type": "final.transcript",
    "callId": "85fb7bd968274f4680cfbb2912bd5e07",
    "timestamp": "2026-09-22T12:38:32.000Z",
    "role": "user",
    "text": "Yeah, I want to book in.",
    "turn": 1,
    "language": "en",
    "confidence": null
  }
  ```
</CodeGroup>

| Field        | Description                                                          |
| ------------ | -------------------------------------------------------------------- |
| `role`       | `agent` or `user`                                                    |
| `text`       | Recognised, or agent-spoken, text                                    |
| `turn`       | Groups partials with their final for one utterance                   |
| `language`   | ISO 639-1 code, when detected                                        |
| `confidence` | 0.0 to 1.0, on finals only. `null` when your STT does not provide it |

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.

One utterance typically looks like this:

```text theme={null}
partial.transcript  ->  "Yeah, I want"
partial.transcript  ->  "Yeah, I want to book"
final.transcript    ->  "Yeah, I want to book in."
```

## Sample session

One call from start to finish.

```text theme={null}
Netra ->  open wss://api.acme.com/netra/agent-42   (with auth)

You   ->  { "type": "call.started", "callId": "85fb7bd9...",
            "config": { "audioFormat": { "sampleRate": 48000 } } }

You   ->  [binary]  the agent greets the caller
Netra ->  [binary]  the caller speaks
You   ->  { "type": "partial.transcript", "role": "user", "text": "Yeah, I want", "turn": 1 }
You   ->  { "type": "final.transcript",   "role": "user",
            "text": "Yeah, I want to book in.", "turn": 1 }

You   ->  { "type": "speech.state", "role": "agent", "status": "started", "turn": 2 }
You   ->  [binary]  the agent replies
You   ->  { "type": "speech.state", "role": "agent", "status": "stopped", "turn": 2 }

          ... back and forth ...

You   ->  { "type": "call.ended", "reason": "client_ended",
            "endedBy": "client", "duration": 69.36 }
You   ->  socket closed

          minutes later, over plain HTTP:
You   ->  POST <your Netra webhook URL>
          { "type": "end-of-call-report", "call": { "id": "85fb7bd9..." } }
```

## 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.

<CodeGroup>
  ```http request theme={null}
  POST <your Netra webhook URL>
  Authorization: your-netra-api-key
  Content-Type: application/json
  ```
</CodeGroup>

| Our response     | What it means                                                  |
| ---------------- | -------------------------------------------------------------- |
| `2xx`            | We have the report. Do not resend                              |
| `4xx`            | The report is malformed. Resending the same body will not help |
| `5xx` or timeout | Retry with backoff                                             |

<Note>
  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.
</Note>

### Top-level fields

| Field                                       | Required    | Type     | Notes                                                                                          |
| ------------------------------------------- | ----------- | -------- | ---------------------------------------------------------------------------------------------- |
| `type`                                      | Required    | string   | Exactly `end-of-call-report`. Anything else is rejected                                        |
| `call.id`                                   | Required    | string   | The same `callId` you sent in `call.started`                                                   |
| `startedAt`                                 | Required    | ISO 8601 | When the call began                                                                            |
| `endedAt`                                   | Required    | ISO 8601 | When the call ended                                                                            |
| `durationMs`                                | Required    | int      | Call wall clock in milliseconds                                                                |
| `artifact.messages`                         | Required    | array    | The conversation, turn by turn                                                                 |
| `costs`                                     | Required    | array    | Session totals                                                                                 |
| `provider`                                  | Recommended | string   | Which stack produced the report                                                                |
| `endedReason`                               | Recommended | string   | `assistant-ended-call`, `customer-ended-call`, `silence-timed-out`, `assistant-forwarded-call` |
| `transcript`                                | Recommended | string   | The whole conversation, one line per turn, each prefixed `User:` or `AI:`                      |
| `stereoRecordingUrl`                        | Recommended | URL      | Stereo WAV. Channel 0 is the caller, channel 1 is your agent                                   |
| `artifact.performanceMetrics.turnLatencies` | Recommended | array    | One entry per turn                                                                             |
| `customer.number`, `phoneNumber.number`     | Optional    | string   | Caller and called numbers                                                                      |

Set a recommended or optional field to `null` when you do not have it, rather than leaving the key out.

<CodeGroup>
  ```json report header theme={null}
  {
    "type": "end-of-call-report",
    "provider": "o1",
    "call": { "id": "85fb7bd968274f4680cfbb2912bd5e07" },
    "startedAt": "2026-09-22T12:38:22.000Z",
    "endedAt": "2026-09-22T12:39:31.000Z",
    "durationMs": 69360,
    "endedReason": "customer-ended-call",
    "transcript": "AI: Hello! How may I help you today?\nUser: Yeah, I want to book in.",
    "customer": null,
    "phoneNumber": null,
    "stereoRecordingUrl": null
  }
  ```
</CodeGroup>

### 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.

| Field              | Required                          | Notes                                                                                         |
| ------------------ | --------------------------------- | --------------------------------------------------------------------------------------------- |
| `role`             | Required                          | One of `system`, `bot`, `user`, `tool_calls`, `tool_call_result`                              |
| `message`          | Required                          | The text of the turn. Empty string for `tool_calls`                                           |
| `time`             | Required                          | Epoch ms when the speaker started                                                             |
| `endTime`          | Required on `bot` and `user`      | Epoch ms when the speaker finished                                                            |
| `secondsFromStart` | Required                          | Offset in seconds from the start of the call                                                  |
| `duration`         | Required on `bot` and `user`      | Speech duration in ms                                                                         |
| `toolCalls`        | Required on `tool_calls`          | Array of function name and arguments                                                          |
| `result`           | Recommended on `tool_call_result` | Raw result as a JSON string                                                                   |
| `assistantName`    | Recommended on `bot`              | Persona name                                                                                  |
| `source`           | Recommended on `bot`              | Empty string is fine                                                                          |
| `turn_usage`       | Recommended                       | Per-turn `llm` and `tts` on `bot`, `stt` on `user`, each with model, provider and cost in USD |

<CodeGroup>
  ```json bot turn theme={null}
  {
    "role": "bot",
    "message": "Sure, umm—do you have a booking ID?",
    "time": 1790080712346,
    "endTime": 1790080715816,
    "secondsFromStart": 9.93,
    "duration": 2641,
    "source": "",
    "assistantName": "Apollo Clinic",
    "assistantId": "booking-agent",
    "turn_usage": {
      "llm": {
        "promptTokens": 2480,
        "completionTokens": 21,
        "model": "gpt-5.4-nano",
        "provider": "api.openai.com",
        "cost": 0.00031
      },
      "tts": {
        "characters": 35,
        "model": "sonic-3.5",
        "provider": "Cartesia",
        "cost": 0.00008
      }
    }
  }
  ```

  ```json user turn theme={null}
  {
    "role": "user",
    "message": "I don't have a booking ID.",
    "time": 1790080717541,
    "endTime": 1790080719160,
    "secondsFromStart": 15.125,
    "duration": 1619,
    "turn_usage": {
      "stt": {
        "durationSeconds": 1.619,
        "model": "ink-2",
        "provider": "Cartesia",
        "cost": null
      }
    }
  }
  ```

  ```json tool call theme={null}
  {
    "role": "tool_calls",
    "message": "",
    "time": 1790080734237,
    "secondsFromStart": 31.82,
    "toolCalls": [
      {
        "function": {
          "name": "get_all_services",
          "arguments": "{}"
        }
      }
    ]
  }
  ```

  ```json tool result theme={null}
  {
    "role": "tool_call_result",
    "message": "[{\"service_id\":\"service_demo_1\",\"service_name\":\"ENT\"}]",
    "time": 1790080734239,
    "secondsFromStart": 31.822,
    "result": "[{\"service_id\":\"service_demo_1\",\"service_name\":\"ENT\"}]"
  }
  ```
</CodeGroup>

### Turn latencies

`artifact.performanceMetrics.turnLatencies` carries the per-turn breakdown. Entries are in call order and any field you cannot measure is `null`.

<CodeGroup>
  ```json turnLatencies theme={null}
  {
    "turnLatencies": [
      {
        "transcriberLatency": null,
        "modelLatency": 1287,
        "voiceLatency": 120,
        "endpointingLatency": null,
        "turnLatency": null
      },
      {
        "transcriberLatency": 171,
        "modelLatency": 896,
        "voiceLatency": 156,
        "endpointingLatency": 323,
        "turnLatency": 928
      }
    ]
  }
  ```
</CodeGroup>

| Field                | Milliseconds from                                                      |
| -------------------- | ---------------------------------------------------------------------- |
| `transcriberLatency` | End of caller speech to final transcript                               |
| `endpointingLatency` | End of caller speech to the decision that the turn is over             |
| `modelLatency`       | Prompt sent to first LLM token                                         |
| `voiceLatency`       | First LLM token to first audio byte                                    |
| `turnLatency`        | End of caller speech to first audio byte — the number the caller feels |

### Session costs

Three entries in `costs`, one per provider type.

| `type`        | Carries                                                                     |
| ------------- | --------------------------------------------------------------------------- |
| `model`       | `promptTokens`, `completionTokens`, the model name and provider, and `cost` |
| `transcriber` | `minutes`, plus the model name and provider                                 |
| `voice`       | `characters`, plus the model name and provider                              |

<CodeGroup>
  ```json costs theme={null}
  {
    "costs": [
      {
        "type": "model",
        "promptTokens": 30419,
        "completionTokens": 281,
        "model": { "model": "gpt-5.4-nano", "provider": "api.openai.com" },
        "cost": 0.00397
      },
      {
        "type": "voice",
        "characters": 653,
        "voice": { "model": "sonic-3.5", "provider": "Cartesia" }
      },
      {
        "type": "transcriber",
        "minutes": 0.989,
        "transcriber": { "model": "ink-2", "provider": "Cartesia" }
      }
    ]
  }
  ```
</CodeGroup>

### Three things people get wrong

<Warning>
  **Use `bot`, never `assistant`.** Turn grouping matches that exact string. A report using `assistant` is accepted, produces zero agent turns, and scores as a call where your agent never spoke.
</Warning>

**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.

## Related

* [Voice Simulation Agents](/Simulations/voice-simulations/agents) — Register your agent in Netra
* [Voice Simulations](/Simulations/voice-simulations/Simulations) — Create test scenarios for your voice agent
* [Vapi Integration](/Integrations/voice-frameworks/Vapi) — Hosted alternative, no protocol work
* [LiveKit Integration](/Integrations/voice-frameworks/LiveKit) — Room-based alternative
