Skip to navigation

Voice Calls

Voice has two surfaces:

  • REST endpoints under /calls for call lifecycle, stream tokens, events, and recordings.
  • A bidirectional WebSocket from POST /calls/{id}/stream-token for live audio and commands.

For calls with more than two people, see Conference Calls.

Start an outbound call

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?" });

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.

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.

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,
});

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.

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

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

SituationResponse
First use of the key201 with the new call
Same key, same request200 with the original call — nothing was dialled
Same key, different request422 — the key is bound to its first request
Same key, first attempt still running409 — 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.

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

CallPrice
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 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:

ValueMeaning
INBOUNDThe call originated from an external caller and was received by one of your Pinnacle voice-enabled phone numbers.
OUTBOUNDThe call was created from Pinnacle and dialed out from one of your voice-enabled phone numbers.

Status values:

ValueMeaning
INITIATEDPinnacle accepted the call request and is setting up the call with the voice provider.
RINGINGThe call is ringing and has not been answered yet.
ANSWEREDThe call has been answered and is active.
BRIDGEDThe call is connected to another call leg, such as after a transfer or bridge command.
ENDEDThe call ended normally.
FAILEDThe call could not be established or could not continue because of a provider or system failure.
NO_ANSWERThe call rang until timeout without being answered.
BUSYThe destination was busy.
CANCELEDThe 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:

{
"event": "command",
"command_id": "cmd_welcome",
"action": "audio.play",
"params": {
"text": "Thanks for calling. How can I help?",
"voice": "female",
"language": "en-US"
}
}
FieldMeaning
eventAlways command for commands sent by your app.
command_idYour unique ID for matching the later ack. SDK helpers generate this when you do not pass one.
actionThe command to run. See the catalog below.
paramsCommand-specific options. Omit it for commands with no options.

Pinnacle acknowledges each command with the same command_id:

