> This page is for version v2 (default).
> For other versions, use one of these documentation indexes:
> - v2 (default): https://docs.pinnacle.sh/v-2/llms.txt
> - v1: https://docs.pinnacle.sh/v1/llms.txt

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.pinnacle.sh/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.pinnacle.sh/_mcp/server.

# Voice Calls

> Start calls, connect WebSockets, send commands, and retrieve recordings.

Voice has two surfaces:

* REST endpoints under [`/calls`](/api-reference/calls/lifecycle/create-call) for call lifecycle, stream tokens, events, and recordings.
* A bidirectional WebSocket from [`POST /calls/{id}/stream-token`](/api-reference/calls/streaming/create-call-stream-token) for live audio and commands.

For calls with more than two people, see [Conference Calls](/guides/conference-calls).

## Start an outbound call

#### TypeScript

```typescript
import { PinnacleClient } from "rcs-js";

const client = new PinnacleClient({ apiKey: process.env.PINNACLE_API_KEY });

const voice = await client.voice.createAndConnect({
  from: "+14155559876",
  to: "+14155551234",
  record: true,
  metadata: { customer_id: "cus_123" },
  token: {
    commands_enabled: true,
    stream_id: "agent",
    record: true,
  },
});

voice.on("event", (frame) => console.log(frame.type, frame.payload));
await voice.waitUntilOpen();
voice.playAudio({ text: "Thanks for calling. How can I help?" });
```

#### Python

```python
import os

from rcs import Pinnacle

client = Pinnacle(api_key=os.environ["PINNACLE_API_KEY"])

voice = client.voice.create_and_connect(
    from_="+14155559876",
    to="+14155551234",
    record=True,
    metadata={"customer_id": "cus_123"},
    token={
        "commands_enabled": True,
        "stream_id": "agent",
        "record": True,
    },
)

voice.on("event", lambda frame: print(frame["type"], frame["payload"]))
voice.wait_until_open()
voice.play_audio({"text": "Thanks for calling. How can I help?"})
```

#### Ruby

```ruby
require "rcs"

client = Pinnacle::Client.new(api_key: ENV["PINNACLE_API_KEY"])

voice = client.voice.create_and_connect(
  from: "+14155559876",
  to: "+14155551234",
  record: true,
  metadata: { customer_id: "cus_123" },
  token: {
    commands_enabled: true,
    stream_id: "agent",
    record: true
  }
)

voice.on("event") { |frame| puts "#{frame['type']}: #{frame['payload']}" }
voice.wait_until_open
voice.play_audio({ text: "Thanks for calling. How can I help?" })
```

Use `client.voice.createAndConnect(...)` / `create_and_connect(...)` for interactive voice apps. It creates the call, mints a short-lived customer WebSocket token, creates the stream socket, and refreshes the token on reconnect by default. Pass `reconnect: { enabled: false }` only when you want to handle reconnects yourself. Wait for the socket to open before sending commands.

The TypeScript SDK uses `globalThis.WebSocket` when available and includes a `ws` fallback for Node. The Python SDK uses `websocket-client`, and the Ruby SDK uses `websocket-client-simple`.

The TypeScript, Python, and Ruby SDKs include custom voice helpers for connecting, sending commands, waiting for acknowledgements, and reconnecting. See [Voice WebSockets](/methods/voice-websockets).

## Manual REST flow

Use REST directly when you only need call lifecycle control, when you do not need a customer WebSocket, or when you need to manage observers, multiple subscribers, reconnect token refresh, or token options yourself.

#### TypeScript

```typescript
const call = await client.calls.create({
  from: "+14155559876",
  to: "+14155551234",
  record: true,
  metadata: { customer_id: "cus_123" },
});

const stream = await client.calls.createStreamToken(call.id, {
  commands_enabled: true,
  stream_id: "agent",
  record: true,
});
```

#### Python

```python
call = client.calls.create(
    from_="+14155559876",
    to="+14155551234",
    record=True,
    metadata={"customer_id": "cus_123"},
)

stream = client.calls.create_stream_token(
    call.id,
    commands_enabled=True,
    stream_id="agent",
    record=True,
)
```

#### Ruby

```ruby
call = client.calls.create(
  from: "+14155559876",
  to: "+14155551234",
  record: true,
  metadata: { customer_id: "cus_123" }
)

stream = client.calls.create_stream_token(
  id: call.id,
  commands_enabled: true,
  stream_id: "agent",
  record: true
)
```

Use `stream.stream_url` with your language's WebSocket client. The token is a short-lived bearer credential; create a new token when reconnecting after expiry. The SDK `connect(...)` and `createAndConnect(...)` helpers do this automatically for call streams.

## Retrying safely

A create request that times out or fails mid-flight leaves you unsure whether a
call was placed. Retrying blindly dials the recipient twice. Send an
`Idempotency-Key` header and the call happens at most once, no matter how many
times you repeat the request.

#### TypeScript

