# Make a call

Two objects and one command. Create an agent — the configuration that decides how the conversation goes — then place a call that runs it. Everything below assumes `$SPHORO_API_KEY` is set from [step 1](https://voice.sphoro.com/docs/api-keys).

## Step 1 · Create an agent

An agent is long-lived: create it once and place as many calls with it as you like. The only two things it insists on are a name and one language carrying the instructions it follows.

`POST /v1/agents`

**curl**

```bash
AGENT_ID=$(curl -s -X POST https://voice.sphoro.com/v1/agents \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Clinic reception",
    "languages": [{
      "code": "en-IN",
      "system_prompt": "You are a receptionist for Acme Clinic. Answer questions about opening hours and appointments. Keep answers to one or two sentences.",
      "greeting": "Thanks for calling Acme Clinic. How can I help?"
    }]
  }' | jq -r .id)

echo $AGENT_ID
# agt_7f3ab2c19e4d
```

**Node**

```js
const res = await fetch("https://voice.sphoro.com/v1/agents", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SPHORO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Acme Clinic reception",
    languages: [{
      code: "en-IN",
      system_prompt:
        "You are a warm, efficient receptionist for Acme Clinic. Answer questions about " +
        "opening hours and appointments. Keep every answer to one or two sentences.",
      greeting: "Thanks for calling Acme Clinic. How can I help?",
    }],
  }),
});

const agent = await res.json();
console.log(agent.id); // agt_7f3ab2c19e4d
```

**Python**

```python
import os, json, urllib.request

body = json.dumps({
    "name": "Acme Clinic reception",
    "languages": [{
        "code": "en-IN",
        "system_prompt": (
            "You are a warm, efficient receptionist for Acme Clinic. Answer questions "
            "about opening hours and appointments. Keep every answer to one or two sentences."
        ),
        "greeting": "Thanks for calling Acme Clinic. How can I help?",
    }],
}).encode()

req = urllib.request.Request(
    "https://voice.sphoro.com/v1/agents",
    data=body,
    headers={
        "Authorization": "Bearer " + os.environ["SPHORO_API_KEY"],
        "Content-Type": "application/json",
    },
)
with urllib.request.urlopen(req) as res:
    agent = json.load(res)

print(agent["id"])  # agt_7f3ab2c19e4d
```

**Response201 Created**

```json
{
  "id": "agt_7f3ab2c19e4d",
  "object": "agent",
  "name": "Acme Clinic reception",
  "languages": [{
    "code": "en-IN",
    "system_prompt": "You are a receptionist for Acme Clinic. Answer questions...",
    "greeting": "Thanks for calling Acme Clinic. How can I help?"
  }],
  "model": "claude-sonnet-5",
  "temperature": 0.6,
  "max_tokens": 512,
  "speed": 1,
  "pitch": 1,
  "created_at": "2026-08-18T09:12:44Z"
}
```

Keep the `id`. It is the one thing every call needs.

### Every field an agent takes

Two are required. The rest have the defaults shown above, and every one of them can be changed later with `PATCH /v1/agents/{id}` without touching the others.

| Field | Type | Description |
| --- | --- | --- |
| `name` required | string | A label for you, up to 120 characters. Never spoken aloud. |
| `languages` required | array | Every language it speaks, the one calls open in first — and where the agent's instructions live. Each entry has a `code`, a `system_prompt`, an optional `voice_id`, and a `greeting` — the first thing said, before the caller speaks. The first entry's prompt is the agent's own, up to 100,000 characters: say what it does, what it must not do, and how long its answers should be, because voice punishes long answers far more than chat does. A later language that leaves its prompt empty is read against the first one with an instruction to speak that language in front. Without a greeting the agent waits for the caller to open, which on an outbound call is a silence they will hang up on. Every accepted code, and what a voice is, is in [step 4](https://voice.sphoro.com/docs/voices). |
| `speed` optional | number, 0.25–4 or 0 | Speaking rate, 1 being the voice's natural pace. Send 0 for whatever the deployment plays at, which is also what an agent that has never been given one carries. |
| `pitch` optional | number, 0.25–4 or 0 | Pitch, 1 being natural. Send 0 for the deployment's own. |
| `filler_phrase` optional | string | Said while a slow lookup runs, so the line is not silent — "let me check that for you". |
| `model` optional | string | The language model behind the conversation. Defaults to `claude-sonnet-5`. Leave it alone unless you have measured a reason. |
| `temperature` optional | number, 0–2 | How much the wording varies. Defaults to 0.6. Below 0.3 sounds clipped; above 1 starts improvising facts. |
| `max_tokens` optional | integer, 1–4000 or 0 | Ceiling on one reply. Defaults to 512, which is roughly 30 seconds of speech — and the ceiling is low on purpose, because a long reply is one the caller talks over. Send 0 for the deployment's own default, which is also what an agent with no model carries. |
| `record_calls` optional | boolean | Record the audio, retrievable afterwards. Defaults to off. Recording people has legal conditions that differ by country — that is your call to make, not ours. |
| `transfer_number` optional | string | The one number in E.164 the agent may transfer a caller to. A single number rather than a list, because an agent that can dial anywhere is one prompt injection away from dialling anywhere. |
| `webhook_url` optional | string | An https URL for this agent's events alone. Most integrations want one account-wide endpoint instead — [step 5](https://voice.sphoro.com/docs/webhooks). |
| `carrier` optional | string | Which carrier places this agent's outbound calls, overriding the deployment default. Leave empty unless you have been told otherwise. |
| `description` optional | string | A note to yourself. Never spoken, never sent to the model. |
| `metadata` optional | object | Your own string key/values, returned on every read. Useful for the id this agent has in your system. |

> **The agent accepts further fields** for knowledge bases, callable functions and keypad menus, which are outside this guide — everything needed to get a call connected is above. The complete schema is in [openapi.json](https://voice.sphoro.com/openapi.json).

## Step 2 · Place the call

One request. It returns immediately with a `queued` call — dialling, ringing and answering happen after the response, and you hear about them through [events](https://voice.sphoro.com/docs/calls#step-3-follow-the-call-as-it-happens) or [webhooks](https://voice.sphoro.com/docs/webhooks).

`POST /v1/calls`

**curl**

```bash
CALL_ID=$(curl -s -X POST https://voice.sphoro.com/v1/calls \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d "{
    \"agent_id\": \"$AGENT_ID\",
    \"to\": \"+919876543210\"
  }" | jq -r .id)

echo $CALL_ID
# call_1d8e4c7b9a02
```

**Node**

```js
import { randomUUID } from "node:crypto";

const res = await fetch("https://voice.sphoro.com/v1/calls", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SPHORO_API_KEY}`,
    "Idempotency-Key": randomUUID(),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    agent_id: agent.id,
    to: "+919876543210",
  }),
});

