Voice Calls
Voice has two surfaces:
- REST endpoints under
/callsfor call lifecycle, stream tokens, events, and recordings. - A bidirectional WebSocket from
POST /calls/{id}/stream-tokenfor live audio and commands.
For calls with more than two people, see Conference Calls.
Start an outbound call
TypeScript
Python
Ruby
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.
TypeScript
Python
Ruby
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
cURL
Reusing the key returns the original call rather than placing another:
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
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:
Status values:
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:
Pinnacle acknowledges each command with the same command_id:
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:
Play text-to-speech:
Play a public audio URL:
Collect keypad input:
Collect structured speech:
Update call metadata:
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:
Inbound media from Pinnacle to your app:
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:
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:
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.stopis 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.