```typescript
const key = crypto.randomUUID();

const call = await client.calls.create(
  {
    from: "+14155559876",
    to: "+14155551234",
    record: true,
  },
  { headers: { "Idempotency-Key": key } },
);
```

#### cURL

```bash
curl -X POST https://api.pinnacle.sh/calls \
  -H "PINNACLE-API-KEY: $PINNACLE_API_KEY" \
  -H "Idempotency-Key: 2f1c8b34-9a1e-4a51-8f0f-6c4a1f4c21f7" \
  -H "Content-Type: application/json" \
  -d '{"from":"+14155559876","to":"+14155551234","record":true}'
```

Reusing the key returns the original call rather than placing another:

| Situation                             | Response                                           |
| ------------------------------------- | -------------------------------------------------- |
| First use of the key                  | `201` with the new call                            |
| Same key, same request                | `200` with the original call — nothing was dialled |
| Same key, different request           | `422` — the key is bound to its first request      |
| Same key, first attempt still running | `409` — retry once it settles                      |

The `200` is how you tell a replay from a fresh call. Treat `409` as "wait and
retry with the same key"; sending a *new* key at that point is what causes the
second dial you were trying to avoid.

Keys are scoped to your team and expire after 24 hours, so the same value can
be reused for a genuinely new call after that. Two requests count as the same
when `to`, `from`, `record` and `metadata` match — metadata key order does not
matter.

> **Note**
>
> If a call cannot be confirmed — a network timeout, or a `5xx` — the key stays
> bound rather than being released. That is deliberate: we cannot tell whether
> the call was placed, and the safe assumption is that it was.

## Pricing

| Call     | Price               |
| -------- | ------------------- |
| Outbound | \$0.01 per minute   |
| Inbound  | \$0.0085 per minute |

You pay for answered time only, rounded up to the whole minute, deducted from your credits when the call ends. Calls that never connect (busy, no answer, failed) are free. Recording is included. People in a [conference](/guides/conference-calls) also pay a conference fee on top of their call.

## Direction and status values

Call objects use `state`; `CALL.STATUS` webhook payloads use `status`. Both fields share the same lifecycle values.

Direction values:

| Value      | Meaning                                                                                                           |
| ---------- | ----------------------------------------------------------------------------------------------------------------- |
| `INBOUND`  | The call originated from an external caller and was received by one of your Pinnacle voice-enabled phone numbers. |
| `OUTBOUND` | The call was created from Pinnacle and dialed out from one of your voice-enabled phone numbers.                   |

Status values:

| Value       | Meaning                                                                                          |
| ----------- | ------------------------------------------------------------------------------------------------ |
| `INITIATED` | Pinnacle accepted the call request and is setting up the call with the voice provider.           |
| `RINGING`   | The call is ringing and has not been answered yet.                                               |
| `ANSWERED`  | The call has been answered and is active.                                                        |
| `BRIDGED`   | The call is connected to another call leg, such as after a transfer or bridge command.           |
| `ENDED`     | The call ended normally.                                                                         |
| `FAILED`    | The call could not be established or could not continue because of a provider or system failure. |
| `NO_ANSWER` | The call rang until timeout without being answered.                                              |
| `BUSY`      | The destination was busy.                                                                        |
| `CANCELED`  | The call was canceled before normal completion.                                                  |

## Commands and audio frames

Voice WebSockets are bidirectional. Your app sends `command` frames to control the call and optional `media` frames when you generate live outbound audio. Pinnacle sends `ack`, `event`, and `media` frames back.

### Command frame

Every command has the same envelope:

```json
{
  "event": "command",
  "command_id": "cmd_welcome",
  "action": "audio.play",
  "params": {
    "text": "Thanks for calling. How can I help?",
    "voice": "female",
    "language": "en-US"
  }
}
```

| Field        | Meaning                                                                                          |
| ------------ | ------------------------------------------------------------------------------------------------ |
| `event`      | Always `command` for commands sent by your app.                                                  |
| `command_id` | Your unique ID for matching the later `ack`. SDK helpers generate this when you do not pass one. |
| `action`     | The command to run. See the catalog below.                                                       |
| `params`     | Command-specific options. Omit it for commands with no options.                                  |

Pinnacle acknowledges each command with the same `command_id`:

```json
{
  "event": "ack",
  "command_id": "cmd_welcome",
  "action": "audio.play",
  "status": "ok",
  "sequence_number": 12
}
```

### Command catalog

Each command lists required and optional fields. Use **Show example** to reveal a sample payload.

<pre />

### Common command examples

Answer and start the conversation:

```json
{
  "event": "command",
  "command_id": "cmd_answer",
  "action": "call.answer",
  "params": {
    "as": "Support"
  }
}
```

Play text-to-speech:

```json
{
  "event": "command",
  "command_id": "cmd_play_tts",
  "action": "audio.play",
  "params": {
    "text": "Thanks for calling. Please hold while I look that up.",
    "voice": "female",
    "language": "en-US"
  }
}
```

Play a public audio URL:

