Developer documentation

Install, configure and integrate Phonevoice — REST API, signed webhooks, the embeddable call widget and the Relay service.

Getting started

The Phonevoice API is a JSON REST API. You can launch and control calls, buy and configure relay numbers, and manage webhooks. All endpoints live under the same base URL:

https://api.phonevoice.ai/api/v1

Every request must send your token as a Bearer header and (for writes) JSON:

Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

Verify your token with a quick call to /whoami:

curl https://api.phonevoice.ai/api/whoami \
  -H "Authorization: Bearer YOUR_TOKEN"
const res = await fetch("https://api.phonevoice.ai/api/whoami", {
  headers: { Authorization: "Bearer YOUR_TOKEN" }
});
console.log(await res.json());
import requests

r = requests.get(
    "https://api.phonevoice.ai/api/whoami",
    headers={"Authorization": "Bearer YOUR_TOKEN"},
)
print(r.json())
require "net/http"
require "json"

uri = URI("https://api.phonevoice.ai/api/whoami")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)

# => { "status": "ok", "id": 42, "name": "…", "email": "…" }

Integration modes

There are two ways to bring Phonevoice into your product — and you can mix them per call. Embed our UI for the fastest path, or drive the REST API + webhooks when you want to own the design and keep the data in your stack.

Fastest

Embed — hosted UI (iframe)

Drop a signed Phonevoice iframe into your page. POST /calls/:id/embed_session returns a live softphone (roles agent / talk / listen); POST /calls/:id/player_session returns a recording player with the waveform and synced transcript bubbles. Phonevoice owns the design and handles the audio — no media code on your side.

Full control

Native — your own UI (API + webhooks)

Drive everything from the REST API and receive signed webhooks. Each phone_call.completed event carries the transcription, the AI analysis summary and the recording_url, so you render your own screens and keep the data in your stack. Stream the transcript live over WebSocket while the call is still running.

What you get Embed (iframe) Native (API + webhooks)
Who owns the UI designPhonevoiceYou
Setup effortPaste an iframeHandle webhooks
Audio playbackBuilt inFrom recording_url
Where call data livesPhonevoice-hostedYour stack

Mix freely per call: a CRM can embed the player when a recording exists and fall back to its own transcript bubbles (built from the webhook transcription) when it doesn’t — same call, both modes.

Authentication

Authenticate with a Bearer token. There are two kinds — use a Project token for server-to-server integrations:

Project API token (recommended)

Scoped to a single project — its agents, numbers and webhooks are isolated from your other projects. Find it in the dashboard under your project → “API Configuration”. Treat it like a password: server-side only, never in client code.

User token

Issued to a user account by the sign-up flow (POST /api/signup → POST /api/confirm returns a user_token). Useful for the mobile app and personal access.

A token may be passed as the Authorization header (preferred) or as an ?api_token= / ?user_token= query parameter.

🔒 Building a browser or embedded UI? Never ship your Project or User token to the client. Mint a short-lived signed URL server-side with /calls/:id/embed_session or /calls/:id/player_session and hand only that to the browser — it expires in minutes and grants nothing else.

Errors & status codes

Errors return a JSON body with an "error" message and a matching HTTP status.

CodeMeaning
200 / 201OK / created
401Missing or invalid token
403Authenticated but you do not own this resource
404Not found
422Validation error (see "error")

Calls API

Launch an outbound AI-agent call (or a bridge between two humans), then control it.

POST/api/v1/calls/initiateStart an outbound call
GET/api/v1/agentsList your agents (use an id as agent_id in /calls/initiate)
POST/api/v1/calls/:id/hangupHang up a call
POST/api/v1/calls/:id/add_monitorDial a supervisor in, muted (live-listen)
POST/api/v1/calls/:id/embed_sessionSigned iframe URL for browser softphone/listen
POST/api/v1/calls/:id/player_sessionSigned iframe URL to replay the recording + transcript
GET/api/v1/calls/:id/costsEUR cost & price breakdown for a call

Example — start a call with one of your agents:

