Developer documentation

Install, configure and integrate Phonevoice — REST API, signed webhooks 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/callsList your agents
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

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.

Click-to-call widget

A hosted script that renders a standardized “Call” button next to any contact, so every call entry point in your product looks the same. Load it once, then drop a tagged element wherever you want a button:

<!-- 1. Load the widget once (e.g. in your <head>) -->
<script src="https://api.phonevoice.ai/widget.js" defer></script>

<!-- 2. Drop a button next to any contact -->
<span data-phonevoice-call-button
      data-pv-type="leads"
      data-pv-id="123"
      data-pv-name="Jean Dupont"
      data-pv-phone="+33600000000"
      data-pv-contacted="true"></span>
data-*Meaning
data-pv-typeContact kind — “leads” or “clients”.
data-pv-idYour contact id, handed back to your call flow.
data-pv-name / -phoneDisplay name and number shown on the button.
data-pv-contactedSet to "false" to mask the number on the button.
ℹ️ Wiring the click — On click the button triggers a Stimulus call-modal#open action with the contact params, so your own call modal (which uses embed_session) places the call — the widget only standardizes the UI. Not using Stimulus? The click also emits a bubbling phonevoice:call-requested DOM event (detail: type, id, name, phone) that any framework can listen for. The script re-mounts on Turbo navigations; call window.PhoneVoiceWidget.mount() to mount manually.

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/relay_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/relay_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/relay_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}/relay_numbers/available?country=US&type=local&area_code=415`, { headers });

// 2. Buy + forward to your phone
const num = await fetch(`${API}/relay_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}/relay_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}/relay_numbers/available",
             params={"country": "US", "type": "local", "area_code": "415"}, headers=headers)

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

# 3. Update the destination / pause
requests.patch(f"{API}/relay_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("relay_numbers/available", country: "US", type: "local", area_code: "415")

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

# 3. Update the destination / pause
conn.patch("relay_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 API

GET/api/v1/relay_numbersList your numbers + relay config
GET/api/v1/relay_numbers/availableSearch purchasable numbers (country, type, area_code, contains)
POST/api/v1/relay_numbersBuy a number and provision it as a relay
PATCH/api/v1/relay_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. 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

Drop the hosted widget.js button next to a contact, 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.