Documentation développeur
Installez, configurez et intégrez Phonevoice — API REST, webhooks signés et service Relais.
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": "…" }
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.
Errors & status codes
Errors return a JSON body with an "error" message and a matching HTTP status.
| Code | Meaning |
|---|---|
| 200 / 201 | OK / created |
| 401 | Missing or invalid token |
| 403 | Authenticated but you do not own this resource |
| 404 | Not found |
| 422 | Validation error (see "error") |
Calls API
Launch an outbound AI-agent call (or a bridge between two humans), then control it.
| POST | /api/v1/calls/initiate | Start an outbound call |
| GET | /api/v1/calls | List your agents |
| POST | /api/v1/calls/:id/hangup | Hang up a call |
| POST | /api/v1/calls/:id/add_monitor | Dial a supervisor in, muted (live-listen) |
| POST | /api/v1/calls/:id/embed_session | Signed iframe URL for browser softphone/listen |
| POST | /api/v1/calls/:id/player_session | Signed 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).
Appel Direct (Bridge) & softphone navigateur
Faites parler un agent humain (« vendeur ») directement avec un client — enregistré, transcrit et résumé comme un appel relais, mais à l'initiative de l'agent. Deux modes, tous deux via /calls/initiate avec call_type=bridge :
🎧 Softphone navigateur
L'agent parle depuis le navigateur — aucune ligne téléphonique. Envoyez vendeur_mode:"browser", puis intégrez l'iframe softphone signée. Le client est appelé dès que l'agent se connecte.
📞 Bridge téléphone
Phonevoice appelle d'abord la ligne de l'agent (inbound_phone_number), puis met le client en relation. Sans navigateur.
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>
| role | Use |
|---|---|
| agent | The vendeur softphone — mic on; starts, records and ends the call; dials the client. |
| talk | Mic on, extra participant — does not control the call. |
| listen | Muted live-listen, for a supervisor. |
Want to listen on a phone instead? POST /calls/:id/add_monitor with supervisor_phone dials a supervisor into the call, muted.
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 — Find & buy a number
Search available numbers, then buy one and set where it should forward.
2 — Configure forwarding
Change the destination or pause the relay at any time with PATCH.
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)
Numbers API
| GET | /api/v1/relay_numbers | List your numbers + relay config |
| GET | /api/v1/relay_numbers/available | Search purchasable numbers (country, type, area_code, contains) |
| POST | /api/v1/relay_numbers | Buy a number and provision it as a relay |
| PATCH | /api/v1/relay_numbers/:id | Set 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/webhooks | List your webhooks |
| POST | /api/v1/webhooks | Create a webhook (returns the secret once) |
| PATCH | /api/v1/webhooks/:id | Update url / name / events / active |
| DELETE | /api/v1/webhooks/:id | Delete a webhook |
| GET | /api/v1/webhooks/:id/calls | Delivery 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
| type | Fires when |
|---|---|
| phone_call.started | The call connects (in progress). |
| phone_call.completed | The call ends. Re-fired as the transcript and AI summary become available. |
| phone_call.<status> | Other transitions: failed, no-answer, busy, error… |
| phone_call.transcript | Each transcription message, in real time (AI-agent calls). |
| sms.received | An 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.