Authentication
Verified against backend/internal/apikeys/, backend/internal/middleware/, and PROJECT-DOCS/DEVELOPER_PLATFORM.md (production acceptance run, 2026-08-05).
Getting a key
Dashboard → Settings → API. Self-serve since 2026-08-04 — before that date the page existed but was unreachable for every real customer (see changelog.md). You create the key yourself; nobody at VoiceMatrix sees it.
The full key is shown exactly once, at creation. VoiceMatrix stores only a SHA-256 hash of it (apikeys/generator.go) — if you lose it, the only fix is to revoke it and create a new one.
Connecting Claude or another MCP client? You don't need a key: the MCP server signs you in with OAuth and a consent screen. Each approved connection is listed among your keys under the application's name (prefix vmo_), counts toward the same per-organization key limit, and is revoked the same way.
Key format
vm_live_<32 random characters>That is the only format. There is no vm_test_ / sandbox key — every key is live, every call it can place costs money and rings a real phone.
Sending it
Either header works identically:
curl https://api.voicematrix.ai/api/v1/ext/agents \
-H "X-API-Key: vm_live_your_key_here"curl https://api.voicematrix.ai/api/v1/ext/agents \
-H "Authorization: Bearer vm_live_your_key_here"Browser/dashboard sessions use an httpOnly JWT cookie instead — never a key. If you're building a server-side integration, use a key; if you're calling the API from a browser, you're doing it wrong regardless of which credential you use (see Security).
Scopes
A key holds exactly the scopes it was granted at creation — nothing implicit, no wildcard/"all" scope. A missing scope is a clean 403, not a silent partial success:
{
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "Missing required scope: calls:write",
"details": { "your_scopes": ["agents:read", "agents:write", "webhooks:manage", "calls:read", "leads:read"] }
}
}| Scope | Grants |
|---|---|
calls:read | List/get calls |
calls:write | Place outbound calls |
calls:control | Hang up an in-progress call |
leads:read | List/get leads |
leads:write | Create/update leads |
agents:read | List/get agents and read everything about them: full configuration (prompt included; credentials are never returned), statistics, usage, simulations and their transcripts, security results, calendar and integration settings |
agents:write | Create, configure, publish/unpublish and duplicate agents; draft agents with the wizards (a paid model call each), create share links (anyone holding one can talk to the agent, billed to you), change calendar and integration settings, send webhook tests |
agents:delete | Delete an agent. Permanent: there is no undo through the API |
agents:test | Start the paid test runs: scored simulations (POST /agents/:id/simulations), tester callers (POST /agents/:id/tester) and security & behaviour scans (POST /agents/:id/security/scan). Reading their results needs only agents:read |
knowledge:read | List an agent's knowledge sources and search their content |
knowledge:write | Add, upload, limit to stages and delete knowledge sources |
phone_numbers:read | List phone numbers |
phone_numbers:write | Acquire a number, bind/unbind it to an agent |
webhooks:manage | Full webhook CRUD, test-send, delivery log, replay. Reading an agent's own webhook log also needs calls:read, because it holds call transcripts |
Five of these spend real money the moment they're used — calls:write, phone_numbers:write, agents:write, agents:test (model time for every test run), knowledge:write (embedding tokens) — the key-creation dialog requires you to confirm each of those explicitly.
agents:delete and agents:test were split out of agents:write on 2026-10-07, so a key that edits agents no longer deletes them or starts test runs unless you grant that too. Every live key that held agents:write before then was given both, and can still do everything it could. To narrow one, create a key with only the scopes it needs, switch to it, and revoke the old one.
There is no analytics:read scope and no webhooks:write scope, if you've seen either mentioned elsewhere — they don't exist in the code. The correct scope for webhook management is webhooks:manage.
What you can't set yourself
Three properties are always derived server-side, never accepted from the request body — each one used to be caller-controlled and each one was a real incident:
| Property | Value | Why it's not caller-set |
|---|---|---|
rate_limit_tier | Derived from your billing plan | Used to be read from the request body — a starter customer could mint themselves a premium-tier key |
expires_at | 1 year from creation | Used to be nil (never expires) unless explicitly requested — an indefinite bearer credential is a bad default for something you paste into a coding agent's config |
daily_call_limit | 50/day | Nil means unlimited, not "use the default" — that ambiguity shipped as a bug once |
Tiers and roles
Access to the API-keys page itself is gated two ways:
- Role:
direct_user,admin,managercan create keys.repandexternal_managercannot — a rep minting acalls:writekey was a real finding this gate exists to prevent. - Billing plan:
starter,pro, andenterprisecan all access the API today. (Starter was blocked until 2026-08-04.)
Rate limits
| Limit | Value |
|---|---|
| Requests per key (standard tier) | 1,000/hour, 10,000/day |
| Outbound calls per key | 50/day |
| Outbound calls per organization | Daily + monthly budget, atomic reservation, concurrency cap — this is the limit that actually bounds spend, since per-key caps alone are bypassable by minting more keys |
| Active keys / webhooks per organization | Bounded |
| Idempotency window | 24 hours |
A rate-limited request gets 429 RATE_LIMIT_EXCEEDED with a retry_after. If the limiter's backing store is unavailable, requests are refused with 503 RATE_LIMIT_UNAVAILABLE — the limiter fails closed, not open.
Idempotency
POST, PUT and PATCH honor an optional Idempotency-Key header. Send one and a retried request with the same key returns the original response instead of executing twice — useful since POST /calls places a real call and POST /agents creates an agent. Only successful (2xx) responses are replayed, for 24 hours; a replay carries Idempotency-Replayed: true. The key is bound to the request's method, path, query, If-Match and exact body bytes — for a multipart upload, resend the identical bytes. DELETE needs no key: repeating a delete answers 404, which means it is done.
The check runs after scope validation, deliberately: an idempotency check at the group level (before the scope check) would let a cached replay reach a key that has since lost the scope, serving a stale 2xx to a de-authorized caller for the rest of the 24-hour window. Ordering it after scope validation means a replay is only ever served to a caller that still holds the scope.
API versioning
Every response carries a VoiceMatrix-Version value, resolved from the same header on your request. Omit the header and you're pinned to the oldest supported version, permanently — so an integration written today keeps today's behavior even as the API evolves. Send an unrecognized version and you get 400, not a silent fallback. The version check runs before authentication, so even a 401 response carries the resolved-version header — you see the same contract on every response, not just the successful ones.
GET /me — introspection
No scope required, deliberately: a key has to be able to discover its own permissions without guessing from a wall of 403s.
curl https://api.voicematrix.ai/api/v1/ext/me -H "X-API-Key: vm_live_..."{
"key_id": "...",
"key_name": "production-integration",
"organization_id": "...",
"scopes": ["calls:read", "calls:write", "leads:read", "leads:write"],
"rate_limit_tier": "standard",
"daily_call_limit": 50,
"daily_calls_used": 12
}Security practices
- Store keys in environment variables / a secrets manager — never in source control.
- Never call the API directly from browser or mobile client code; proxy through your own backend.
- Revoke a key the moment you suspect it's compromised — revocation is immediate (
APIKeyAuthchecks the hash on every request, no cache). - Prefer the narrowest scope set your integration actually needs. There's no cost to creating several narrowly-scoped keys instead of one broad one.