Authentication
The core API authenticates with per-tenant API keys, sent as a bearer token. There is no login endpoint, no user/password flow, no JWT, and no roles on this API — the key is the tenant identity.
Authorization: Bearer dd_...Every endpoint except GET /healthz requires the header. Only the sha256 of the token is stored server-side (api_keys table, RLS-protected) — a leaked database never leaks usable keys.
Getting your first key
The first key for a tenant is minted at provisioning time by the Dialer Digital team (locally: priv/repo/seeds.exs, which prints the bearer token exactly once). From then on you rotate keys yourself through the API.
The dashboard logs in with the same key
The owner dashboard has no separate account system today: its login form takes the tenant API key and validates it against GET /v1/me. User accounts, SSO and MFA are on the Roadmap.
Managing keys: /v1/api_keys
| Method | Path | Notes |
|---|---|---|
GET | /v1/api_keys | List keys — id, label, revoked_at, created_at. Hashes are never exposed. |
POST | /v1/api_keys | Body {"label": "..."}. Returns 201 with the new key and the plaintext token — shown once, never again. |
DELETE | /v1/api_keys/{id} | Revokes the key (sets revoked_at). Keys are never deleted — the audit trail keeps them. |
Mint a new key
curl -s -X POST $API/v1/api_keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"label": "warehouse-etl"}'{
"api_key": {
"id": "7c0e6a1e-3f2b-4d5c-9e8f-1a2b3c4d5e6f",
"label": "warehouse-etl",
"revoked_at": null,
"created_at": "2026-06-11T14:30:00Z"
},
"token": "dd_kQ3v…plaintext-shown-ONCE…"
}Store token immediately — there is no way to retrieve it later.
Rotation recipe
POST /v1/api_keyswith a fresh label → get the new plaintext.- Deploy the new token to your integration.
DELETE /v1/api_keys/{old-id}→ old key stops working immediately.
Revocation also kills live WebSocket connections: each heartbeat re-authenticates the token, so a revoked key is dropped within one heartbeat (~30 s) with close code 1008.
Failure modes
| Status | Code | When |
|---|---|---|
401 | unauthorized | Missing, malformed, unknown, or revoked key. |
403 | forbidden | Valid key, but the tenant is suspended. |
WebSocket authentication: an ephemeral ticket
Browsers cannot set headers on a WebSocket handshake, so something has to ride the query string. /v1/ws accepts exactly one thing there, and it is not your API key: a single-use ticket that expires in seconds. Minting it is one extra call, and that call is the whole point — the long-lived key stays in an Authorization header, where it belongs.
Step 1 — mint the ticket, presenting the API key the normal way:
POST /v1/ws/ticket
Authorization: Bearer dd_...{
"ticket": "ddtt_8Zr3kQ0m2sVx1yPd7La9WfHnB6TcEjRu4KgMoZvQiXs",
"expires_at": "2026-01-01T00:00:30Z",
"expires_in": 30
}expires_in is the ticket's life in seconds, and it is deliberately short — read it from the response rather than assuming a value.
Step 2 — open the socket with the ticket:
GET /v1/ws?ticket=ddtt_...The ticket inherits the exact principal of the bearer that minted it, and its lane is encoded in the prefix — ddtt_ for a tenant key, ddta_ for an agent one. A ticket minted by an agent credential can never open a tenant socket.
403 (suspended tenant) and 426 (the request is not an upgrade) behave as everywhere else. 401 covers three cases with one identical response — unknown ticket, already redeemed, and expired — so a client learns nothing from the difference. The socket's topic comes from the redeemed ticket: a socket only ever receives its own tenant's events, and there is nothing to subscribe to by name. Details in WebSocket events.
Why a ticket, and not the key
Anything in a URL ends up in access logs, browser history and Referer headers. Rather than ask you to mitigate that, the API makes what rides there worthless: the ticket is burned on first redemption and expires in seconds even if it is never used. Mint one per connection, and nothing reusable ever reaches a log.
Tenancy model
- One key ↔ one tenant.
GET /v1/mereturns the tenant (not a user). GET /v1/tenantsreturns a one-element list (your own tenant) — kept for dashboard parity.- The API never accepts a tenant id from path, query, or body. Another tenant's resource id reads as
404 not_found— existence is never leaked (enforced by Postgres RLS, not by handler-level id comparison).
Security checklist for integrators
- Store tokens server-side only; never embed them in code shipped to consumers.
- Mint one key per integration (label it) so revocation is surgical.
- Rotate via the recipe above on your normal credential schedule.
- Log the
X-Request-IDof failed calls — it makes support cases instant.