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.
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 |
agents:write | Create/update/publish/unpublish agents |
knowledge:read | List an agent's knowledge sources |
knowledge:write | Add/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 |
Four of these spend real money the moment they're used — calls:write, phone_numbers:write, agents:write, knowledge:write (embedding tokens) — the key-creation dialog requires you to confirm each of those explicitly.
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
Unsafe methods (POST, PATCH, DELETE) 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 spends embedding tokens.
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.