```json
{
  "event": "command",
  "command_id": "cmd_play_url",
  "action": "audio.play",
  "params": {
    "url": "https://static.pinnacle.sh/examples/hold.wav",
    "loop": 1,
    "overlay": true
  }
}
```

Collect keypad input:

```json
{
  "event": "command",
  "command_id": "cmd_input_digits",
  "action": "input.get",
  "params": {
    "prompt": {
      "text": "Enter your four digit PIN, then press pound."
    },
    "minDigits": 4,
    "maxDigits": 4,
    "terminatingDigit": "#",
    "timeoutMs": 8000
  }
}
```

Collect structured speech:

```json
{
  "event": "command",
  "command_id": "cmd_input_ai",
  "action": "input.get",
  "params": {
    "prompt": {
      "text": "Briefly describe what you need help with."
    },
    "ai": {
      "extract": {
        "type": "object",
        "properties": {
          "intent": {
            "type": "string"
          }
        },
        "required": ["intent"]
      },
      "instructions": "Classify the caller's support intent."
    }
  }
}
```

Update call metadata:

```json
{
  "event": "command",
  "command_id": "cmd_state",
  "action": "call.update_state",
  "params": {
    "metadata": {
      "ticket_id": "tkt_123",
      "handoff": "billing"
    }
  }
}
```

### Audio frames

Use command helpers for most audio playback. Send raw media frames only when your app generates live audio itself.

Outbound media from your app to the call:

```json
{
  "event": "media",
  "media": {
    "track": "outbound",
    "payload": "base64-mulaw-audio",
    "chunk": 1,
    "timestamp": "2026-06-24T00:00:00.000Z"
  }
}
```

Inbound media from Pinnacle to your app:

```json
{
  "event": "media",
  "stream_sid": "stream_123",
  "sequence_number": 42,
  "media": {
    "track": "inbound",
    "payload": "base64-mulaw-audio",
    "chunk": 7,
    "timestamp": "2026-06-24T00:00:01.400Z"
  }
}
```

The stream uses 8 kHz, 8-bit mu-law audio. `payload` is base64-encoded audio bytes. `sequence_number` is monotonic for server frames and should be used for ordering.

Outbound audio is queued and played at real-time speed, so you can send it faster than real time. To interrupt it, for example when the caller starts talking, send the `audio.clear` command: it stops what's playing and drops what's still queued.

### Server events

Pinnacle sends lifecycle and command-result events as `event` frames:

```json
{
  "event": "event",
  "type": "call.answered",
  "stream_sid": "stream_123",
  "sequence_number": 8,
  "payload": {
    "call_id": "call_123"
  }
}
```

Listen for `ack` when you care whether a command was accepted. Listen for `event` for call lifecycle and input results. Listen for `media` for inbound audio.

## Webhooks and recordings

Attach a webhook to a phone number with `event: "CALL.STATUS"` to receive call lifecycle updates. Recording download URLs are available from [`GET /calls/{id}/recording`](/api-reference/calls/recordings/download-call-recording) after the call is terminal and the recording has finished processing.

### Reading recording status

`record` and `recording_state` answer different questions, and you need both to
tell a problem from a non-event:

| `record` | `recording_state` | Meaning                                                                                                           |
| -------- | ----------------- | ----------------------------------------------------------------------------------------------------------------- |
| `false`  | `null`            | No recording was asked for.                                                                                       |
| `true`   | `null`            | Asked for, none yet. While the call is live this is normal; once the call is terminal it means none was produced. |
| `true`   | `PENDING`         | Audio uploaded, still being assembled. `GET .../recording` answers `425` until it is ready.                       |
| `true`   | `READY`           | Downloadable.                                                                                                     |
| `true`   | `FAILED`          | Upload exhausted its retries. The audio is not recoverable.                                                       |
| any      | `DELETED`         | Removed on request or by retention. Deliberate, not lost.                                                         |

Download URLs are short-lived signed credentials. Fetch one when you need it
rather than storing it, and treat it as a secret: anyone holding it can fetch
the audio until it expires.

### Consent and retention

Recording is off unless you ask for it, per call. What we do **not** do is
anything about consent — that stays with you:

* **Obtaining consent.** Some jurisdictions require every participant to
  consent, others only one. We do not detect jurisdiction or verify consent.
* **Announcing the recording.** No notice or beep is played. If one is
  required, play it yourself.
* **Honouring withdrawal mid-call.** `recording.stop` is available, but nothing
  triggers it automatically.

Recordings are retained for **90 days**, after which they are deleted along
with their stored audio and `recording_state` becomes `DELETED`. Deletion runs
on a daily sweep, so treat the deadline as 90 days plus up to a day rather than
exactly 90. You can delete a recording sooner with
[`DELETE /calls/{id}/recording`](/api-reference/calls/recordings/delete-call-recording).

> **Warning**
>
> Voice is not currently supported for HIPAA or other PHI traffic. That needs a
> separate agreement and data-flow review — talk to us before sending it.