const call = await res.json();
console.log(call.id, call.status); // call_1d8e4c7b9a02 queued
```

**Python**

```python
import os, json, uuid, urllib.request

body = json.dumps({"agent_id": agent["id"], "to": "+919876543210"}).encode()

req = urllib.request.Request(
    "https://voice.sphoro.com/v1/calls",
    data=body,
    headers={
        "Authorization": "Bearer " + os.environ["SPHORO_API_KEY"],
        "Idempotency-Key": str(uuid.uuid4()),
        "Content-Type": "application/json",
    },
)
with urllib.request.urlopen(req) as res:
    call = json.load(res)

print(call["id"], call["status"])  # call_1d8e4c7b9a02 queued
```

**Response201 Created**

```json
{
  "id": "call_1d8e4c7b9a02",
  "object": "call",
  "agent_id": "agt_7f3ab2c19e4d",
  "status": "queued",
  "direction": "outbound",
  "from": "+911140848000",
  "to": "+919876543210",
  "language": "en-IN",
  "source": "sdk",
  "source_client": "sphoro-voice-node/1.0.0",
  "created_at": "2026-08-18T09:14:02Z",
  "started_at": "2026-08-18T09:14:02Z"
}
```

### Every field a call takes

| Field | Type | Description |
| --- | --- | --- |
| `agent_id` required | string | The agent to run. It must belong to your account; another account's id is a validation error, not a 404. |
| `to` optional | string | The number to dial, in E.164 — a leading `+`, country code, no spaces or dashes. Required for a phone call. Omit it for a [browser call](https://voice.sphoro.com/docs/calls#calling-from-a-browser). |
| `from` optional | string | The caller ID to present. Omit it and we pick one for you — the number assigned to `member_id` if you sent one, otherwise the agent's own list, rotating so the same recipient keeps seeing the same number. |
| `member_id` optional | string | Who is placing the call, when a person is. One agent serves your whole team, so the agent alone cannot say who dialled — see [calling on somebody's behalf](https://voice.sphoro.com/docs/calls#calling-on-somebodys-behalf). |
| `direction` optional | string | `outbound`, `inbound` or `web`. Inferred rather than sent: `outbound` when `to` is present, `web` when it is not. |
| `language` optional | string | Opens this one call in one of the agent's languages. Defaults to the first. A language the agent does not speak is refused — see [step 4](https://voice.sphoro.com/docs/voices). |
| `variables` optional | object | String key/values that fill `{{placeholders}}` in the prompt and greeting — see [personalising a single call](https://voice.sphoro.com/docs/calls#personalising-a-single-call). |
| `metadata` optional | object | Your own string key/values, stored on the call and returned on every read and every webhook. Put your order id here. |
| `carrier` optional | string | Which carrier places this one call, overriding the agent's and the deployment's choice. Only valid on an outbound call. |

### Where a call came from

Every call comes back with a `source`, and it is not a field you send. It is derived from the endpoint you reached and the credential you used, so nothing can file itself under the wrong one:

| Source | Means |
| --- | --- |
| `app` | Placed on one of our own surfaces by a signed-in person — the operator console, the dialer, a test call from an agent page. |
| `sdk` | Placed through one of the published clients, or minted at `POST /v1/realtime/tokens` for a browser. `source_client` names which, and at what version. |
| `api` | A direct request whose `User-Agent` named no client we recognise. Your own integration, or curl. |
| `campaign` | The campaign dialler, working through a list. Nobody asked for this call on its own. |
| `inbound` | A carrier delivered it. Nothing on this side placed it, and `source_client` names the carrier. |

`direction` tells you which way the audio went; this tells you which of your integrations produced the call, which stops being the same question the moment you have more than one. `source_client` is free text and is absent when nothing named itself — read it, do not branch on it. Both are absent on calls placed before we recorded this, which is not the same as `api`.

> **Always send an `Idempotency-Key`.** If the connection drops after we accept the request but before you read the response, retrying without one places a second call to a real person. With one, the retry returns the original call. Generate it when you decide to dial and reuse it for every retry of that decision — [step 2](https://voice.sphoro.com/docs/authentication#retrying-safely) has the detail.

## Step 3 · Follow the call as it happens

Open a stream and read events as they occur — transcript turns, transfers, the end reason. It is Server-Sent Events, so `curl` can read it and browsers reconnect to it automatically.

`GET /v1/calls/{id}/events`

**Shell**

```bash
curl -N https://voice.sphoro.com/v1/calls/$CALL_ID/events \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