curl -X POST https://api.phonevoice.ai/api/v1/calls/initiate \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": 1533,
    "recipient_phone_number": "+33612345678",
    "inbound_phone_number": "+33159133354",
    "enable_recording": true,
    "enable_transcription": true,
    "enable_analysis": true
  }'
const res = await fetch("https://api.phonevoice.ai/api/v1/calls/initiate", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    agent_id: 1533,
    recipient_phone_number: "+33612345678",
    inbound_phone_number: "+33159133354",
    enable_recording: true,
    enable_transcription: true,
    enable_analysis: true,
  }),
});
const call = await res.json();
console.log(call.phone_call_id);
import requests

res = requests.post(
    "https://api.phonevoice.ai/api/v1/calls/initiate",
    headers={"Authorization": "Bearer YOUR_TOKEN"},
    json={
        "agent_id": 1533,
        "recipient_phone_number": "+33612345678",
        "inbound_phone_number": "+33159133354",
        "enable_recording": True,
        "enable_transcription": True,
        "enable_analysis": True,
    },
)
print(res.json()["phone_call_id"])
require "net/http"
require "json"

uri = URI("https://api.phonevoice.ai/api/v1/calls/initiate")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Content-Type"] = "application/json"
req.body = {
  agent_id: 1533,
  recipient_phone_number: "+33612345678",
  inbound_phone_number: "+33159133354",
  enable_recording: true,
  enable_transcription: true,
  enable_analysis: true,
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["phone_call_id"]

# => 201 { "status":"success", "phone_call_id": 90210, "telephony_ref":"CA…", "cable_token":"…" }

Configure where results go with Webhooks (below) — webhooks are managed separately, not passed per call. cable_token lets you stream the live transcript (see Live transcription).

Direct calls (Bridge) & browser softphone

Let a human agent (a "vendeur") talk to a client directly — recorded, transcribed and summarized like a relay call, but agent-initiated. Two modes, both via /calls/initiate with call_type=bridge:

🎧 Browser softphone

The agent speaks from the browser — no phone leg. Send vendeur_mode:"browser", then embed the signed softphone iframe. The client is dialed once the agent connects.

📞 Phone bridge

Phonevoice rings the agent line first (inbound_phone_number), then bridges the client. No browser needed.

1 — Start the call (browser mode shown; for a phone bridge omit vendeur_mode and pass inbound_phone_number = the agent line):

curl -X POST https://api.phonevoice.ai/api/v1/calls/initiate \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{ "call_type":"bridge", "vendeur_mode":"browser",
        "recipient_phone_number":"+33612345678" }'
