# Voice Infrastructure Platform — documentation Add calling to your CRM through one REST API. We run the telephony (FreeSWITCH, call control, recordings, webhooks); you bring your own carrier account (Telnyx, Twilio or any SIP trunk) and keep paying the carrier directly. Switching carriers is a configuration change: nothing in your integration refers to a carrier. ## What you can build - **Softphone in the CRM** — browser softphone (`@voice/softphone`) with short-lived credentials. - **Click-to-call** — the agent's phone rings, then the customer is dialed and connected. - **Inbound routing** — to an agent, a ring group, voicemail, or decided by your CRM per call. - **Call log** — every call with CDR, full event timeline, recordings (signed URLs), CSV export. - **Numbers** — search, order, regulatory documents, release — where the carrier supports it. - **Events** — signed webhooks with retries and replay, plus a live SSE/WebSocket stream. ## Concepts | Concept | What it is | |---|---| | Workspace | Your account: limits (agents, concurrency, destinations, spend guard) and settings (recording). | | Carrier | A connected carrier account. Has `capabilities` (what its API supports) and `health`. | | Number | A DID owned by one carrier. Outbound calls use the carrier of the caller ID number. | | Agent | A SIP account `agent_{agent_ref}` for one CRM user; browsers get expiring credentials. | | Inbound rule | Where calls to a number go. Ring groups ring several agents. | | Call | One conversation with a timeline of events; ends with a CDR (`call.ended`). | | Event | Normalized notification (`call.answered`, `recording.ready`, …) delivered by webhook/stream. | ## Start here 1. [Quickstart (sandbox)](quickstart.md) — everything runs locally with a simulated carrier. 2. [Integration guide for AI agents](../INTEGRATION_GUIDE_FOR_AI_AGENTS.md) — the ordered checklist. 3. [API conventions](api-conventions.md), [recipes](recipes/), [events](events.md), [errors](errors.md). 4. Reference: `/openapi.yaml` (OpenAPI 3.1 with examples), `/llms.txt`, `/llms-full.txt`. Operator documentation (not needed to integrate): [operations](operations.md), [production deployment](deploy-production.md), [security checklist](security.md), [load test](load-test-report.md), [adding a carrier](adding-a-carrier.md), [FreeSWITCH internals](freeswitch.md). --- # Quickstart (sandbox) The sandbox is the full platform with a **simulated carrier**: a second FreeSWITCH acting as the phone network (every number answers and echoes your audio; numbers ending in `486` are busy, `480` unavailable, `404` unallocated) and fake carrier APIs for ordering numbers. No real calls, no costs. ## 1. Start it ```bash git clone && cd voice-platform ./scripts/dev-env.sh # creates .env with random secrets docker compose up -d --build # API :3000, dashboard :3002 ./scripts/smoke.sh # waits until every service is healthy ``` ## 2. Get an API key ```bash docker compose exec api node dist/cli.js api-key:create --name "my CRM" --scopes admin export VOICE_API_URL=http://localhost:3000 VOICE_API_KEY=vk_... ``` (Or create an admin with `ADMIN_PASSWORD=... docker compose exec -e ADMIN_PASSWORD api node dist/cli.js admin:create --email you@example.com` and use the dashboard at http://localhost:3002.) ## 3. Connect the sandbox carrier and add a number ```bash curl -s -X POST $VOICE_API_URL/v1/carriers -H "Authorization: Bearer $VOICE_API_KEY" -H 'Content-Type: application/json' \ -d '{"type":"mock","name":"Sandbox","settings":{"authMode":"register"},"credentials":{"apiKey":"mock_my_sandbox_key"}}' curl -s -X POST $VOICE_API_URL/v1/numbers -H "Authorization: Bearer $VOICE_API_KEY" -H 'Content-Type: application/json' \ -d '{"e164":"+31201234567","carrier_id":""}' ``` ## 4. Register a softphone and call Create an agent (`POST /v1/agents {"agent_ref":"me"}`), fetch its softphone config (`POST /v1/agents/me/softphone-config`), open http://localhost:3000/demo/softphone, paste the JSON and press **Register**. Then: ```bash curl -s -X POST $VOICE_API_URL/v1/calls -H "Authorization: Bearer $VOICE_API_KEY" -H 'Content-Type: application/json' \ -d '{"agent":"me","to":"+31612345678","caller_id":"+31201234567"}' ``` Your browser rings; answer and you hear yourself (the simulated callee echoes). Look at the call with `GET /v1/calls/{call_id}` or in the dashboard's call log. ## Next - Recipes with tested code: [recipes/](recipes/) - Simulate inbound calls and other carriers: [sandbox.md](sandbox.md) --- # Integration guide for AI coding agents You are integrating calling into a CRM using the Voice Infrastructure Platform. Work through this checklist in order. Each step names the endpoint(s), the recipe with tested code, and how to verify the step before moving on. Everything here also works in the **sandbox** (mock carrier + simulated phone network), so build and test there first. Machine-readable references: - OpenAPI 3.1: `https://tvh-voice-api.gigaleads.nl/openapi.yaml` (every endpoint with examples; `webhooks` section = all events) - Docs index for LLMs: `https://tvh-voice-api.gigaleads.nl/llms.txt`, everything in one file: `https://tvh-voice-api.gigaleads.nl/llms-full.txt` - TypeScript SDK: `@voice/sdk` (typed client generated from the OpenAPI document + `verifyWebhook`) - Browser softphone: `@voice/softphone` (SIP.js wrapper) ## 0. Decisions to ask the human about first Do not guess these; ask, then record the answers in your project notes. 1. **Base URL and API key.** Production and sandbox base URLs. Which scopes the key needs (default: `calls:*`, `agents:*`, `numbers:read`, `routing:*`, `webhooks:*`, `events:read`, `recordings:read`). The key lives in server-side config (env var), never in browser code. 2. **Agent mapping.** Which CRM entity is an agent (user id?) → used as `agent_ref`. 3. **Who answers inbound calls.** One agent, a team (ring group), voicemail, or "the CRM decides per call" (webhook-decides, 2-second answer window with a fallback rule). 4. **Caller ID.** Which number each user calls from (per user, per team, or one company number). 5. **Recording.** Off / all / inbound / outbound; consent announcement on or off; retention days. This is a legal decision (GDPR, local consent law) — the human must decide. 6. **Softphone location.** Embedded browser softphone (recommended), desk phones/apps (long-lived SIP password), or both. 7. **Which carrier** (production only) and who creates its account and API key. The platform never needs the human's carrier password, only an API key/credentials for a dedicated key. 8. **Destinations and limits.** Allowed destination countries, max concurrent calls, calls per hour/day, spend-guard minutes (toll-fraud protection — the customer pays the carrier). ## 1. Authenticate - Header on every request: `Authorization: Bearer vk_...`. - Errors always look like `{ "error": { "code": "...", "message": "...", "request_id": "..." } }`; branch on `code` (stable; see `/docs/errors.md`), not on the message. - Retry `POST` requests safely with an `Idempotency-Key` header. - Recipe: `/docs/recipes/01-authenticate.md`. Verify: `GET /v1/workspace` returns 200. ## 2. Connect a carrier (usually done once by the human in the dashboard) - `GET /v1/carrier-types` → types, settings schema, credential fields, default capabilities. - `POST /v1/carriers` with `type`, `settings`, `credentials`. The platform validates the credentials at the carrier and provisions everything (SIP connection/trunk, origination, outbound profile). - Wait for `health.state == "healthy"` (or the `carrier.health.changed` event). - Read `capabilities` and **hide CRM features the carrier does not support** (e.g. number search for generic SIP trunks). Unsupported calls return `CAPABILITY_NOT_SUPPORTED`. - Recipe: `/docs/recipes/02-connect-carrier.md`. Sandbox: use `type: "mock"`. ## 3. Create agents and embed the softphone - For each CRM user: `POST /v1/agents { agent_ref }` (treat `409 CONFLICT` as "already exists"). - Your **backend** exposes an endpoint (e.g. `GET /crm/softphone-config`) that calls `POST /v1/agents/{agent_ref}/softphone-config` and returns the JSON to the logged-in user's browser. - In the browser: `new Softphone({ getConfig: () => fetch('/crm/softphone-config').then(r => r.json()) })`, then `start()`. The widget refreshes the short-lived password itself via `getConfig`. - Verify: the agent shows `registration.state == "registered"` (and `agent.registered` arrives). - Recipe: `/docs/recipes/03-agents-and-softphone.md`, widget reference: `/docs/softphone.md`. ## 4. Numbers and regulatory documents - If `capabilities.supports_ordering`: search (`GET /v1/available-numbers`), check `requires_requirement_group`, collect requirements (`GET /v1/regulatory-requirements`), upload documents (`POST /v1/documents`, base64), create + submit a requirement group, order (`POST /v1/number-orders`). - Status follows the carrier: `number.order.updated` / `number.requirements.updated` events. A rejected group can be corrected (`PATCH`) and resubmitted. - If the carrier has no number API: add existing numbers with `POST /v1/numbers`. - Recipe: `/docs/recipes/04-numbers.md`. ## 5. Click-to-call - `POST /v1/calls { agent, to, caller_id, external_ref }` → the agent's phone rings first, then `to` is dialed via the carrier of `caller_id`, then both are connected. - Show call state from webhooks/stream: `call.initiated → call.ringing → call.answered → call.ended`. - Controls: `/hangup`, `/hold`, `/unhold`, `/dtmf`, `/transfer` (blind or attended). - The browser softphone receives the agent leg as an incoming call with header `X-Call-Id` (= `call_id`): auto-answer it in the UI when it matches the call the user just started. - Recipe: `/docs/recipes/05-click-to-call.md`. ## 6. Webhooks (store call activity in the CRM) - `POST /v1/webhook-endpoints { url, event_types }` → keep the returned `secret`. - Verify every request with the **raw body** (`verifyWebhook(secret, rawBody, headers)` from the SDK, or any Standard Webhooks library). Respond 2xx within 10 s; process asynchronously. - Deduplicate on the event `id` (= `webhook-id` header): deliveries are at-least-once and can be replayed. - `call.ended` carries the full CDR (status, durations, hangup cause); `recording.ready` → fetch a signed URL with `GET /v1/calls/{id}/recording` (valid 15 min; do not store the URL, store the call id). - Recipe: `/docs/recipes/06-webhooks.md`, catalog with JSON Schemas: `/docs/events.md`. ## 7. Inbound routing - Create ring groups / inbound rules and attach a rule to each number (`PATCH /v1/numbers/{id}`). - Webhook-decides: answer the synchronous `call.incoming` request within 2 s with `{ "route": { "type": "agent", "agent_ref": "user-123" } }` (or `ring_group`, `voicemail`, `reject`); otherwise the fallback rule is used. - Recipe: `/docs/recipes/07-inbound-routing.md`. ## 8. Call logs and recordings - `GET /v1/calls` (filters, cursor pagination), `GET /v1/calls/{id}` (with event timeline), `GET /v1/calls/export.csv`, recordings via signed URLs, `DELETE /v1/calls/{id}/recording` for erasure. - Recipe: `/docs/recipes/08-call-logs.md`. ## 9. Go-live test (run it, report the results to the human) 1. Sandbox: steps 1–8 pass against the sandbox (`docker compose up` in the platform repo, see `/docs/sandbox.md`). 2. Production carrier connected and healthy; at least one active number. 3. Agent registers from the CRM in a real browser (microphone allowed). 4. Click-to-call to a real mobile phone: both hear each other; `call.ended` stored in the CRM. 5. Call the number from a mobile phone: the configured agent/group rings; answer; hang up. 6. Webhook signature verification rejects a modified body (send one manually). 7. If recording is on: `recording.ready` received, playback works, consent message heard. 8. Limits: a call to a non-allowed country is refused with `DESTINATION_NOT_ALLOWED`. ## 10. Switching carriers later Configuration only — no CRM change: 1. Connect the new carrier (`POST /v1/carriers`); both can be active at the same time. 2. Move numbers (port them outside the platform, or order new numbers at the new carrier). Outbound calls always use the carrier of the chosen caller ID, so you migrate per number. 3. Optionally set `failover_carrier_id` on the old carrier during the migration. 4. Revoke the old carrier (`POST /v1/carriers/{id}/revoke`). Never reference carrier/provider names in CRM code: use `carrier_id` and `capabilities` only. Recipe: `/docs/recipes/09-switch-carriers.md`. --- # API conventions ## Base URL and versioning All endpoints live under `/v1`. Additive changes (new fields, endpoints, event types) happen without a version change — ignore unknown fields. Breaking changes would ship as `/v2` next to `/v1`. Webhook payloads carry `version` (currently `1`); an endpoint receives the version it was created with. ## Authentication and scopes `Authorization: Bearer vk__`. Keys are created in the dashboard or with `POST /v1/api-keys` (shown once, stored hashed), rotated with `POST /v1/api-keys/{id}/rotate` and revoked with `DELETE /v1/api-keys/{id}`. | Scope | Allows | |---|---| | `calls:read` / `calls:write` | Call log, workspace & usage / start and control calls, delete recordings | | `agents:read` / `agents:write` | Agents / create, change, delete agents and issue softphone configs | | `numbers:read` / `numbers:write` | Numbers, orders, documents / order, release, upload | | `carriers:read` / `carriers:write` | Carrier configuration | | `routing:read` / `routing:write` | Inbound rules and ring groups | | `webhooks:read` / `webhooks:write` | Webhook endpoints and deliveries | | `events:read` | Event list and live stream | | `recordings:read` | Signed recording URLs | | `admin` | Everything, plus API keys, limits and the audit log | ## Requests and responses - JSON in and out, `snake_case` fields, timestamps in ISO 8601 UTC, phone numbers in E.164 (`+31201234567`). - Path ids are UUIDs; agents can also be addressed by `agent_ref`, numbers by E.164. - `provider_extras` fields are opaque carrier data — never build logic on them. ## Errors `{ "error": { "code", "message", "details"?, "request_id" } }` — see [errors.md](errors.md) for all codes. Validation errors list the failing fields in `details.issues`. ## Pagination List endpoints that can grow (`/v1/calls`, `/v1/events`, `/v1/webhook-deliveries`, `/v1/audit-log`) take `limit` (1–200, default 50) and return `next_cursor`; pass it as `cursor` until it is `null`. ## Idempotency Send `Idempotency-Key: ` on any `POST`. The first response is stored for 24 hours and replayed (header `idempotent-replayed: true`) for retries with the same key and body. Reusing a key with a different body returns `409 IDEMPOTENCY_KEY_REUSED`; a retry while the first request is still running returns `409 CONFLICT`. Server errors (5xx) are not stored, so you can retry them with the same key. Always use a key for `POST /v1/calls` — it prevents double dialing. ## Rate limits | Limit | Value | |---|---| | All /v1 endpoints, per API key (or per IP without a key) | 600 requests / minute | | `POST /v1/calls`, per API key | 120 requests / minute | | Dashboard sign-in, per IP | 10 attempts / minute | Responses carry `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset`; a `429` carries `Retry-After`. Call volume is additionally capped by the workspace limits (concurrent calls, calls per hour/day, spend guard), which return `429 LIMIT_EXCEEDED` / `SPEND_GUARD_TRIPPED`. ## Capabilities Carriers differ (a generic SIP trunk has no number API). `GET /v1/carriers/{id}` returns `capabilities`; hide features the carrier lacks. Calling them anyway returns `422 CAPABILITY_NOT_SUPPORTED`. --- # Recipe: Authenticate Every request needs an API key in the `Authorization` header. Keys are created in the dashboard (**API keys**) or with `POST /v1/api-keys` (admin scope), are shown once, are stored hashed, carry scopes, and can be rotated (`POST /v1/api-keys/{id}/rotate`) or revoked. Keep keys on your server. Browsers get short-lived softphone credentials instead (recipe 03). ## TypeScript (SDK) ```ts // Every request carries an API key. Create keys in the dashboard (API keys) and keep them server-side. import { createVoiceClient } from '@voice/sdk'; const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! }); const { data: workspace, error } = await voice.GET('/v1/workspace'); if (error) throw new Error(`${error.error.code}: ${error.error.message}`); console.log('workspace', workspace.name, 'sip domain', workspace.sip_domain); console.log('allowed destination countries', workspace.limits.allowed_destination_countries); ``` ## curl ```bash # Any endpoint; a missing or wrong key returns 401 with error.code UNAUTHENTICATED. curl -sS "$VOICE_API_URL/v1/workspace" -H "Authorization: Bearer $VOICE_API_KEY" ``` ## Notes | Response | Meaning | |---|---| | `401 UNAUTHENTICATED` | Missing, malformed, expired or revoked key | | `403 FORBIDDEN` | The key lacks the scope (`details` names it) | | `429 RATE_LIMITED` | Too many requests; wait `Retry-After` seconds | _This example is executed against the sandbox in CI (`tests/e2e/specs/m6-docs.spec.ts`)._ --- # Recipe: Connect a carrier A carrier is the customer's own account at Telnyx, Twilio or any SIP trunk provider; carrier costs stay with that account. Connecting validates the credentials, provisions the SIP side and creates the gateway. Health is reported asynchronously (`carrier.health.changed`). Production examples (same call, different body): ```json { "type": "telnyx", "name": "Telnyx", "settings": { "allowedDestinationCountries": ["NL"], "inboundSourceCidrs": [""] }, "credentials": { "apiKey": "KEY..." } } { "type": "twilio", "name": "Twilio", "settings": { "authMode": "credentials", "regulatoryEmail": "ops@example.com" }, "credentials": { "accountSid": "AC...", "apiKeySid": "SK...", "apiKeySecret": "...", "authToken": "..." } } { "type": "generic-sip", "name": "My trunk", "settings": { "proxy": "sip.trunk.example:5060", "authMode": "register" }, "credentials": { "username": "...", "password": "..." } } ``` Create a **dedicated** API key at the carrier for this platform, so it can be revoked on its own. ## TypeScript (SDK) ```ts // Connect a carrier account. The sandbox uses the "mock" carrier; production uses "telnyx", // "twilio" or "generic-sip" with the same call — only type/settings/credentials change. import { createVoiceClient } from '@voice/sdk'; const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! }); // 1. See which carrier types exist, their settings and capabilities. const { data: types } = await voice.GET('/v1/carrier-types'); console.log(types!.data.map((t) => `${t.type}: ${t.credential_fields.join(', ')}`)); // 2. Connect. Credentials are validated at the carrier and never returned again. const { data: carrier, error } = await voice.POST('/v1/carriers', { body: { type: 'mock', name: 'Sandbox carrier', settings: { authMode: 'register' }, credentials: { apiKey: 'mock_docs_example_key' } }, }); if (error) throw new Error(`${error.error.code}: ${error.error.message}`); console.log('carrier', carrier.id, carrier.status, 'supports ordering:', carrier.capabilities.supports_ordering); // 3. Health becomes "healthy" once the SIP gateway is up (also sent as carrier.health.changed). for (let i = 0; i < 30; i++) { const { data } = await voice.GET('/v1/carriers/{id}', { params: { path: { id: carrier.id } } }); if (data!.health.state === 'healthy') break; await new Promise((r) => setTimeout(r, 1000)); } console.log('CARRIER_ID=' + carrier.id); ``` ## curl ```bash curl -sS -X POST "$VOICE_API_URL/v1/carriers" \ -H "Authorization: Bearer $VOICE_API_KEY" -H "Content-Type: application/json" \ -d '{"type":"mock","name":"Sandbox carrier (curl)","settings":{"authMode":"ip_auth"},"credentials":{"apiKey":"mock_docs_example_key"}}' ``` ## Notes - `CARRIER_AUTH_FAILED`: the carrier rejected the credentials (nothing was stored as active). - `VALIDATION_FAILED`: `details.issues` / `details.fields` say which setting or credential field is wrong (values are never echoed). - Use `capabilities` to decide which number features to show. _This example is executed against the sandbox in CI (`tests/e2e/specs/m6-docs.spec.ts`)._ --- # Recipe: Agents and the browser softphone One agent (SIP account `agent_{agent_ref}`) per CRM user. Creating returns a long-lived SIP password once (for desk phones/apps). Browsers use `POST /v1/agents/{id}/softphone-config`, which returns the WSS URL, ICE/TURN servers and a password that expires (default 1 hour). Browser side (see `/docs/softphone.md`): ```ts import { Softphone } from '@voice/softphone'; const phone = new Softphone({ getConfig: () => fetch('/crm/softphone-config').then((r) => r.json()) }); phone.on('incoming', (call) => { if (call.platformCallId === expectedCallId) phone.answer(call.id); }); await phone.start(); // registers; refreshes credentials before they expire ``` ## TypeScript (SDK) ```ts // Create a SIP account per CRM user and give the browser short-lived softphone credentials. import { createVoiceClient } from '@voice/sdk'; const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! }); const crmUserId = process.env.DOCS_AGENT_REF ?? `docs-${Date.now()}`; // Idempotent "ensure agent": create, or reuse when it already exists (409 CONFLICT). const created = await voice.POST('/v1/agents', { body: { agent_ref: crmUserId, display_name: 'Docs Example' } }); if (created.error && created.error.error.code !== 'CONFLICT') throw new Error(created.error.error.message); // Your backend calls this and hands the result to the browser softphone widget. // The password expires (default 1 h); the widget asks your backend again before then. const { data: config, error } = await voice.POST('/v1/agents/{id}/softphone-config', { params: { path: { id: crmUserId } } }); if (error) throw new Error(error.error.message); console.log('websocket', config.sip.websocket_url, 'user', config.sip.username, 'expires', config.sip.expires_at); console.log('ice servers', config.ice_servers.length); ``` ## curl ```bash curl -sS -X POST "$VOICE_API_URL/v1/agents" \ -H "Authorization: Bearer $VOICE_API_KEY" -H "Content-Type: application/json" \ -d "{\"agent_ref\":\"docs-curl-$(date +%s)\",\"display_name\":\"Docs (curl)\"}" ``` ## Notes - `LIMIT_EXCEEDED` (`details.limit = max_agents`): the workspace agent limit is reached. - `registration.state` on the agent (and `agent.registered` / `agent.unregistered` events) shows whether the softphone is online. _This example is executed against the sandbox in CI (`tests/e2e/specs/m6-docs.spec.ts`)._ --- # Recipe: Numbers and regulatory documents Ordering is asynchronous at most carriers. The order status follows the carrier: `pending → (requirements_needed → in_review →) completed | failed`. Regulated numbers (for example Dutch local numbers) need a **requirement group**: one item per requirement key, documents uploaded first. Submit it; the carrier reviews it (`number.requirements.updated`). A rejected group shows per-item reasons: correct the items with `PATCH` and submit again. Order first and attach the group later (`POST /v1/number-orders/{id}/requirement-group`), or create the group first and pass `requirement_group_id` when ordering — carriers with synchronous ordering (`capabilities.ordering_is_async == false`) need the group up front. ## TypeScript (SDK) ```ts // Buy a number: search → (regulatory requirements) → order → wait for "completed". import { createVoiceClient } from '@voice/sdk'; const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! }); const carrierId = process.env.CARRIER_ID!; // Hide this feature in your UI when the carrier cannot do it. const { data: carrier } = await voice.GET('/v1/carriers/{id}', { params: { path: { id: carrierId } } }); if (!carrier!.capabilities.supports_ordering) throw new Error('This carrier has no number API: add numbers with POST /v1/numbers'); const { data: found } = await voice.GET('/v1/available-numbers', { params: { query: { carrier_id: carrierId, country: 'NL', number_type: 'local', limit: 1 } } }); const pick = found!.data[0]!; let groupId: string | null = null; if (pick.requires_requirement_group) { // Regulatory information: one item per requirement key; documents are uploaded first. const { data: reqs } = await voice.GET('/v1/regulatory-requirements', { params: { query: { carrier_id: carrierId, country: 'NL', number_type: 'local' } } }); const { data: doc } = await voice.POST('/v1/documents', { body: { filename: 'kvk.pdf', content_type: 'application/pdf', content_base64: Buffer.from('%PDF-1.4 example').toString('base64') }, }); const items = reqs!.data.map((r) => (r.kind === 'document' ? { key: r.key, document_id: doc!.id } : { key: r.key, value: 'Acme B.V., Damrak 1, 1012 LG Amsterdam' })); const { data: group } = await voice.POST('/v1/requirement-groups', { body: { carrier_id: carrierId, country: 'NL', number_type: 'local', items } }); await voice.POST('/v1/requirement-groups/{id}/submit', { params: { path: { id: group!.id } } }); groupId = group!.id; } const { data: order, error } = await voice.POST('/v1/number-orders', { body: { carrier_id: carrierId, numbers: [pick.e164], requirement_group_id: groupId, label: 'Docs example' }, }); if (error) throw new Error(error.error.message); // Status follows the carrier (number.order.updated events). Poll here for simplicity. console.log('order', order.id, order.status, pick.e164); console.log('ORDER_ID=' + order.id); ``` ## Notes - `CAPABILITY_NOT_SUPPORTED`: no number API at this carrier → `POST /v1/numbers` to add existing numbers. - Completed orders create the numbers (status `active`) with the order's `label` and `inbound_rule_id`. _This example is executed against the sandbox in CI (`tests/e2e/specs/m6-docs.spec.ts`)._ --- # Recipe: Click-to-call The agent's phone rings first. When the agent answers, the platform dials `to` through the carrier that owns `caller_id` and connects both parties. Policy checks run before anything rings: allowed destination countries, blocked prefixes, owned caller ID, concurrency and hourly/daily caps, and the spend guard. The agent leg arrives at the softphone with SIP header `X-Call-Id: ` so the UI can auto-answer the call the user just started. ## TypeScript (SDK) ```ts // Click-to-call: the agent's phone rings first; when they answer we dial the customer. import { createVoiceClient } from '@voice/sdk'; import { randomUUID } from 'node:crypto'; const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! }); const { data: call, error } = await voice.POST('/v1/calls', { body: { agent: process.env.DOCS_AGENT_REF!, // agent_ref of a registered agent to: '+31612345678', caller_id: process.env.CALLER_ID!, // one of your active numbers; its carrier places the call external_ref: 'crm-activity-42', // your id, searchable in the call log metadata: { lead_id: 'L-1001' }, }, // Safe retries: the same key never dials twice. headers: { 'Idempotency-Key': randomUUID() }, }); if (error) throw new Error(`${error.error.code}: ${error.error.message}`); console.log('call', call.call_id, call.status); // Follow it (webhooks are better in production — see the webhooks recipe). for (let i = 0; i < 30; i++) { const { data } = await voice.GET('/v1/calls/{id}', { params: { path: { id: call.call_id } } }); if (data!.status === 'answered' || data!.ended_at) break; await new Promise((r) => setTimeout(r, 1000)); } await voice.POST('/v1/calls/{id}/hangup', { params: { path: { id: call.call_id } } }); const { data: done } = await voice.GET('/v1/calls/{id}', { params: { path: { id: call.call_id } } }); console.log('timeline', done!.timeline.map((e) => e.type).join(' → ')); ``` ## curl ```bash curl -sS -X POST "$VOICE_API_URL/v1/calls" \ -H "Authorization: Bearer $VOICE_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: docs-$(date +%s)" \ -d "{\"agent\":\"$DOCS_AGENT_REF\",\"to\":\"+31612345678\",\"caller_id\":\"$CALLER_ID\"}" ``` ## Notes | Error code | Fix | |---|---| | `AGENT_NOT_REGISTERED` | The agent's softphone is offline | | `CALLER_ID_NOT_OWNED` | Use an active number from `GET /v1/numbers` | | `DESTINATION_NOT_ALLOWED` | Country/prefix not allowed by the workspace limits | | `LIMIT_EXCEEDED`, `SPEND_GUARD_TRIPPED` | Volume caps reached; see `GET /v1/usage` | | `INVALID_PHONE_NUMBER` | `to` must be E.164 (`+31612345678`) | Status flow: `initiated → ringing → answered → completed` (or `busy`, `no_answer`, `canceled`, `failed`). _This example is executed against the sandbox in CI (`tests/e2e/specs/m6-docs.spec.ts`)._ --- # Recipe: Webhooks Events are delivered to your endpoints as signed JSON (Standard Webhooks: `webhook-id`, `webhook-timestamp`, `webhook-signature`). Verify with the raw body, answer 2xx within 10 seconds, and do the work asynchronously. Failed deliveries are retried with exponential backoff (8 attempts over about 2 hours); every attempt is in the delivery log and any delivery can be replayed. ## TypeScript (SDK) ```ts // Receive signed events. Verify with the raw body; respond 2xx quickly; deduplicate on event id. import { createServer } from 'node:http'; import { createVoiceClient, verifyWebhook } from '@voice/sdk'; const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! }); const seen = new Set(); // 1. A receiver (in your CRM this is a normal route). let secret = ''; const received: string[] = []; const server = createServer((req, res) => { let raw = ''; req.on('data', (c) => (raw += c)); req.on('end', () => { try { const event = verifyWebhook(secret, raw, req.headers as Record); if (!seen.has(event.id)) { seen.add(event.id); received.push(event.type); // e.g. store call.ended as a CRM activity } res.writeHead(204).end(); } catch { res.writeHead(401).end(); // bad signature } }); }); await new Promise((r) => server.listen(Number(process.env.DOCS_WEBHOOK_PORT ?? 4600), '0.0.0.0', r)); // 2. Register it. The signing secret is returned once. const { data, error } = await voice.POST('/v1/webhook-endpoints', { body: { url: process.env.DOCS_WEBHOOK_URL!, event_types: ['webhook.test', 'call.initiated', 'call.answered', 'call.ended', 'recording.ready'] }, }); if (error) throw new Error(error.error.message); secret = data.secret; // 3. Send a test event and wait for it. await voice.POST('/v1/webhook-endpoints/{id}/test', { params: { path: { id: data.endpoint.id } } }); for (let i = 0; i < 20 && !received.includes('webhook.test'); i++) await new Promise((r) => setTimeout(r, 500)); console.log('received', received); await voice.DELETE('/v1/webhook-endpoints/{id}', { params: { path: { id: data.endpoint.id } } }); server.close(); if (!received.includes('webhook.test')) throw new Error('test event not received'); ``` ## Notes - Deduplicate on the event `id` (equals `webhook-id`): delivery is at-least-once. - Events for one call are emitted in order, but deliveries can arrive out of order after retries: use `data.status` and timestamps, not arrival order. - Rotate the secret with `POST /v1/webhook-endpoints/{id}/rotate-secret` (the old one stops at once). - Full catalog with JSON Schemas: `/docs/events.md`. _This example is executed against the sandbox in CI (`tests/e2e/specs/m6-docs.spec.ts`)._ --- # Recipe: Inbound routing Each number has an inbound rule: an agent, a ring group (simultaneous or sequential), voicemail (recording → `recording.ready` with `kind: "voicemail"`), or **webhook-decides**: the platform sends `call.incoming` synchronously to the rule's webhook endpoint and waits at most 2 seconds for a JSON answer like: ```json { "route": { "type": "agent", "agent_ref": "user-123" } } ``` Other routes: `{ "type": "ring_group", "ring_group_id": "..." }`, `{ "type": "voicemail" }`, `{ "type": "reject" }`. No (valid) answer in time → the rule's `fallback_rule_id` is used. ## TypeScript (SDK) ```ts // Route a number: to an agent, a ring group, voicemail, or let your CRM decide per call. import { createVoiceClient } from '@voice/sdk'; const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! }); const { data: agent } = await voice.GET('/v1/agents/{id}', { params: { path: { id: process.env.DOCS_AGENT_REF! } } }); const { data: group } = await voice.POST('/v1/ring-groups', { body: { name: 'Sales (docs)', strategy: 'simultaneous', agent_ids: [agent!.id], ring_timeout_seconds: 25 } }); const { data: rule, error } = await voice.POST('/v1/inbound-rules', { body: { name: 'Sales line (docs)', target: { type: 'ring_group', ring_group_id: group!.id } } }); if (error) throw new Error(error.error.message); // Attach the rule to a number. const { data: number } = await voice.PATCH('/v1/numbers/{id}', { params: { path: { id: process.env.CALLER_ID! } }, body: { inbound_rule_id: rule.id } }); console.log(number!.e164, '→ rule', rule.name); ``` ## Notes - The `call.incoming` event is also delivered asynchronously to other endpoints subscribed to it (not twice to the deciding endpoint). _This example is executed against the sandbox in CI (`tests/e2e/specs/m6-docs.spec.ts`)._ --- # Recipe: Call logs, timelines and recordings Every call (click-to-call, dialed from a phone, inbound, agent-to-agent) has a record with CDR fields and a timeline of channel events (`initiated`, `agent_answered`, `remote_ringing`, `bridged`, `held`, `dtmf`, `transferred`, `recording_started`, `remote_hangup`, `ended`, …). Recordings are stereo (agent / other party), stored in object storage, played through signed URLs valid for 15 minutes, and deleted after the retention period (or now, with `DELETE /v1/calls/{id}/recording`). ## TypeScript (SDK) ```ts // Call log with filters, a call's event timeline, recordings and CSV export. import { createVoiceClient } from '@voice/sdk'; const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! }); const { data: page } = await voice.GET('/v1/calls', { params: { query: { limit: 5, direction: 'outbound' } } }); for (const c of page!.data) console.log(c.initiated_at, c.direction, c.from, '→', c.to, c.status, c.billable_seconds ?? 0, 's'); // More pages: pass next_cursor as cursor until it is null. const first = page!.data[0]; if (first) { const { data: detail } = await voice.GET('/v1/calls/{id}', { params: { path: { id: first.call_id } } }); console.log('timeline', detail!.timeline.map((e) => e.type)); if (detail!.recording?.status === 'ready') { const { data: rec } = await voice.GET('/v1/calls/{id}/recording', { params: { path: { id: first.call_id } } }); console.log('signed recording URL valid until', rec!.expires_at); } } // CSV export uses the same filters (plain HTTP: it is not JSON). const csv = await fetch(`${process.env.VOICE_API_URL}/v1/calls/export.csv?direction=outbound`, { headers: { authorization: `Bearer ${process.env.VOICE_API_KEY}` } }); console.log((await csv.text()).split('\n')[0]); ``` ## Notes - Cursor pagination: pass `next_cursor` as `cursor` until it is `null`. - CSV cells that start with `= + - @` are prefixed with `'` (spreadsheet formula injection). _This example is executed against the sandbox in CI (`tests/e2e/specs/m6-docs.spec.ts`)._ --- # Recipe: Switch carriers Carriers are configuration. Nothing in the CRM integration refers to a carrier type: agents, softphones, numbers, inbound rules, calls, webhooks and events are identical for every carrier. Several carriers can be active at once; outbound calls use the carrier of the caller ID, with an optional failover carrier, so you can migrate number by number. ## TypeScript (SDK) ```ts // Switching carriers is configuration only: connect the new carrier, move numbers, revoke the old one. // Agents, softphones, inbound rules, webhooks and every call/number endpoint stay exactly the same. import { createVoiceClient } from '@voice/sdk'; const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! }); const oldCarrierId = process.env.CARRIER_ID!; // 1. Connect the new carrier next to the old one (both active = parallel running). const { data: next, error } = await voice.POST('/v1/carriers', { body: { type: 'mock', name: 'New carrier', settings: { authMode: 'ip_auth' }, credentials: { apiKey: 'mock_docs_new_carrier' } }, }); if (error) throw new Error(error.error.message); // 2. Optional during migration: fail over from the old carrier to the new one. await voice.PATCH('/v1/carriers/{id}', { params: { path: { id: oldCarrierId } }, body: { failover_carrier_id: next.id } }); // 3. Numbers belong to one carrier. Port them (outside the platform) or order new ones at the new // carrier; outbound calls use the carrier of the caller ID you choose, so you control the switch // per number. When everything runs on the new carrier, revoke the old one: const { data: numbers } = await voice.GET('/v1/numbers', { params: { query: { carrier_id: oldCarrierId } } }); console.log('numbers still on the old carrier:', numbers!.data.length); console.log('new carrier', next.id, next.type, next.capabilities.auth_mode); await voice.POST('/v1/carriers/{id}/revoke', { params: { path: { id: next.id } } }); ``` ## Notes - Porting numbers between carriers happens outside the platform; afterwards register the number at the new carrier (`POST /v1/numbers` or import with `POST /v1/numbers/sync`). - Revoking removes the gateway and wipes stored credentials; call history is kept. _This example is executed against the sandbox in CI (`tests/e2e/specs/m6-docs.spec.ts`)._ --- # Recipe: Live events (SSE / WebSocket) For live dashboards: `GET /v1/events/stream` (Server-Sent Events) or `GET /v1/events/ws` (WebSocket), both authenticated with the API key and filterable with `types=call.*,agent.registered`. Each message is the same envelope as a webhook. Use it from your backend; browsers should get events through your backend (never give the API key to a browser). ## TypeScript (SDK) ```ts export {}; // Live call state for dashboards: Server-Sent Events (or WebSocket at /v1/events/ws). const res = await fetch(`${process.env.VOICE_API_URL}/v1/events/stream?types=call.*,agent.registered`, { headers: { authorization: `Bearer ${process.env.VOICE_API_KEY}` }, signal: AbortSignal.timeout(3000), }).catch(() => null); if (!res || !res.ok) throw new Error('stream not available'); const reader = res.body!.getReader(); const { value } = await reader.read(); console.log(new TextDecoder().decode(value)); // ": connected", then "event: call.answered\ndata: {...}" await reader.cancel(); ``` ## Notes - Missed events can be fetched with `GET /v1/events` (filter by `type` or `call_id`). _This example is executed against the sandbox in CI (`tests/e2e/specs/m6-docs.spec.ts`)._ --- # Webhooks reference ## Delivery - `POST` to your URL with `Content-Type: application/json` and the [event envelope](events.md). - Headers (Standard Webhooks, https://www.standardwebhooks.com/): - `webhook-id` — the event id (stable across retries and replays), - `webhook-timestamp` — Unix seconds, - `webhook-signature` — `v1,` of `"{webhook-id}.{webhook-timestamp}.{raw body}"` keyed with the base64-decoded part of your `whsec_...` secret. - Answer with any `2xx` within 10 seconds. Redirects are not followed. ## Verifying Use the SDK's `verifyWebhook(secret, rawBody, headers)` or any Standard Webhooks library (available for many languages). Verify against the **raw** bytes, reject timestamps older than 5 minutes (the libraries do), compare in constant time. ## Retries Non-2xx, timeouts and connection errors are retried with exponential backoff: 8 attempts starting at 30 seconds (30 s, 1 m, 2 m, 4 m, … ≈ 2 hours in total). Each attempt is visible in the delivery log (`GET /v1/webhook-deliveries`): status, attempts, response status and the first 2 KB of your response. Disabled endpoints receive nothing. ## Replay `POST /v1/webhook-deliveries/{id}/replay` sends the same event again as a new delivery (same `webhook-id`). `POST /v1/webhook-endpoints/{id}/test` sends a `webhook.test` event. ## Ordering and duplicates Delivery is at-least-once. Events of one call are created in order, but retries can reorder deliveries — use `data.status` and the timestamps, and deduplicate on `id`. ## Security In production webhook URLs must be `https` and resolve to public addresses. Rotate the secret with `POST /v1/webhook-endpoints/{id}/rotate-secret` (the old secret stops working immediately). --- # Event catalog Every event has the same envelope; `data` depends on the type. Webhooks deliver it as the request body (signed, see `webhooks.md`), the live stream as `data:` lines, `GET /v1/events` as list items. ```json { "id": "uuid — equals the webhook-id header", "type": "call.answered", "version": 1, "created_at": "ISO 8601", "data": {} } ``` JSON Schemas (draft 2020-12) for each envelope: `docs/schemas/events/.json`, and the `webhooks` section of `openapi.yaml`. | Type | When | |---|---| | `call.initiated` | An outbound or internal call was created (click-to-call or dialed from a phone). | | `call.incoming` | A call arrived on one of your numbers. | | `call.ringing` | The destination is ringing (outbound: the called party; inbound: an agent).. | | `call.answered` | The destination answered; both parties are connected.. | | `call.updated` | The call was put on hold or resumed.. | | `call.ended` | The call is over. | | `recording.ready` | A call recording or voicemail is stored. | | `agent.registered` | An agent went from no registered device to at least one (softphone online).. | | `agent.unregistered` | The agent's last registered device went away (unregistered or expired).. | | `number.order.updated` | A number order changed status. | | `number.requirements.updated` | A requirement group changed status (submitted, in_review, approved, rejected) or items were accepted/rejected.. | | `carrier.health.changed` | A carrier's SIP connectivity changed (registration lost, trunk unreachable, recovered).. | | `usage.alert` | A spend-guard threshold was crossed (alert level or hard stop). | | `webhook.test` | Sent by POST /v1/webhook-endpoints/{id}/test to check your endpoint and signature verification.. | ## call.initiated An outbound or internal call was created (click-to-call or dialed from a phone). For click-to-call the agent phone rings next. ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "call.initiated", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "call_id": "0b8e6c1a-2f3d-4e5a-8b9c-1d2e3f4a5b6c", "direction": "outbound", "origin": "api", "status": "initiated", "agent_id": "a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d", "agent_ref": "user-123", "from": "+31201234567", "to": "+31612345678", "carrier_id": "c4a1e7b2-5d6f-4a8b-9c0d-1e2f3a4b5c6d", "caller_id_number_id": "6f1c2b9e-3d4a-4f5b-9c8d-7e6f5a4b3c2d", "initiated_at": "2026-11-09T10:15:00.000Z", "ringing_at": null, "answered_at": null, "ended_at": null, "duration_seconds": null, "billable_seconds": null, "hangup_cause": null, "hangup_by": null, "recording": null, "recording_enabled": false, "external_ref": "crm-activity-42", "metadata": { "lead_id": "L-1001" } } } ``` ## call.incoming A call arrived on one of your numbers. Webhook-decides inbound rules send this synchronously and wait up to 2 s for a route (see the inbound routing guide). ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "call.incoming", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "call_id": "0b8e6c1a-2f3d-4e5a-8b9c-1d2e3f4a5b6c", "direction": "inbound", "origin": "carrier", "status": "initiated", "agent_id": null, "agent_ref": null, "from": "+31612345678", "to": "+31201234567", "carrier_id": "c4a1e7b2-5d6f-4a8b-9c0d-1e2f3a4b5c6d", "caller_id_number_id": "6f1c2b9e-3d4a-4f5b-9c8d-7e6f5a4b3c2d", "initiated_at": "2026-11-09T10:15:00.000Z", "ringing_at": null, "answered_at": null, "ended_at": null, "duration_seconds": null, "billable_seconds": null, "hangup_cause": null, "hangup_by": null, "recording": null, "recording_enabled": false, "external_ref": "crm-activity-42", "metadata": { "lead_id": "L-1001" } } } ``` ## call.ringing The destination is ringing (outbound: the called party; inbound: an agent). ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "call.ringing", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "call_id": "0b8e6c1a-2f3d-4e5a-8b9c-1d2e3f4a5b6c", "direction": "outbound", "origin": "api", "status": "ringing", "agent_id": "a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d", "agent_ref": "user-123", "from": "+31201234567", "to": "+31612345678", "carrier_id": "c4a1e7b2-5d6f-4a8b-9c0d-1e2f3a4b5c6d", "caller_id_number_id": "6f1c2b9e-3d4a-4f5b-9c8d-7e6f5a4b3c2d", "initiated_at": "2026-11-09T10:15:00.000Z", "ringing_at": null, "answered_at": null, "ended_at": null, "duration_seconds": null, "billable_seconds": null, "hangup_cause": null, "hangup_by": null, "recording": null, "recording_enabled": false, "external_ref": "crm-activity-42", "metadata": { "lead_id": "L-1001" } } } ``` ## call.answered The destination answered; both parties are connected. ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "call.answered", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "call_id": "0b8e6c1a-2f3d-4e5a-8b9c-1d2e3f4a5b6c", "direction": "outbound", "origin": "api", "status": "answered", "agent_id": "a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d", "agent_ref": "user-123", "from": "+31201234567", "to": "+31612345678", "carrier_id": "c4a1e7b2-5d6f-4a8b-9c0d-1e2f3a4b5c6d", "caller_id_number_id": "6f1c2b9e-3d4a-4f5b-9c8d-7e6f5a4b3c2d", "initiated_at": "2026-11-09T10:15:00.000Z", "ringing_at": null, "answered_at": null, "ended_at": null, "duration_seconds": null, "billable_seconds": null, "hangup_cause": null, "hangup_by": null, "recording": null, "recording_enabled": false, "external_ref": "crm-activity-42", "metadata": { "lead_id": "L-1001" } } } ``` ## call.updated The call was put on hold or resumed. ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "call.updated", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "call_id": "0b8e6c1a-2f3d-4e5a-8b9c-1d2e3f4a5b6c", "direction": "outbound", "origin": "api", "status": "answered", "agent_id": "a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d", "agent_ref": "user-123", "from": "+31201234567", "to": "+31612345678", "carrier_id": "c4a1e7b2-5d6f-4a8b-9c0d-1e2f3a4b5c6d", "caller_id_number_id": "6f1c2b9e-3d4a-4f5b-9c8d-7e6f5a4b3c2d", "initiated_at": "2026-11-09T10:15:00.000Z", "ringing_at": null, "answered_at": null, "ended_at": null, "duration_seconds": null, "billable_seconds": null, "hangup_cause": null, "hangup_by": null, "recording": null, "recording_enabled": false, "external_ref": "crm-activity-42", "metadata": { "lead_id": "L-1001" }, "change": "held" } } ``` ## call.ended The call is over. Contains the final status, durations and hangup cause (the CDR). ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "call.ended", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "call_id": "0b8e6c1a-2f3d-4e5a-8b9c-1d2e3f4a5b6c", "direction": "outbound", "origin": "api", "status": "completed", "agent_id": "a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d", "agent_ref": "user-123", "from": "+31201234567", "to": "+31612345678", "carrier_id": "c4a1e7b2-5d6f-4a8b-9c0d-1e2f3a4b5c6d", "caller_id_number_id": "6f1c2b9e-3d4a-4f5b-9c8d-7e6f5a4b3c2d", "initiated_at": "2026-11-09T10:15:00.000Z", "ringing_at": null, "answered_at": null, "ended_at": "2026-11-09T10:17:30.000Z", "duration_seconds": 150, "billable_seconds": 141, "hangup_cause": "NORMAL_CLEARING", "hangup_by": "remote", "recording": null, "recording_enabled": false, "external_ref": "crm-activity-42", "metadata": { "lead_id": "L-1001" } } } ``` ## recording.ready A call recording or voicemail is stored. Fetch a playback URL with GET /v1/calls/{call_id}/recording. ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "recording.ready", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "recording_id": "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a", "call_id": "0b8e6c1a-2f3d-4e5a-8b9c-1d2e3f4a5b6c", "kind": "call", "duration_seconds": 141, "size_bytes": 27072044, "from": "+31201234567", "to": "+31612345678" } } ``` ## agent.registered An agent went from no registered device to at least one (softphone online). ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "agent.registered", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "agent_id": "a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d", "agent_ref": "user-123", "user_agent": "SIP.js/0.21.2", "network_ip": "203.0.113.7", "transport": "ws" } } ``` ## agent.unregistered The agent's last registered device went away (unregistered or expired). ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "agent.unregistered", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "agent_id": "a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d", "agent_ref": "user-123" } } ``` ## number.order.updated A number order changed status. `completed` means the numbers are active and usable. ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "number.order.updated", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "order_id": "6f1c2b9e-3d4a-4f5b-9c8d-7e6f5a4b3c2d", "carrier_id": "c4a1e7b2-5d6f-4a8b-9c0d-1e2f3a4b5c6d", "status": "completed", "requirement_group_id": null, "customer_reference": "crm-42", "failure_reason": null, "numbers": [ { "e164": "+31201234567", "status": "active", "number_id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e" } ] } } ``` ## number.requirements.updated A requirement group changed status (submitted, in_review, approved, rejected) or items were accepted/rejected. ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "number.requirements.updated", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "requirement_group_id": "6f1c2b9e-3d4a-4f5b-9c8d-7e6f5a4b3c2d", "carrier_id": "c4a1e7b2-5d6f-4a8b-9c0d-1e2f3a4b5c6d", "country": "NL", "number_type": "local", "status": "approved", "rejection_reason": null, "items": [ { "key": "business_name", "status": "accepted", "rejection_reason": null } ] } } ``` ## carrier.health.changed A carrier's SIP connectivity changed (registration lost, trunk unreachable, recovered). ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "carrier.health.changed", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "carrier_id": "c4a1e7b2-5d6f-4a8b-9c0d-1e2f3a4b5c6d", "name": "Telnyx production", "state": "down", "previous_state": "healthy", "detail": "Registration failed" } } ``` ## usage.alert A spend-guard threshold was crossed (alert level or hard stop). Outbound calls are refused after a hard stop until an admin resets it. ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "usage.alert", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "kind": "outbound_minutes_alert", "value": 1512, "threshold": 1500 } } ``` ## webhook.test Sent by POST /v1/webhook-endpoints/{id}/test to check your endpoint and signature verification. ```json { "id": "4c1f8e2a-7b3d-4e9f-a1c2-5d6e7f8a9b0c", "type": "webhook.test", "version": 1, "created_at": "2026-11-09T10:15:00.000Z", "data": { "endpoint_id": "6f1c2b9e-3d4a-4f5b-9c8d-7e6f5a4b3c2d" } } ``` --- # Error codes Every error response has this shape; branch on `code` (stable, never renamed), show `message` to humans, and quote `request_id` when asking for support. ```json { "error": { "code": "DESTINATION_NOT_ALLOWED", "message": "Calls to US are not allowed", "details": { "country": "US" }, "request_id": "5f0c7c9e-..." } } ``` | Code | HTTP | Meaning | |---|---|---| | `VALIDATION_FAILED` | 400 | The request body, query or path parameters are invalid. | | `UNAUTHENTICATED` | 401 | Missing or invalid API key or session. | | `FORBIDDEN` | 403 | The API key lacks the scope required for this operation. | | `NOT_FOUND` | 404 | The resource does not exist. | | `CONFLICT` | 409 | The request conflicts with the current state of the resource. | | `IDEMPOTENCY_KEY_REUSED` | 409 | The Idempotency-Key was already used with a different request body. | | `INVALID_STATE` | 409 | The operation is not allowed in the resource's current state. | | `CAPABILITY_NOT_SUPPORTED` | 422 | The carrier behind this resource does not support the operation. Check /v1/carriers/{id} capabilities. | | `CALLER_ID_NOT_OWNED` | 422 | The caller ID is not an active number in this workspace. | | `DESTINATION_NOT_ALLOWED` | 422 | The destination country or prefix is not allowed by the workspace limits. | | `INVALID_PHONE_NUMBER` | 422 | The phone number is not a valid E.164 number. | | `AGENT_NOT_REGISTERED` | 422 | The agent has no active SIP registration, so it cannot be rung. | | `LIMIT_EXCEEDED` | 429 | A workspace limit (agents, concurrent calls, calls per hour/day) was reached. | | `SPEND_GUARD_TRIPPED` | 429 | Outbound calling is stopped by the spend guard. An admin must review usage and reset it. | | `RATE_LIMITED` | 429 | Too many API requests. Retry after the time in the Retry-After header. | | `CARRIER_ERROR` | 502 | The carrier API returned an error. Details contain the normalized reason. | | `CARRIER_AUTH_FAILED` | 422 | The carrier rejected the supplied credentials. | | `CARRIER_UNAVAILABLE` | 503 | The carrier is disabled, revoked or unhealthy. | | `SWITCH_UNAVAILABLE` | 503 | The call controller or FreeSWITCH is not reachable. | | `INTERNAL_ERROR` | 500 | Unexpected server error. Retry with the same Idempotency-Key. | Retry guidance: `429` → wait `Retry-After`; `503 SWITCH_UNAVAILABLE` / `CARRIER_UNAVAILABLE` and `500` → retry with backoff and the same `Idempotency-Key`; `4xx` validation/policy errors → fix the request. --- # Browser softphone (`@voice/softphone`) A small wrapper around SIP.js for WebRTC calling in your CRM. Use it from your frontend; it never sees your API key — it gets short-lived credentials from **your** backend. ```ts import { Softphone } from '@voice/softphone'; const phone = new Softphone({ // Your backend calls POST /v1/agents/{agent_ref}/softphone-config and returns the JSON. getConfig: () => fetch('/crm/softphone-config').then((r) => r.json()), autoAnswer: false, }); phone.on('registered', () => setStatus('online')); phone.on('incoming', (call) => { // Click-to-call: the agent leg carries the platform call id (X-Call-Id). if (call.platformCallId === pendingClickToCallId) void phone.answer(call.id); else showIncomingUi(call); // from: call.remote }); phone.on('call', (call) => render(call)); // state: ringing | connecting | active | held | ended phone.on('error', (e) => console.error(e)); await phone.start(); // connects over WSS and registers; renews credentials before they expire ``` | Method | Does | |---|---| | `start()` / `stop()` | Register / unregister and disconnect | | `call(target, { headers })` | Call an agent (`agent_ref`) or a number (`+31…`). Choose the caller ID with header `X-Caller-Id: +31…` (must be one of your numbers; otherwise the workspace default is used) | | `answer(id?)`, `hangup(id?)` | Answer / end | | `hold(id?)`, `unhold(id?)` | SIP hold | | `mute(id?)`, `unmute(id?)` | Microphone | | `sendDtmf('123#', id?)` | RFC 4733 DTMF | | `transfer(target, id?)` | Blind transfer (SIP REFER) | | `attendedTransfer(id, consultId)` | Connect two calls | | `calls` | Current calls | Requirements: HTTPS page (microphone permission), the browser must reach the SIP WSS URL (TCP 7443) and the TURN server (UDP/TCP 3478). A plain `