**Event stream**

```sse
id: evt_01
event: call.started
data: {"id":"evt_01","type":"call.started","call_id":"call_1d8e4c7b9a02","created_at":"2026-08-18T09:14:02Z","data":{"agent_id":"agt_7f3ab2c19e4d","direction":"outbound","to":"+919876543210","from":"+911140848000"}}

id: evt_02
event: call.transcript.updated
data: {"id":"evt_02","type":"call.transcript.updated","call_id":"call_1d8e4c7b9a02","created_at":"2026-08-18T09:14:19Z","data":{"role":"user","text":"are you open on Sunday?"}}

id: evt_03
event: call.ended
data: {"id":"evt_03","type":"call.ended","call_id":"call_1d8e4c7b9a02","created_at":"2026-08-18T09:15:31Z","data":{"duration_seconds":89.2,"turns":6,"end_reason":"completed"}}

event: done
data: {}
```

Three things about this stream are worth knowing before you build on it:

- It ends with an `event: done` frame after `call.ended`, so a client knows the difference between "finished" and "the connection dropped".
- Every frame carries an `id`. Reconnect with `Last-Event-ID` set to the last one you saw and you get everything you missed, rather than a gap.
- A comment line arrives every 15 seconds during a quiet call, which is what stops a proxy idling the connection out.

