# VoiceMatrix Developer API A REST API for creating voice agents, placing calls, reading transcripts and receiving webhooks. Everything below works with an API key you create yourself in the dashboard under Settings -> API Keys. Base URL: https://api.voicematrix.ai/api/v1/ext OpenAPI: https://api.voicematrix.ai/swagger/doc.json HTML: https://voicematrix.ai/docs/developers ## Quickstart # 1. Create a key in the dashboard: Settings -> API Keys. # It is shown once. Store it like a password. export VM_KEY=vm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # 2. Confirm it works and see what it can do. curl https://api.voicematrix.ai/api/v1/ext/me \ -H "Authorization: Bearer $VM_KEY" # 3. Create an agent. curl -X POST https://api.voicematrix.ai/api/v1/ext/agents \ -H "Authorization: Bearer $VM_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Reception", "prompt": "You answer the phone for a dental clinic. Book appointments.", "greeting": "Hi, thanks for calling.", "language": "en" }' # 4. Get a phone number (IL or US). Requires an ACTIVE plan — a trial cannot # acquire one, because a number carries a real monthly charge. On a trial # this answers 402 SUBSCRIPTION_REQUIRED and nothing is reserved. curl -X POST https://api.voicematrix.ai/api/v1/ext/phone-numbers \ -H "Authorization: Bearer $VM_KEY" \ -H "Content-Type: application/json" \ -d '{"country": "US"}' # 5. Point its inbound calls at your agent, using the ids from steps 3 and 4. curl -X POST https://api.voicematrix.ai/api/v1/ext/agents/AGENT_ID/phone-number \ -H "Authorization: Bearer $VM_KEY" \ -H "Content-Type: application/json" \ -d '{"phone_number_id": "PHONE_NUMBER_ID"}' # The response says routing_live: true when calls will actually reach the agent. ## Authentication Authorization: Bearer vm_live_... # or X-API-Key: vm_live_... - Keys are shown once at creation and stored only as a hash. There is no way to recover one — rotate instead. - Every key expires. The default lifetime is one year; you can set a shorter one at creation. - Revocation is immediate. Every request re-reads the key, so there is no cache to wait out. - A key is bound to the organization that created it. Sending an organization or reseller id in a request body does not change what the key can reach. ## Versioning VoiceMatrix-Version: 2026-08-06 - Pin a version by sending VoiceMatrix-Version: on every request. The current version is listed above; new ones are dated. - Omit the header and you are pinned to the OLDEST supported version, permanently. An integration written today keeps today's behaviour whether or not it asks for it. - Every response echoes VoiceMatrix-Version, so you can always see which contract you actually got rather than which one you meant to ask for. - An unrecognised version is refused with 400 and a list of what we serve. We never silently substitute a different contract for the one you requested. - The /v1/ in the path is the major version and changes only if the resource model does. The header is what changes when a field's shape or meaning does. ## Errors // Every non-2xx on /api/v1/ext has the same shape, from middleware and // handlers alike — you write one error path, not two. { "error": { "code": "VALIDATION_ERROR", // machine-readable, stable; switch on this "message": "phone_number_id is required", "hint": "...", // what to do next, when there is a next "fields": [ // validation failures only { "field": "phone_number_id", "reason": "is required" } ], "details": { } // e.g. reset_at on a 429, required_scope on a 403 } } // `fields` names the JSON field you sent, never an internal one. // `code` is stable. `message` is for a human and may be reworded. ## Scopes A key carries only the scopes you select. There is no wildcard scope. - agents:read: List and read agents - agents:write: Create, update, publish and unpublish agents - calls:read: List and read calls, including transcripts - calls:write: Place outbound calls (spends money) - calls:control: Hang up a live call - leads:read: List and read leads - leads:write: Create and update leads - knowledge:read: Read an agent’s knowledge base - knowledge:write: Add and remove knowledge documents (spends embedding tokens) - phone_numbers:read: List phone numbers - phone_numbers:write: Acquire a number, bind and unbind it to an agent (changes who answers a real phone line) - webhooks:manage: Full webhook lifecycle, including deliveries and test sends ## Endpoints Relative to the base URL. - GET /me — - GET /agents agents:read - GET /agents/{id} agents:read - POST /agents agents:write - PATCH /agents/{id} agents:write - POST /agents/{id}/publish agents:write - POST /agents/{id}/unpublish agents:write - GET /calls calls:read - GET /calls/{id} calls:read - POST /calls calls:write - POST /calls/{id}/hangup calls:control - GET /leads leads:read - GET /leads/{id} leads:read - POST /leads leads:write - PATCH /leads/{id} leads:write - GET /agents/{id}/knowledge knowledge:read - POST /agents/{id}/knowledge knowledge:write - DELETE /agents/{id}/knowledge/{title} knowledge:write - GET /phone-numbers phone_numbers:read - POST /phone-numbers phone_numbers:write - POST /agents/{id}/phone-number phone_numbers:write - DELETE /agents/{id}/phone-number phone_numbers:write - GET /webhooks webhooks:manage - POST /webhooks webhooks:manage - GET /webhooks/{id} webhooks:manage - PATCH /webhooks/{id} webhooks:manage - DELETE /webhooks/{id} webhooks:manage - POST /webhooks/{id}/test webhooks:manage - GET /webhooks/{id}/deliveries webhooks:manage - GET /webhooks/events webhooks:manage ## Rate limits - free 100 requests/hour, 500 requests/day - standard 1,000 requests/hour, 10,000 requests/day - premium 10,000 requests/hour, 100,000 requests/day Your tier follows your plan; a request cannot select it. Outbound calling carries its own daily ceiling per key AND per organization — minting more keys does not raise it. Any write accepts an Idempotency-Key header. Repeating a request with the same key inside 24 hours returns the original response instead of acting twice. ## Errors { "error": "Agent not found or not accessible", "code": "AGENT_NOT_FOUND" } Match on "code", not on "error" — the prose may be reworded. A resource belonging to another organization is reported as absent rather than forbidden, so 404 does not distinguish "does not exist" from "not yours". ## Webhooks curl -X POST https://api.voicematrix.ai/api/v1/ext/webhooks \ -H "Authorization: Bearer $VM_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/hooks/voicematrix", "events": ["call.ended", "lead.created"] }' # The response carries "secret" ONCE. That is the HMAC key. ### Events - call.started — When a call begins, with the caller and direction - call.ended — When a call ends, with transcript and summary - lead.created — When a new lead is captured - lead.updated — When a lead's status changes - agent.error — When an agent reports an unhandled error mid-call This list is read from the running API, not maintained by hand. An event is offered only once something in the platform dispatches it. ### Payload Unless you configure a payload template, every delivery is this envelope. "data" varies by event. { "id": "3f2b1c9e-...", "event": "call.ended", "timestamp": "2026-08-04T14:59:11Z", "api_version": "2026-01-01", "data": { "call_id": "975207f9-...", "agent_id": "fb7ce292-...", "direction": "inbound", "status": "completed", "duration": 84, "summary": "Caller booked a cleaning for Thursday." } } ### Verifying the signature Compute the HMAC over "" + "." + "" — the raw bytes, before any JSON parsing. Compare in constant time. Node: // Every delivery is signed TWICE, with both header sets on the same request. // Use this one unless you already have a verifier written against the legacy // headers further down. // // webhook-id: // webhook-timestamp: // webhook-signature: v1, // // This is Standard Webhooks (standardwebhooks.com) — the scheme Zapier, Twilio, // ngrok, Supabase and OpenAI use. Do not implement it by hand: // // npm install standardwebhooks # also pip, Go, Rust, Ruby, PHP, Java, C# const { Webhook } = require('standardwebhooks'); function verify(rawBody, headers, secret) { // Throws if the signature is wrong or the timestamp is outside the tolerance // window. Returns the parsed payload. return new Webhook(secret).verify(rawBody, headers); } // The message id is INSIDE this signature, so webhook-id is a deduplication key // you can actually trust — a replayer cannot rewrite it without breaking the MAC. // // Secrets minted before 2026-08 are "whsec_" + a uuid rather than "whsec_" + // base64, and the default constructor cannot load those. Pass the WHOLE secret // string, prefix included, as raw bytes instead: // // new Webhook(Buffer.from(secret), { raw: true }) // node // Webhook.new_using_raw_bytes(secret.encode()) // python // // Rotating the secret from the dashboard mints one in the new format. const crypto = require('crypto'); // The LEGACY scheme. Still sent on every delivery, and it will not be withdrawn // without notice — but a new integration should use the Standard Webhooks headers // above, which an off-the-shelf library verifies in one line. // // Headers on every delivery: // X-Webhook-Signature: v1= // X-Webhook-Timestamp: // X-Webhook-ID: // // The two schemes sign different strings and encode differently, so a signature // from one never verifies against the other: // legacy: hex(hmac(secret, timestamp + "." + body)) // standard: b64(hmac(base64decode(key), id + "." + timestamp + "." + body)) function verify(rawBody, headers, secret) { const signature = headers['x-webhook-signature']; const timestamp = headers['x-webhook-timestamp']; // Reject old deliveries. The timestamp is inside the MAC, so an attacker // cannot replay a captured body with a fresh timestamp. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const mac = crypto.createHmac('sha256', secret) .update(timestamp + '.' + rawBody) // NOTE the '.' separator .digest('hex'); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from('v1=' + mac) ); } Python: import hmac, hashlib, time def verify(raw_body: bytes, headers: dict, secret: str) -> bool: signature = headers["X-Webhook-Signature"] timestamp = headers["X-Webhook-Timestamp"] if abs(time.time() - int(timestamp)) > 300: return False mac = hmac.new( secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(signature, f"v1={mac}") ### Payload templates — sending YOUR schema By default we send the envelope above. A payload template replaces it with the exact shape your system expects, including translating a captured value into the id your database stores. // A payload template replaces our envelope with YOUR schema. // Set it per webhook: Settings -> Webhooks -> Advanced. { "first_name": "{{first_name}}", // plain substitution "referral_source": "VoiceMatrix Agent", // constant, passed through // A lookup translates a captured VALUE into what your system stores. // The agent hears "single"; your column is an integer tag id. "marital_status_tag_id": { "$from": "marital_status", "$map": { "single": 13, "divorced": 14, "widowed": 16 }, "$default": 13 // catches anything not in $map }, // $else picks the first source holding a usable value. // $match constrains the shape a source must have to qualify. "phone": { "$from": "phone_override", "$else": "phone", "$match": "^05[0-9]{8}$" } } - A value with no placeholder is sent exactly as written, so constants and static ids pass through untouched. - {{name}} resolves against the event payload. A string that is EXACTLY one placeholder keeps the original type, so numbers stay numbers; a placeholder inside a longer string is stringified in place. An unknown name renders as an empty string. - A lookup ($from) reads a captured field. With $map the value is translated; without $map the lookup simply picks the first source that holds a value. - A value that is present but NOT in $map falls to $default. With no $default it renders as JSON null — deliberately, so an untranslated string never lands in a field your system types. If your destination requires that field, one null rejects the whole request: give every $map a $default unless filing a wrong value would be worse than failing loudly. - $else lists further sources, tried in order; the first usable one wins. $match is a regular expression a source must satisfy to be usable, and a source that fails it is treated exactly like a missing one, so the lookup falls through to the next. - On PUT and PATCH a key whose placeholders are all unfilled is OMITTED rather than sent blank, so a partial capture cannot erase a value your CRM already holds. On POST every key is sent — there is nothing behind a create to overwrite. - Dry run renders this template and sends nothing. It reports per field which captured value was used and whether it mapped, fell to $default, or came out null — press it after every template change, and before you conclude that a destination is at fault. ### Delivery, retries and pausing - Each attempt times out after 10 seconds. Return 2xx quickly and do the work asynchronously. - Retries back off 30s -> 2m -> 10m -> 30m -> 2h, so a failing destination is retried for about 2 hours 45 minutes before the delivery is abandoned. - 4xx is treated as a settled refusal and is NOT retried — except 401, 403, 408 and 429, which are treated as temporary. - After 10 consecutive failures the webhook is paused. Nothing queues up while it is paused; resume it from the dashboard once the endpoint is healthy. Resuming does not replay what was dropped. - X-Webhook-ID is stable across retries. Use it as your deduplication key. - POST /webhooks/{id}/test sends a synthetic delivery. Test sends are marked and never count toward the pause threshold. - Every delivery carries X-Correlation-ID, and it is identical across all retries of that delivery — log it and one id joins your records to ours. When the event came from something you called (creating or updating a lead through the API, or the test endpoint above), it is the same id we echoed on your request. When the event came from a live call — call.started, call.ended, agent.error, and leads the agent captures mid-conversation — there is no request of yours behind it, so the id is one we generate; quote it to support and it still resolves. Set your own X-Correlation-ID in custom headers to override ours entirely. - A past delivery can be replayed from the dashboard. A replay re-sends to the destination stored on the webhook — never to an address supplied with the replay request — and the new attempt is marked as a test, so it cannot move your failure count or pause the subscription. - A replay keeps the original event id and gets a NEW X-Webhook-ID. Deduplicating on X-Webhook-ID as above means a replay arrives as a fresh delivery; match on the event id inside the body instead if you would rather recognise it as the event you already processed. - When the original delivery carried our standard envelope, a replay re-renders it through your CURRENT payload template, condition and method — which is how you fix a template and confirm the fix against a real past event. When the webhook already had a template at the time of that delivery, the original event cannot be reconstructed from what we stored, so the replay re-sends the stored body verbatim. The response tells you which of the two happened. - A replay is refused if your current delivery condition does not match the event. Run it as a dry run to see the rendered body and the evaluation. - Replays are capped per webhook and per organization per day, and the cap is checked before anything is sent. ## Behaviour that surprises people Correct and deliberate, and easy to mistake for a bug on a first read. - A new agent is created unpublished (status "inactive") and does not answer calls until you POST /agents/{id}/publish. It appears in GET /agents immediately; pass ?status=active to list only the agents that are answering. - Recording URLs are signed per request and valid for 24 hours from the moment you fetch them. Fetch the call again for a fresh link rather than storing the URL. - Every response carries X-Correlation-ID. Send your own and we use it, provided it is at most 128 characters of letters, digits, and - _ . : — anything else is replaced with a generated id, so read the header back rather than assuming what you sent survived. Log it: it is the id support asks for, and it also arrives on webhooks that your request triggered. Events raised by a live call instead of by an API call of yours carry a generated id, for the reason given under Webhooks. - Acquiring a phone number requires an ACTIVE subscription. A trial answers 402 SUBSCRIPTION_REQUIRED and reserves nothing — the check fails closed on purpose, because a number is a recurring charge from our carrier and a wrong "allow" bills you for something you did not ask for. Everything else on a trial works, including creating and publishing agents. - Inbound calls to your numbers are admitted against a per-organization concurrency ceiling derived from your plan. Past the ceiling a new inbound call is refused and torn down rather than queued, and the refusal reason is recorded on the call. Outbound calls are governed separately, by the daily limit on the key. ## MCP — drive your account from Claude or Cursor VoiceMatrix speaks the Model Context Protocol. Endpoint: https://api.voicematrix.ai/mcp Same API key, same permissions as the REST API above. Claude Code: claude mcp add voicematrix --transport http https://api.voicematrix.ai/mcp --header "X-API-Key: vm_live_your_key_here" Cursor, Claude Desktop, or any client taking a JSON config: { "mcpServers": { "voicematrix": { "type": "http", "url": "https://api.voicematrix.ai/mcp", "headers": { "X-API-Key": "vm_live_your_key_here" } } } } ### Tools - whoami (—) — Which organization this key belongs to, and what it may do - list_agents (agents:read) — Every agent and its status - get_agent (agents:read) — One agent, with its greeting and language - list_calls (calls:read) — Recent calls, filterable by agent, status and date - get_call (calls:read) — One call, with its summary; transcript only on request - list_leads (leads:read) — Leads captured by the agents - get_lead (leads:read) — One lead, with the notes the agent captured - list_webhooks (webhooks:manage) — Your subscriptions, and whether any is paused - webhook_deliveries (webhooks:manage) — Why a webhook is failing, in the destination's own words ### What it will and will not do - Read-only beta. No tool creates, changes or deletes anything — nothing here places a call, publishes an agent, or binds a phone number. - The key decides what is visible. A tool sees exactly what the same REST endpoint would return for that key, and refuses a scope the key does not hold. - An id is not permission. Passing an id from another organization returns an error, not a record — including as a filter argument. - Transcripts are opt-in per call, and never appear in a list. They are personal data, and an MCP tool result is forwarded into your model provider's context. - Recording links, webhook secrets, destination credentials and delivery request bodies are never returned to a model. Your destination's own error reply IS returned, capped at 500 characters, because it is usually the actual explanation for a failure. - Soft-deleted calls stay deleted here. A call you removed does not reappear over MCP, and neither does its transcript. - Remote OAuth-connected clients are NOT supported yet. This works with locally configured clients such as Claude Code and Cursor, where the key stays on your machine. ## What this API does not do yet Stated here so you find it now rather than mid-integration. - Self-serve phone numbers are capped: one number per organization, IL and US only, and platform-wide capacity is bounded. A second number or another country is a support request, not an API call. - No official client SDK. Generate one from the OpenAPI document. - No sandbox or test mode. Calls placed through the API are real calls and cost real money. - No inbound webhook endpoint: you subscribe to our events, you cannot push events to us. - The legacy X-Webhook-* signature is still sent alongside the Standard Webhooks one, so an older integration keeps working. It will not be withdrawn without notice. - List endpoints paginate with limit and offset; filtering is limited. ## Support support@voicematrix.ai