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.

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
agents:writeCreate/update/publish/unpublish agents
knowledge:readList an agent's knowledge sources
knowledge:writeAdd/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

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:

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

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.

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