This stream is for watching one call you are already holding open — a live UI, or a terminal while you test. For a server that needs to know about every call without holding a connection per call, use [webhooks](https://voice.sphoro.com/docs/webhooks) instead.

## Step 4 · Read the result

Once the call is over, fetch it. The record carries how it ended, how long it lasted, and the whole conversation.

`GET /v1/calls/{id}`

**Shell**

```bash
curl -s https://voice.sphoro.com/v1/calls/$CALL_ID \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

**Response200 OK**

```json
{
  "id": "call_1d8e4c7b9a02",
  "object": "call",
  "agent_id": "agt_7f3ab2c19e4d",
  "status": "completed",
  "direction": "outbound",
  "from": "+911140848000",
  "to": "+919876543210",
  "language": "en-IN",
  "started_at": "2026-08-18T09:14:02Z",
  "ended_at": "2026-08-18T09:15:31Z",
  "duration_seconds": 89.2,
  "end_reason": "completed",
  "source": "sdk",
  "source_client": "sphoro-voice-node/1.0.0",
  "turns": 6,
  "transcript": [
    { "role": "agent", "text": "Thanks for calling Acme Clinic. How can I help?", "at": "2026-08-18T09:14:11Z" },
    { "role": "user",  "text": "are you open on Sunday?", "at": "2026-08-18T09:14:19Z", "latency_ms": 640 }
  ],
  "recording_url": "https://voice.sphoro.com/v1/calls/call_1d8e4c7b9a02/recording"
}
```

### Just the transcript

The same conversation without the rest of the record, which is what you want if you are rendering it in a UI:

`GET /v1/calls/{id}/transcript`

**Shell**

```bash
curl -s https://voice.sphoro.com/v1/calls/$CALL_ID/transcript \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

**Response200 OK**

```json
{
  "object": "transcript",
  "call_id": "call_1d8e4c7b9a02",
  "status": "completed",
  "turns": 6,
  "entries": [ ... ]
}
```

Each entry carries a `role` of `user`, `agent` or `tool`; the text; when it was said; and, on the caller's turns, `latency_ms` — how long the agent took to start answering. An entry with `"source": "keypad"` was typed on the keypad rather than spoken, which matters because "1" pressed and "one" said arrive as the same string.

### The recording

Only if the agent was created with `record_calls: true`. The endpoint streams the audio itself, authenticated the same way as everything else:

`GET /v1/calls/{id}/recording`

**Shell**

```bash
curl -s https://voice.sphoro.com/v1/calls/$CALL_ID/recording \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -o call.wav
```

A call with no recording answers `404` and `not_found` rather than an empty file.

## What each call status means

| Status | Means |
| --- | --- |
| `queued` | Accepted, not yet dialled. Every outbound call starts here. |
| `in_progress` | Connected and talking. A browser call starts here, since there is nothing to dial. |
| `completed` | Finished. Read `end_reason` to know how — a call that went to voicemail also completes. |
| `failed` | Never got to a conversation. |

### And why it ended

`end_reason` is the field to branch on, not `status`. Reaching voicemail and reaching the customer both end a call the same way.

| End reason | Means |
| --- | --- |
| `completed` | The conversation ran to its end. |
| `voicemail` | An answering machine picked up, not a person. |
| `no_answer` | Rang out. |
| `busy` | The line was busy. |
| `canceled` | Cancelled before it connected. |
| `rejected` | The far end refused the call outright. |
| `bad_number` | There is no such number to reach. |
| `unanswered` | It rang and nobody picked up. On a console call, the carrier reported the far end ringing and the ring window then ran out. |
| `not_connected` | It never rang. The number was dialled and the far end's telephone never started, so the attempt never left the carrier — which is a different thing from `unanswered` and worth a different response. |
| `dropped` | A connected leg stopped sending without hanging up. |
| `declined` | An operator refused it while it rang. |
| `missed` | It rang on a line a person answers and nobody took it. |
| `caller_gave_up` | The caller hung up while it was still ringing. |
| `ended_by_api` | You ended it — see below. |
| `carrier_failure` | The carrier could not place it. Bad number, blocked route, no credit. |
| `carrier_disconnected` | The line dropped mid-conversation. Also how a transferred call ends here, since the carrier tears the media stream down once the caller is with a person. |
| `announcement` | A `normal` agent with no transfer number read its greeting out and hung up. |
| `transfer_failed` | A `normal` agent could not hand the call over. It has no model to apologise with, so the call ends rather than holding the caller on a line nobody is coming to answer. |
| `at_capacity` | Your account was at its concurrent-call ceiling. Retry it. |
| `no_input` | The caller went quiet. A conversation agent asks once whether they are still there and, hearing nothing more, says goodbye; a keypad menu re-reads its options and then gives up. The timings are the agent's `conversation` settings. |
| `max_duration` | The call reached the agent's `conversation.max_call_seconds`. The agent said a short goodbye first. |
| `over_budget` | The project this agent bills to was over its monthly budget, so the call was refused with the busy line. |

## Ending a call early

Hangs up the phone leg as well as closing the conversation. Both matter: closing only our side leaves a carrier leg up, and you are billed for every minute of it.

`POST /v1/calls/{id}/end`

**Shell**

```bash
curl -s -X POST https://voice.sphoro.com/v1/calls/$CALL_ID/end \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason":"customer_requested"}'
```

`reason` is optional and is stored as the call's `end_reason`; omit it and you get `ended_by_api`.

## Listing calls

Newest first, cursor-paginated like every list on the API. Two filters, applied before pagination — so `?status=failed` pages through failures rather than through the first page of everything.

`GET /v1/calls`

**Shell**

```bash
curl -s "https://voice.sphoro.com/v1/calls?status=failed&limit=20" \
  -H "Authorization: Bearer $SPHORO_API_KEY"

curl -s "https://voice.sphoro.com/v1/calls?agent_id=$AGENT_ID" \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

List responses omit transcripts — fetch a single call for those. Paging is covered in [step 2](https://voice.sphoro.com/docs/authentication#reading-lists).

## Calling on somebody’s behalf

One agent serves your whole team. That is the point of it — fifty people who make the same kind of call share one prompt, one voice and one set of documents, and you maintain one thing instead of fifty. But an agent is a configuration, not a person, so a call placed through it says nothing about who made it.

If your own system dials on a colleague’s behalf — a CRM with a click-to-call button, a collections queue, a callback worker — send `member_id`:

`POST /v1/calls`

**Shell**

```bash
curl -s -X POST https://voice.sphoro.com/v1/calls \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"agent_id\": \"$AGENT_ID\",
    \"to\": \"+919876543210\",
    \"member_id\": \"$MEMBER_ID\"
  }"
