Skip to main content

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

text
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:

bash
curl https://api.voicematrix.ai/api/v1/ext/agents \
  -H "X-API-Key: vm_live_your_key_here"
bash
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:

json
{
  "error": {
    "code": "INSUFFICIENT_SCOPE",
    "message": "Missing required scope: calls:write",
    "details": { "your_scopes": ["agents:read", "agents:write", "webhooks:manage", "calls:read", "leads:read"] }
  }
}
ScopeGrants
calls:readList/get calls
calls:writePlace outbound calls
calls:controlHang up an in-progress call
leads:readList/get leads
leads:writeCreate/update leads
agents:readList/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:writeCreate, 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:deleteDelete an agent. Permanent: there is no undo through the API
agents:testStart 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:readList an agent's knowledge sources and search their content
knowledge:writeAdd, upload, limit to stages and delete knowledge sources
phone_numbers:readList phone numbers
phone_numbers:writeAcquire a number, bind/unbind it to an agent
webhooks:manageFull 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:

PropertyValueWhy it's not caller-set
rate_limit_tierDerived from your billing planUsed to be read from the request body — a starter customer could mint themselves a premium-tier key
expires_at1 year from creationUsed 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_limit50/dayNil 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, manager can create keys. rep and external_manager cannot — a rep minting a calls:write key was a real finding this gate exists to prevent.
  • Billing plan: starter, pro, and enterprise can all access the API today. (Starter was blocked until 2026-08-04.)

Rate limits

LimitValue
Requests per key (standard tier)1,000/hour, 10,000/day
Outbound calls per key50/day
Outbound calls per organizationDaily + 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 organizationBounded
Idempotency window24 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.

bash
curl https://api.voicematrix.ai/api/v1/ext/me -H "X-API-Key: vm_live_..."
json
{
  "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 (APIKeyAuth checks 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.

Next

All pages