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.

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).

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

Want to listen on a phone instead? POST /calls/:id/add_monitor with supervisor_phone dials a supervisor into the call, muted.

ℹ️ Même captation que le relais — 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.

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.