```

Two things follow, and both are what the console already does for somebody signed in:

- **The caller ID becomes their own number** — whichever of your numbers is assigned to them on the Phone numbers page. So when the person they rang calls back, it reaches *them* and not a colleague. Without this the call goes out on the agent’s rotation, and the callback lands wherever that rotation pointed.
- **The call is attributed to them** — `member_id` comes back on the call and on every webhook, and the call appears in that person’s own history rather than the account’s.

> `member_id` is the member’s id on *this* platform, not an id from your system. A member with no number assigned to them falls back to the agent’s rotation, and an id from another account matches nothing at all — the lookup only ever sees your own numbers.

An explicit `from` still wins over both. If your system has already decided which number to present, that decision is not overruled by a seat it does not know about — and the call is still attributed.

## Personalising a single call

Write `{{placeholders}}` into a language's prompt or greeting, then fill them per call. This is how one agent serves every customer without a prompt per customer.

**On the agent, once**

```json
{
  "languages": [
    {
      "code": "en-IN",
      "system_prompt": "You are confirming an appointment for {{first_name}} on {{date}} at {{time}}.",
      "greeting": "Hello {{first_name}}, this is Acme Clinic calling about your appointment on {{date}}."
    }
  ]
}
```

**On each call**

```bash
curl -s -X POST https://voice.sphoro.com/v1/calls \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d "{
    \"agent_id\": \"$AGENT_ID\",
    \"to\": \"+919876543210\",
    \"variables\": {
      \"first_name\": \"Priya\",
      \"date\": \"Thursday the 21st\",
      \"time\": \"4:30 in the afternoon\"
    }
  }"