# => 201 { "phone_call_id": 90210, "cable_token": "…" }
const API = "https://api.phonevoice.ai/api/v1";
const headers = { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json" };

// Start a bridge call (browser mode)
const { phone_call_id, cable_token } = await fetch(`${API}/calls/initiate`, {
  method: "POST", headers,
  body: JSON.stringify({ call_type: "bridge", vendeur_mode: "browser", recipient_phone_number: "+33612345678" }),
}).then(r => r.json());
import requests
API = "https://api.phonevoice.ai/api/v1"
headers = {"Authorization": "Bearer YOUR_TOKEN"}

# Start a bridge call (browser mode)
call = requests.post(f"{API}/calls/initiate", headers=headers, json={
    "call_type": "bridge", "vendeur_mode": "browser",
    "recipient_phone_number": "+33612345678",
}).json()
# call["phone_call_id"], call["cable_token"]
require "faraday"
require "json"

conn = Faraday.new("https://api.phonevoice.ai/api/v1") do |f|
  f.headers["Authorization"] = "Bearer YOUR_TOKEN"
  f.headers["Content-Type"] = "application/json"
end

call = JSON.parse(conn.post("calls/initiate",
  { call_type: "bridge", vendeur_mode: "browser", recipient_phone_number: "+33612345678" }.to_json).body)
# call["phone_call_id"], call["cable_token"]

2 — Browser mode: get a signed softphone iframe and embed it (role agent = the speaking vendeur).

curl -X POST https://api.phonevoice.ai/api/v1/calls/90210/embed_session \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{ "role":"agent" }'
# => { "embed_url":"https://api.phonevoice.ai/embed?t=…", "role":"agent", "expires_in":300 }
const { embed_url } = await fetch("https://api.phonevoice.ai/api/v1/calls/90210/embed_session", {
  method: "POST",
  headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json" },
  body: JSON.stringify({ role: "agent" }),
}).then(r => r.json());

const iframe = Object.assign(document.createElement("iframe"), { src: embed_url, allow: "microphone; autoplay" });
document.body.appendChild(iframe);
import requests
session = requests.post("https://api.phonevoice.ai/api/v1/calls/90210/embed_session",
    headers={"Authorization": "Bearer YOUR_TOKEN"}, json={"role": "agent"}).json()
print(session["embed_url"])  # hand this URL to a browser to embed
require "faraday"
require "json"

res = Faraday.post("https://api.phonevoice.ai/api/v1/calls/90210/embed_session",
  { role: "agent" }.to_json,
  { "Authorization" => "Bearer YOUR_TOKEN", "Content-Type" => "application/json" })
puts JSON.parse(res.body)["embed_url"]  # hand this URL to a browser to embed
<iframe src="EMBED_URL" allow="microphone; autoplay"></iframe>
roleUse
agentThe vendeur softphone — mic on; starts, records and ends the call; dials the client.
talkMic on, extra participant — does not control the call.
listenMuted live-listen, for a supervisor.

Live monitoring. A supervisor can listen to a call in progress two ways: silently in the browser with an embed_session of role=listen (muted), or from a phone — POST /calls/:id/add_monitor with supervisor_phone dials them into the live call, muted.

ℹ️ Same capture as relay — bridge calls are recorded (dual-channel), transcribed live then re-timed with diarization post-call, and summarized. You receive phone_call.started / phone_call.completed (the full transcript is in the completed payload — no per-message transcript event). The embedded recording player also works here via /calls/:id/player_session.

Call widget

One line of JavaScript turns every phone number on your site into a Phonevoice call button. The widget decorates every tel: link (a MutationObserver keeps up with SPAs and CRMs), mounts explicit buttons where you place them, and opens a self-contained call modal — browser call, “we call you” bridge, or your AI agent — with live status and duration, plus the transcript, AI summary and price when your key enables them.

Quick start — create a key in the dashboard, allow your site’s domains, then paste one line before the closing body tag:

<script src="https://phonevoice.ai/widget.js"
        data-key="pk_live_YOUR_KEY" defer></script>

Modes, features and limits live server-side on the key: change them in the dashboard and every page embedding the widget follows instantly — nothing to redeploy. Manage your widget keys → Try the live demo →

Script attributeMeaning
data-keyRequired. Your publishable key (pk_live_…) — safe in public pages, see Security below.
data-lang"en" or "fr". Defaults to the page (then browser) language.
data-modesComma list to further restrict modes client-side, e.g. "browser,bridge". The key stays the source of truth.
data-tel-linksSet to "off" to disable tel: auto-detection (explicit buttons only).
data-apiAPI origin override — for testing against a staging backend.

Explicit buttons (no tel: link needed)

Wherever there is no tel: link — CRM records, lead lists — drop a placeholder element and the widget mounts a call button in it:

<span data-phonevoice-call-button
      data-pv-phone="+33159133354"
      data-pv-name="Jean Dupont"></span>
data-*Meaning
data-pv-phoneThe number to call (E.164 preferred).
data-pv-nameContact name shown in the modal and attached to the call.
data-pv-contactedSet to "false" to mask the number on the button (shows “Hidden number”).
data-pv-type / data-pv-idOptional metadata about your contact, echoed in the phonevoice:call-requested DOM event.

JS API: every click emits a bubbling phonevoice:call-requested event (detail: type, id, name, phone); window.PhoneVoiceWidget.mount() rescans the page (Turbo navigations are handled automatically) and window.PhoneVoiceWidget.open(phone, name) opens the call modal programmatically.

Publishable keys & security

A pk_live key is designed to ship inside public pages. Unlike a Bearer token it cannot read calls, transcripts or balances — it can only mint call sessions that still satisfy every guard you set on the key:

Domain allow-list

Sessions are only minted when the request’s Origin/Referer matches your list (*.example.com supported). A key with no domains is inert.

Destination countries

Anti-fraud: the platform refuses to dial any number outside your country allow-list (default: your own country). In bridge mode the visitor-phone field is off by default.

Caps & rate limits

Per-visitor-IP rate limit (default 5 calls/hour) and a daily spend cap (default €50) — when reached, the widget stops placing calls until the next day.

Modes & revocation

Only the modes you tick (browser / bridge / AI agent) are offered. Revoke or delete the key at any time — embedded widgets stop offering calls immediately.

🔒 Never put a Project or User token in a page — those are secrets (see Authentication). The widget only ever needs the publishable key.

Pricing

Widget calls are ordinary Phonevoice calls, billed to the key owner at the standard rates: browser and “we call you” calls at the Click-to-call (bridge) rate, AI-agent calls like any agent call. The visitor sees the price only if “Show price” is enabled on the key.

See the per-minute rates by country →

Under the hood the widget speaks to three public endpoints — authenticated by the publishable key and the page’s Origin, never by a secret:

GET/api/widget/configThe key’s public config (modes, features, prefilled objective)
POST/api/widget/sessionsMint a call session (returns a short-lived session token)
GET/api/widget/calls/:idStatus, duration, live transcript — scoped by the session token

Relay service

A relay turns a real phone number into a forwarding line. Inbound calls forward to a personal number while Phonevoice records, transcribes, summarizes and fires webhooks — no AI agent and no app required. See the Relay overview for the product story; this is the integration recipe.

  1. 1 — Find & buy a number

    Search available numbers, then buy one and set where it should forward.

  2. 2 — Configure forwarding

    Change the destination or pause the relay at any time with PATCH.

  3. 3 — Receive enriched events

    Inbound calls ring your phone (caller ID = the relay number); when they end, a phone_call.completed webhook carries the recording, transcript and AI summary.

# 1. Search available US numbers
curl "https://api.phonevoice.ai/api/v1/numbers/available?country=US&type=local&area_code=415" \
  -H "Authorization: Bearer YOUR_TOKEN"

# 2. Buy one and forward it to your phone
curl -X POST https://api.phonevoice.ai/api/v1/numbers \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{ "phone_number":"+14155550123", "forward_to":"+33612345678", "country":"US" }'

# 3. Later — change the destination or pause it
curl -X PATCH https://api.phonevoice.ai/api/v1/numbers/123 \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{ "forward_to":"+33700000000", "relay_enabled": true }'
const API = "https://api.phonevoice.ai/api/v1";
const headers = { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json" };

// 1. Search available numbers
const avail = await fetch(`${API}/numbers/available?country=US&type=local&area_code=415`, { headers });

// 2. Buy + forward to your phone
const num = await fetch(`${API}/numbers`, {
  method: "POST", headers,
  body: JSON.stringify({ phone_number: "+14155550123", forward_to: "+33612345678", country: "US" }),
}).then(r => r.json());

// 3. Update the destination / pause
await fetch(`${API}/numbers/${num.id}`, {
  method: "PATCH", headers,
  body: JSON.stringify({ forward_to: "+33700000000", relay_enabled: true }),
});
import requests

API = "https://api.phonevoice.ai/api/v1"
headers = {"Authorization": "Bearer YOUR_TOKEN"}

# 1. Search available numbers
requests.get(f"{API}/numbers/available",
             params={"country": "US", "type": "local", "area_code": "415"}, headers=headers)

# 2. Buy + forward to your phone
num = requests.post(f"{API}/numbers", headers=headers,
    json={"phone_number": "+14155550123", "forward_to": "+33612345678", "country": "US"}).json()

# 3. Update the destination / pause
requests.patch(f"{API}/numbers/{num['id']}", headers=headers,
    json={"forward_to": "+33700000000", "relay_enabled": True})
require "faraday"
require "json"

conn = Faraday.new("https://api.phonevoice.ai/api/v1") do |f|
  f.headers["Authorization"] = "Bearer YOUR_TOKEN"
  f.headers["Content-Type"] = "application/json"
end

# 1. Search available numbers
conn.get("numbers/available", country: "US", type: "local", area_code: "415")

# 2. Buy + forward to your phone
num = JSON.parse(conn.post("numbers",
  { phone_number: "+14155550123", forward_to: "+33612345678", country: "US" }.to_json).body)

# 3. Update the destination / pause
conn.patch("numbers/#{num['id']}",
  { forward_to: "+33700000000", relay_enabled: true }.to_json)
SMS forwarding — inbound texts are forwarded and fire an sms.received webhook, but only on SMS-capable numbers. FR landline/geographic numbers are typically NOT SMS-capable; voice relay works on all.
Progressive enrichment — phone_call.completed may fire several times: first on hang-up, again once the transcript is ready, again after the AI summary. Treat the latest payload as authoritative (match on data.id).

Numbers

GET/api/v1/numbersList your numbers + relay config
GET/api/v1/numbers/availableSearch purchasable numbers (country, type, area_code, contains)
POST/api/v1/numbersBuy a number (set forward_to to use it as a relay)
PATCH/api/v1/numbers/:idSet forward_to / toggle relay_enabled

Webhooks

Register endpoints to receive call events. Webhooks are scoped to your project. The secret is shown once, on creation — store it to verify signatures.

GET/api/v1/webhooksList your webhooks
POST/api/v1/webhooksCreate a webhook (returns the secret once)
PATCH/api/v1/webhooks/:idUpdate url / name / events / active
DELETE/api/v1/webhooks/:idDelete a webhook
GET/api/v1/webhooks/:id/callsDelivery log (last 50 attempts)
curl -X POST https://api.phonevoice.ai/api/v1/webhooks \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{ "url":"https://example.com/hooks/phonevoice",
        "events":["phone_call.completed","sms.received"], "name":"My CRM" }'
const res = await fetch("https://api.phonevoice.ai/api/v1/webhooks", {
  method: "POST",
  headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json" },
  body: JSON.stringify({
    url: "https://example.com/hooks/phonevoice",
    events: ["phone_call.completed", "sms.received"],
    name: "My CRM",
  }),
});
const { secret } = await res.json(); // store this — shown only once
import requests

res = requests.post(
    "https://api.phonevoice.ai/api/v1/webhooks",
    headers={"Authorization": "Bearer YOUR_TOKEN"},
    json={
        "url": "https://example.com/hooks/phonevoice",
        "events": ["phone_call.completed", "sms.received"],
        "name": "My CRM",
    },
)
secret = res.json()["secret"]  # store this — shown only once
require "net/http"
require "json"

uri = URI("https://api.phonevoice.ai/api/v1/webhooks")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Content-Type"] = "application/json"
req.body = {
  url: "https://example.com/hooks/phonevoice",
  events: ["phone_call.completed", "sms.received"],
  name: "My CRM",
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
secret = JSON.parse(res.body)["secret"] # store this — shown only once

# => 201 { "id":35, "url":"…", "events":[…], "secret":"whsec_…" } ← store the secret

Omit "events" (or use ["*"]) to receive every event. Each delivery is a POST with this envelope:

{
  "id": "evt_… (unique per delivery)",
  "type": "phone_call.completed",
  "created_at": "2026-06-04T09:23:56Z",
  "data": { … }   // event-specific (see below)
}

Webhook events

typeFires when
phone_call.startedThe call connects (in progress).
phone_call.completedThe call ends — payload status is "completed", or "stopped" when hung up via the API/console (equally final). Re-fired as the transcript and AI summary become available.
phone_call.<status>Other transitions: failed, no-answer, busy, error…
phone_call.transcriptEach transcription message, in real time (AI-agent calls).
sms.receivedAn inbound SMS hits a relay-enabled number.

data for phone_call.* events:

{
  "id": 90210, "status": "completed", "duration": 50, "cost": 0.12,
  "direction": "inbound", "from": "+33783484044", "to": "+33612345678",
  "relay_number": "+33159133354",
  "recording_url": "https://…/bridge_90210.mp3",
  "transcription": "[Caller] Hello…\n[Callee] Hello, …",
  "analysis": { "summary": "…", "status": "fulfilled", "interest_level": 7 }
}

data for sms.received:

{ "direction":"inbound", "from":"+33783484044", "to":"+33159133354",
  "relay_number":"+33159133354", "body":"Hello!" }

Verifying webhook signatures

Every delivery is signed. The Phonevoice-Signature header is t=<unix_timestamp>,v1=<hex>, where v1 is an HMAC-SHA256 of "{timestamp}.{raw_body}" keyed with your webhook secret. Recompute it and compare in constant time.

// Node.js (Express) — verify before trusting the payload
const crypto = require("crypto");

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  const expected = crypto.createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
# Python (Flask) — verify before trusting the payload
import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=") for p in header.split(","))
    expected = hmac.new(secret.encode(),
                        f"{parts['t']}.{raw_body.decode()}".encode(),
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
# Ruby (Rails) — verify before trusting the payload
require "openssl"

def verify(raw_body, header, secret)
  parts = header.split(",").map { |p| p.split("=") }.to_h
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts['t']}.#{raw_body}")
  Rack::Utils.secure_compare(expected, parts["v1"])
end

Live transcription (ActionCable)

For a real-time transcript during the call, subscribe to the call channel over WebSocket using the cable_token returned by /calls/initiate (and emitted for relay calls). Messages stream as the conversation happens.

wss://api.phonevoice.ai/cable
→ subscribe { channel: "CallsChannel", id: 90210, token: "<cable_token>" }
← { type: "transcript", role: "user", content: "…" }
← { type: "status_changed", status: "completed" }
// Browser — subscribe to the live transcript over ActionCable
const cable = new WebSocket("wss://api.phonevoice.ai/cable");
const identifier = JSON.stringify({ channel: "CallsChannel", id: 90210, token: "CABLE_TOKEN" });

cable.onopen = () => cable.send(JSON.stringify({ command: "subscribe", identifier }));

cable.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (!msg.message) return;            // welcome / ping / confirm_subscription
  const m = msg.message;
  if (m.type === "transcript") console.log(`${m.role}: ${m.content}`);
  if (m.type === "status_changed") console.log("status:", m.status);
};
# pip install websocket-client
import json, websocket

ws = websocket.create_connection("wss://api.phonevoice.ai/cable")
identifier = json.dumps({"channel": "CallsChannel", "id": 90210, "token": "CABLE_TOKEN"})
ws.send(json.dumps({"command": "subscribe", "identifier": identifier}))

while True:
    m = json.loads(ws.recv()).get("message")
    if not m:                          # welcome / ping / confirm_subscription
        continue
    if m["type"] == "transcript":
        print(m["role"], ":", m["content"])
    elif m["type"] == "status_changed":
        print("status:", m["status"])

Prefer a drop-in UI? Call /calls/:id/player_session to get a signed, embeddable iframe with the waveform + synced transcript bubbles — no audio handling on your side.

Recipes

Common end-to-end flows, each wiring together the endpoints above. Pick the one that matches your use case.

Place an AI-agent call, get the result

POST /calls/initiate with an agent_id, then handle the phone_call.completed webhook — it carries the transcription, the AI summary and the recording_url.

Add a click-to-call button

Paste the one-line call widget with a publishable key — every tel: link becomes a call button — or roll your own with a bridge call (call_type=bridge) and the embedded softphone.

Embed the recording + transcript

POST /calls/:id/player_session for a signed iframe with the waveform and synced transcript bubbles — no audio handling on your side.

Listen to a call live

Embed an embed_session of role=listen for a silent in-browser supervisor, or POST /calls/:id/add_monitor with supervisor_phone to dial one in by phone.

Stream the live transcript

Take the cable_token from /calls/initiate and subscribe to the CallsChannel over WebSocket to receive transcript messages as the call happens.

Forward a real number, capture everything

Buy a relay number, point your existing line at it, and receive the recording, transcript, summary and SMS on your webhooks — no app and no AI agent required.