{
"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.

[
  {
    "title": "Call control",
    "description": "Answer, end, transfer, and annotate the active call.",
    "commands": [
      {
        "action": "call.answer",
        "description": "Answer an inbound call or confirm an outbound call is ready for interaction.",
        "optional": ["as"],
        "params": {
          "as": "Support"
        }
      },
      {
        "action": "call.end",
        "description": "End an active call or reject a pre-answer call.",
        "optional": [
          {
            "name": "cause",
            "values": ["user_busy", "call_rejected"]
          }
        ],
        "params": {
          "cause": "call_rejected"
        }
      },
      {
        "action": "call.transfer",
        "description": "Dial and bridge a new number, or bridge to another Pinnacle call.",
        "params": [
          {
            "label": "Dial a new number",
            "required": ["to"],
            "optional": ["from", "as", "custom_headers"],
            "payload": {
              "to": "+14155551234",
              "from": "+14155559876",
              "as": "Support",
              "custom_headers": {
                "X-Customer-ID": "cus_123"
              }
            }
          },
          {
            "label": "Bridge an existing call",
            "required": [
              {
                "name": "call_id",
                "note": "Must be a Pinnacle call ID, not a provider call-control ID."
              }
            ],
            "optional": ["as"],
            "payload": {
              "call_id": "call_123",
              "as": "Support"
            }
          }
        ]
      },
      {
        "action": "call.update_state",
        "description": "Attach metadata that appears on future call events.",
        "required": ["metadata"],
        "params": {
          "metadata": {
            "ticket_id": "tkt_123"
          }
        }
      }
    ]
  },
  {
    "title": "Audio",
    "description": "Play audio, stop playback, and tune the live audio stream.",
    "commands": [
      {
        "action": "audio.play",
        "description": "Play TTS or a public audio URL into the call.",
        "params": [
          {
            "label": "Text-to-speech",
            "required": ["text"],
            "optional": [
              {
                "name": "voice",
                "valueLabel": "examples",
                "values": ["female", "alice"]
              },
              {
                "name": "language",
                "valueLabel": "examples",
                "values": ["en-US"]
              }
            ],
            "payload": {
              "text": "Thanks for calling.",
              "voice": "female",
              "language": "en-US"
            }
          },
          {
            "label": "URL playback",
            "required": [
              {
                "name": "url",
                "note": "Must be a public http or https URL without embedded credentials or private-network hosts."
              }
            ],
            "optional": ["loop", "overlay"],
            "payload": {
              "url": "https://static.pinnacle.sh/examples/hold.wav",
              "loop": 1,
              "overlay": true
            }
          }
        ]
      },
      {
        "action": "audio.stop",
        "description": "Stop active URL playback or TTS.",
        "params": ""
      },
      {
        "action": "audio.clear",
        "description": "Stop audio your app has streamed over the WebSocket and drop what's still queued. Use it for barge-in: audio you send is buffered, so stopping sending doesn't stop the caller hearing it.",
        "params": ""
      },
      {
        "action": "audio.reduce_noise",
        "description": "Enable or disable noise reduction for inbound, outbound, or both tracks.",
        "required": ["enabled"],
        "optional": [
          {
            "name": "engine",
            "values": ["default", "krisp", "aicoustics"]
          },
          {
            "name": "direction",
            "values": ["inbound", "outbound", "both"]
          }
        ],
        "params": {
          "enabled": true,
          "engine": "krisp",
          "direction": "both"
        }
      }
    ]
  },
  {
    "title": "Input",
    "description": "Collect keypad digits, spoken answers, and send DTMF tones.",
    "commands": [
      {
        "action": "input.get",
        "description": "Gather digits, speech, or structured AI extraction after an optional prompt.",
        "optional": [
          "prompt",
          "minDigits",
          "maxDigits",
          {
            "name": "validDigits",
            "valueLabel": "example",
            "values": ["12345"]
          },
          {
            "name": "terminatingDigit",
            "valueLabel": "example",
            "values": ["#"]
          },
          "timeoutMs",
          "interDigitTimeoutMs",
          "ai"
        ],
        "params": {
          "prompt": {
            "text": "Enter your PIN.",
            "voice": "female",
            "language": "en-US"
          },
          "minDigits": 4,
          "maxDigits": 4,
          "validDigits": "0123456789",
          "terminatingDigit": "#",
          "timeoutMs": 10000,
          "interDigitTimeoutMs": 3000,
          "ai": {
            "extract": {
              "type": "object",
              "properties": {
                "pin": {
                  "type": "string"
                }
              },
              "required": ["pin"]
            },
            "instructions": "Extract the caller's PIN."
          }
        }
      },
      {
        "action": "input.cancel",
        "description": "Cancel the active input gathering request.",
        "params": ""
      },
      {
        "action": "dtmf.send",
        "description": "Send keypad tones to the far end of the call.",
        "required": [
          {
            "name": "digits",
            "values": ["0-9", "*", "#", "A-D"]
          }
        ],
        "optional": ["duration_ms"],
        "params": {
          "digits": "1234#",
          "duration_ms": 250
        }
      }
    ]
  },
  {
    "title": "Recording",
    "description": "Control Pinnacle-managed recording during the call.",
    "commands": [
      {
        "action": "recording.start",
        "description": "Start recording future audio frames.",
        "params": ""
      },
      {
        "action": "recording.stop",
        "description": "Stop future capture and flush the current recording segment.",
        "params": ""
      }
    ]
  },
  {
    "title": "Conferences",
    "description": "Add people to a conference with this call in it, and change or remove them. See the Conference Calls guide.",
    "commands": [
      {
        "action": "conference.add_participant",
        "description": "Dial someone (or pull in another of your calls). Without conference_id, uses this call's conference, creating one with this call in it. from defaults to your number on this call.",
        "params": [
          {
            "label": "Dial someone",
            "required": ["to"],
            "optional": ["from", "label", "muted", "on_hold", "whisper_to", "announce", "ring_timeout_secs", "end_conference_on_leave", "record", "metadata", "conference_id"],
            "payload": {
              "to": "+14155551234",
              "label": "supervisor",
              "muted": true,
              "announce": { "text": "You're joining a billing call. You're muted." }
            }
          },
          {
            "label": "Pull in an existing call",
            "required": ["call_id"],
            "optional": ["label", "muted", "on_hold", "whisper_to", "end_conference_on_leave", "conference_id"],
            "payload": {
              "call_id": "call_7cQe4F8xT1yB2mN9pK3L",
              "label": "caller"
            }
          }
        ]
      },
      {
        "action": "conference.update_participant",
        "description": "Mute, hold, whisper, or relabel a participant. participant is a call ID, a label, or self (this call).",
        "required": ["participant"],
        "optional": ["muted", "on_hold", "whisper_to", "label"],
        "params": {
          "participant": "supervisor",
          "muted": false,
          "whisper_to": ["specialist"]
        }
      },
      {
        "action": "conference.remove_participant",
        "description": "Hang up a participant's call; everyone else stays.",
        "required": ["participant"],
        "params": {
          "participant": "specialist"
        }
      },
      {
        "action": "conference.leave",
        "description": "This call leaves the conference (hangs up).",
        "params": ""
      },
      {
        "action": "conference.get",
        "description": "Get this call's conference and its participants in the ack.",
        "params": ""
      },
      {
        "action": "conference.end",
        "description": "End this call's conference and hang up everyone.",
        "params": ""
      }
    ]
  }
]

Common command examples

Answer and start the conversation:

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

Play text-to-speech:

{
"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:

{
"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:

{
"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:

{
"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:

{
"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:

{
"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:

{
"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:

{
"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 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:

recordrecording_stateMeaning
falsenullNo recording was asked for.
truenullAsked for, none yet. While the call is live this is normal; once the call is terminal it means none was produced.
truePENDINGAudio uploaded, still being assembled. GET .../recording answers 425 until it is ready.
trueREADYDownloadable.
trueFAILEDUpload exhausted its retries. The audio is not recoverable.
anyDELETEDRemoved 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.

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.

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.