```

> **A missing variable fails the request, not the call.** Placing a call whose prompt needs `{{date}}` without supplying one is refused with `validation_error` naming it. That is deliberate: the alternative is an agent reading "your appointment on curly-curly-date" to a customer, and there is no undo on a phone call. The eight the platform fills itself are the exception — see below.

Write values the way they should be spoken. `"4:30 in the afternoon"` is read back correctly; `"16:30"` is a coin toss. The same goes for dates, amounts and reference numbers.

### Variables the platform fills

Eight placeholders are not yours to supply. The platform already knows them, fills them when the call is answered, and never refuses a call for their absence — so a prompt may use them freely and a campaign spreadsheet needs no column for them.

| Field | Type | Description |
| --- | --- | --- |
| `agent_id` optional | string | The agent taking the call. |
| `agent_name` optional | string | The agent's name, as you named it. Callers never hear it unless the prompt says it. |
| `call_id` optional | string | This call's id — the one call records, webhooks and recordings use. |
| `call_direction` optional | string | `inbound`, `outbound`, or `a browser`. |
| `from_number` optional | string | The number presented. `unknown` on a call held in a browser. |
| `to_number` optional | string | The number dialled. `unknown` on a call held in a browser. |
| `current_date` optional | string | Today, where the agent is — `8 September 2026`. Read in the agent's `timezone`. |
| `current_time` optional | string | Now, to the minute — `4:12 pm`. Worked out when the call is answered, not when it is created, so a campaign row queued at nine and dialled at four says four. |

Sending one of these in `variables` yourself is not an error and is not overridden: your value wins. An agent that has always filled `{{current_date}}` from its own spreadsheet column keeps doing exactly that.

## Calling from a browser

Leave `to` out and you get a `web` call instead of a phone call: no carrier, no number, no per-minute charge, and the response carries a WebSocket URL to connect a browser to. It is the fastest way to hear an agent before a phone number is provisioned.

**Shell**

```bash
curl -s -X POST https://voice.sphoro.com/v1/calls \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"agent_id\":\"$AGENT_ID\"}"
```

**Response201 Created**

```json
{
  "id": "call_1d8e4c7b9a02",
  "status": "in_progress",
  "direction": "web",
  "realtime_url": "wss://voice.sphoro.com/v1/realtime?call_id=call_1d8e4c7b9a02"
}
```

Connect to `realtime_url` and exchange JSON frames. Audio travels base64-encoded in both directions.

**The audio is G.711 µ-law at 8 kHz, each way.** Read it off `session.created` rather than assuming it — that message carries `input_audio_format`, `output_audio_format` and `sample_rate`, and a client that hardcodes them plays noise the day a deployment negotiates something else.

> **A browser will not hand you 8 kHz.** `getUserMedia` produces floating-point samples at the device rate, almost always 48 kHz, so a browser client has to low-pass and decimate before companding — and in that order. Dropping five samples in six without filtering first folds everything above 4 kHz back into the speech band, which is not muffled audio, it is unintelligible audio. Do it in an `AudioWorklet`: a `ScriptProcessorNode` runs on the main thread, so every render competes with the microphone.

Two more things a client has to get right, and both fail quietly. **Pace the playback** — queue the arriving frames and let the audio clock drain them, because writing a long buffer out as fast as it arrives ends the agent’s turn early and takes barge-in with it. And when the caller talks over the agent, **send `interrupt` and empty your own buffer**: the message stops the server generating, and only the flush stops the sound.

| You send | Meaning |
| --- | --- |
| `input_audio.append` | A chunk of microphone audio. |
| `input_audio.commit` | End of the caller's turn, if you are doing your own turn detection. |
| `input_text.append` | A typed turn, for testing without a microphone. |
| `interrupt` | The caller started talking over the agent. Stop speaking. |
| `session.update` | Change language, voice or audio format mid-call. |
| `session.end` | Hang up. |

| You receive | Meaning |
| --- | --- |
| `session.created` | Connected. Carries the resolved session configuration. |
| `transcript.partial` · `transcript.final` | What the caller is saying, as it is recognised and once it settles. |
| `response.started` · `response.done` | The agent began and finished a reply. |
| `response.audio.delta` | A chunk of the agent's speech. Play it as it arrives. |
| `response.text.delta` | The same reply as text, for captions. |
| `state.changed` | Listening, thinking, speaking — enough to drive an indicator. |
| `error` | Something went wrong, in the same shape as an HTTP error. |
| `session.ended` | The call is over, with the reason and turn count. |

> **A browser cannot send headers on a WebSocket.** The WebSocket API has no way to set one, so this endpoint — and only this endpoint — also accepts the credential as an `?access_token=` query parameter. Do not put a long-lived API key there: have your server create the call and hand the browser the `realtime_url` it returns.

## When a call will not go out

| What you see | Why |
| --- | --- |
| `422` on `agent_id` | The id is not one of your agents, or it belongs to another account. Check the value you captured. |
| `422` on `to` | Missing on an outbound call, or not E.164. `+919876543210`, not `09876543210`. |
| `422` on `variables` | The prompt or greeting has a placeholder you did not fill. |
| `402` `plan_limit_reached` | Your plan's ceiling. Retrying will not help. |
| Status `failed`, `carrier_failure` | The request was fine and the carrier could not place it — unreachable number, blocked route, no credit. |
| Ends immediately, `at_capacity` | You were at your concurrent-call ceiling. Retry. |

Every code above is explained in full in [step 2](https://voice.sphoro.com/docs/authentication#every-error-code).

## Check it before you move on

- The call connected, and `end_reason` was `completed`.
- The transcript shows both sides of the conversation.
- You are sending an `Idempotency-Key` on every POST that dials.

Next: [step 4](https://voice.sphoro.com/docs/voices) makes it speak the right language in the right voice.
