Auth
Generated from
DialerDigital/corecommit6e9f026f0a72— specopenapi/dialer-v1.yamlv0.36.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
Dashboard login (email + password -> a revocable ddu_ session; sha256 at rest, plaintext shown once) and password recovery (a one-shot, expiring link — the request half never reveals whether an address has an account).
Endpoints
| Method | Path | Summary |
|---|---|---|
POST | /v1/auth/login | Email + password login |
POST | /v1/auth/logout | Revoke the presented session |
POST | /v1/auth/password-reset | Request a one-shot password-reset link |
POST | /v1/auth/password-reset/confirm | Redeem a reset link and set the new password |
POST | /v1/auth/agent-login | Widget agent login (email + password -> a ddw_ token) |
GET | /v1/auth/widget-config | Public widget bootstrap config (the tenant's enabled origins) |
POST | /v1/me/password | Rotate the caller's own password |
Email + password login
POST /v1/auth/loginMints a revocable ddu_ dashboard session. Enumeration-free: unknown email, wrong password and a disabled user are ONE identical 401 at the same PBKDF2 cost; only proven-correct credentials of a suspended tenant/account earn the 403. Rate-limited per email (fixed window) on top of the hashing work factor. The plaintext token is shown exactly once — only its sha256 is stored.
Auth: none — authenticated by request signature (see below).
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
email | string (email) | yes | |
password | string (password) | yes |
{
"email": "[email protected]",
"password": "local-dev-password"
}Responses
200
Session minted; token is the only time the plaintext is visible.
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | Plaintext ddu_ bearer, shown exactly once. Fixed expiry (no sliding renew): re-login when it ends. |
session | object (UserSession) | yes | A ddu_ login session — the token hash is never exposed. |
session.id | string (uuid) | no | |
session.user_id | string (uuid) | no | |
session.expires_at | string (date-time) | no | Fixed at login (no sliding renew); 24h engine ceiling. |
session.revoked_at | string (date-time) | null | no | |
session.created_at | string (date-time) | no | |
user | object (User) | yes | A dashboard user — the password hash is never exposed. |
user.id | string (uuid) | no | |
user.email | string (email) | no | Global login identifier, stored lowercase. |
user.name | string | no | |
user.status | active · disabled | no | |
user.role | member · account_admin | no | Intra-account privilege. Only an account_admin may create or modify the empresa-wide rows every sibling sede inherits (scope: "empresa" on carriers and SMS providers); a member gets 403. Read-only here: promotion is an operator gesture, never a request. |
user.password_updated_at | string (date-time) | no | |
user.last_login_at | string (date-time) | null | no | |
user.created_at | string (date-time) | no | |
user.updated_at | string (date-time) | no | |
tenant | object (Tenant) | yes | |
tenant.id | string (uuid) | no | |
tenant.name | string | no | |
tenant.status | active · suspended | no | |
tenant.retention_months | integer | no | |
tenant.seats | integer | no | |
tenant.tier | starter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shield | no | |
tenant.voice_ai_enabled | boolean | no | |
tenant.ai_monthly_budget_usd | number | null | no | Cost-integrity ceiling (admin-set; null = no cap). |
tenant.ai_max_call_seconds | integer | null | no | Per-AI-call hard duration ceiling (seconds) set by the admin. null means the PLATFORM default applies — never "no ceiling" (core#922, ADR #115). |
tenant.ai_max_call_seconds_effective | integer | no | The ceiling FreeSWITCH is actually handed for this tenant's AI legs: ai_max_call_seconds when set, else the cell's platform default (AI_MAX_CALL_SECONDS_DEFAULT, compiled fallback 900). Always present, never null. Read-only (derived). |
tenant.sms_monthly_budget_usd | number | null | no | Month-to-date SMS spend cap. |
tenant.human_initiated_enabled | boolean | no | D17 human-initiated consent posture (admin-set; default false). When true, a LIVE preview/manual/CTI voice dial with no artificial and no AI voice does not require prior express consent (no ATDS). Every other gate — DNC, quiet hours, Reg F, state matrix, artificial voice — still applies unchanged. |
tenant.max_cps | integer | null | no | Per-tenant CPS ceiling (admin-set; null = no ceiling). ADR |
tenant.max_concurrent_channels | integer | null | no | Per-tenant concurrent-channel ceiling (admin-set; null = no ceiling). ADR |
tenant.abandon_seller_name | string | null | no | Seller name for the FTC TSR abandoned-call identification message (admin-set; null = not configured, predictive over-dial stays dark). ADR #14. |
tenant.abandon_seller_phone | string | null | no | E.164 callback number for the abandoned-call identification message (admin-set; null = not configured). ADR #14. |
tenant.tax_country | string | null | no | ISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.tax_region | string | null | no | State/province code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.tax_postal_code | string | null | no | Postal code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.created_at | string (date-time) | no | |
tenant.updated_at | string (date-time) | no |
{
"token": "ddu_example_plaintext_shown_once",
"session": {
"id": "2f6a1c58-9d43-4b1a-8e4f-6c1a2b3d4e5f",
"user_id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"expires_at": "2026-07-10T22:00:00Z",
"revoked_at": null,
"created_at": "2026-07-10T10:00:00Z"
},
"user": {
"id": "7c1d2e3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
"email": "[email protected]",
"name": "Local Dev Admin",
"status": "active",
"password_updated_at": "2026-07-01T09:00:00Z",
"last_login_at": "2026-07-10T10:00:00Z",
"created_at": "2026-07-01T09:00:00Z",
"updated_at": "2026-07-10T10:00:00Z"
},
"tenant": {
"id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
"name": "Acme Collections",
"status": "active"
}
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401
Invalid credentials — unknown email, wrong password and disabled user are indistinguishable.
{
"error": {
"code": "unauthorized",
"message": "invalid email or password"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}429 — LoginRateLimited
The per-ACCOUNT login brake refused this attempt (fixed window; retryable later). code rate_limited. Keyed per email (/v1/auth/login) or per {tenant_id, email} (/v1/auth/agent-login), so it bounds many passwords against ONE account. Credential spraying across many accounts is currently COUNTED AND ALERTED but not refused, because the only per-source key available to core is collapsed by the dashboard's reverse proxy — a spraying attempt therefore still returns the ordinary 401. No Retry-After header is set.
{
"error": {
"code": "rate_limited",
"message": "too many login attempts; retry later"
}
}Revoke the presented session
POST /v1/auth/logoutRevokes the ddu_ session carried in the Authorization header. IDEMPOTENT and enumeration-free: any well-formed bearer is 204 whether or not it named a live session; only a missing/malformed header is 401. Sessions are revoked, never deleted.
Responses
204
The session (if any) is revoked.
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}Request a one-shot password-reset link
POST /v1/auth/password-resetMails a single-use, expiring link to the address IF it belongs to an active dashboard account, and says NOTHING about whether it does.
The answer is ALWAYS 202 with the SAME body — byte for byte — whether the address has an account, has none, or belongs to a disabled user. Anything else would turn this unauthenticated route into a customer enumerator. Being rate-limited answers that same 202 too; it simply sends no mail, because a 429 would be an oracle of its own. Only a malformed body (no email, or a non-string) is a 400.
Requesting again invalidates any previous live link for that account: exactly ONE link is ever redeemable. A delivery failure does NOT change the 202, and leaves the token valid so the person can retry.
Auth: none — authenticated by request signature (see below).
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
email | string (email) | yes |
{
"email": "[email protected]"
}Responses
202
Accepted. Identical for every outcome (see the description).
| Field | Type | Required | Description |
|---|---|---|---|
status | accepted | yes | |
message | string | yes |
{
"status": "accepted",
"message": "if that address has an account, a reset link is on its way"
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}Redeem a reset link and set the new password
POST /v1/auth/password-reset/confirmBurns the one-shot token, writes the new password and REVOKES every live ddu_ session of that user — all in ONE transaction, so no session minted under the old password survives the change. The person logs in again with the new one.
Fail-closed and single-valued: an unknown, tampered, expired, superseded or already-redeemed token are ONE identical 401. The password policy (12–128 characters, the same one POST /v1/users applies) is checked BEFORE the token is touched, so a too-short password answers 422 with the link still redeemable.
Auth: none — authenticated by request signature (see below).
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | The plaintext from the e-mailed link. Single use. |
new_password | string (password) | yes |
{
"token": "ddr_example_plaintext_from_the_link",
"new_password": "an-even-better-password"
}Responses
200
Password written, token burned, every live session revoked.
| Field | Type | Required | Description |
|---|---|---|---|
status | ok | yes |
{
"status": "ok"
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401
Unknown, tampered, expired, superseded or already-redeemed token — indistinguishable by design.
{
"error": {
"code": "unauthorized",
"message": "invalid or expired token"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
{
"error": {
"code": "unprocessable",
"message": "scheduled_at must be at least 10 minutes out"
}
}Widget agent login (email + password -> a ddw_ token)
POST /v1/auth/agent-loginSigns an AGENT into the embeddable widget (embedded in the customer's CRM) with their own credential, and mints the restricted ddw_ browser token bound to that agent — so call actions are attributable to the person, in real time, WITHOUT the customer ever handling the tenant dd_ key or a dashboard ddu_ session.
tenant_id is the widget's PUBLIC workspace id (embedded at onboarding; a non-secret uuid, like a publishable key) — it SELECTS the tenant; the Origin NEVER resolves a tenant. The mandatory Origin header is the WIDGET-HOST — the browser document that serves the frame, a DEPLOY CONSTANT (WIDGET_HOST_ORIGINS / Dialer.Widgets.host_origins/0) — and must value-match one of the configured widget-host origins. That is DISTINCT from the tenant's per-tenant EMBED allowlist (widget_origins, which governs CSP frame-ancestors / postMessage); the two allowlists deliberately diverge. Fail-closed: an empty widget-host set authenticates no one. The minted ddw_ is UNPINNED — the widget-host Origin is re-value-matched on every subsequent widget request; the token is not bound to a CRM origin. Cross-origin (CORS): the Origin is reflected; the hard boundary is the widget-host match.
Enumeration- and timing-flat: an unknown tenant, a non-widget-host origin, unknown email, wrong password and a login-not-enabled agent are ONE identical 401 at the same PBKDF2 cost; only proven-correct credentials of a suspended tenant/account earn the 403. Per-(tenant, email) fixed-window brake. Flag-gated on the widget lane (:widget_auth_enabled): while dark, this route is 404.
The minted ddw_ reaches ONLY the default-deny widget surface (WidgetAllowlist): the agent's own call-control verbs, disposition, dial, callbacks, and the token's own renew/introspect — never the tenant management surface.
Auth: none — authenticated by request signature (see below).
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | string (uuid) | yes | The widget's public workspace id (non-secret). |
email | string (email) | yes | |
password | string (password) | yes |
{
"tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
"email": "[email protected]",
"password": "the-agents-passphrase"
}Responses
200
Logged in; token (the ddw_ plaintext) is shown exactly once.
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | Plaintext ddw_ browser token, shown exactly once. |
widget_token | object (WidgetToken) | yes | A ddw_ browser widget token — the plaintext + hash are never exposed here. |
widget_token.id | string (uuid) | no | |
widget_token.agent_id | string (uuid) | no | |
widget_token.label | string | no | |
widget_token.allowed_origin_id | string (uuid) | null | no | Optional pin to one widget_origins row — narrows the EMBED origins surfaced to the frame (CSP/postMessage) to just that one; null = all the tenant's enabled embed origins. NOT the auth boundary (that is the widget-host Origin). Agent-login mints UNPINNED. |
widget_token.expires_at | string (date-time) | no | |
widget_token.session_expires_at | string (date-time) | no | |
widget_token.revoked_at | string (date-time) | null | no | |
widget_token.last_used_at | string (date-time) | null | no | |
widget_token.created_at | string (date-time) | no | |
widget_token.updated_at | string (date-time) | no | |
agent | object (Agent) | yes | |
agent.id | string (uuid) | no | |
agent.tenant_id | string (uuid) | no | |
agent.name | string | no | |
agent.email | string | no | |
agent.role | agent · supervisor · admin | no | |
agent.status | active · inactive | no | Roster flag — NOT presence. |
agent.sip_extension | string | null | no | |
agent.device_mode | browser · external | no | |
agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
agent.created_at | string (date-time) | no | |
agent.updated_at | string (date-time) | no | |
tenant | object (Tenant) | yes | |
tenant.id | string (uuid) | no | |
tenant.name | string | no | |
tenant.status | active · suspended | no | |
tenant.retention_months | integer | no | |
tenant.seats | integer | no | |
tenant.tier | starter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shield | no | |
tenant.voice_ai_enabled | boolean | no | |
tenant.ai_monthly_budget_usd | number | null | no | Cost-integrity ceiling (admin-set; null = no cap). |
tenant.ai_max_call_seconds | integer | null | no | Per-AI-call hard duration ceiling (seconds) set by the admin. null means the PLATFORM default applies — never "no ceiling" (core#922, ADR #115). |
tenant.ai_max_call_seconds_effective | integer | no | The ceiling FreeSWITCH is actually handed for this tenant's AI legs: ai_max_call_seconds when set, else the cell's platform default (AI_MAX_CALL_SECONDS_DEFAULT, compiled fallback 900). Always present, never null. Read-only (derived). |
tenant.sms_monthly_budget_usd | number | null | no | Month-to-date SMS spend cap. |
tenant.human_initiated_enabled | boolean | no | D17 human-initiated consent posture (admin-set; default false). When true, a LIVE preview/manual/CTI voice dial with no artificial and no AI voice does not require prior express consent (no ATDS). Every other gate — DNC, quiet hours, Reg F, state matrix, artificial voice — still applies unchanged. |
tenant.max_cps | integer | null | no | Per-tenant CPS ceiling (admin-set; null = no ceiling). ADR |
tenant.max_concurrent_channels | integer | null | no | Per-tenant concurrent-channel ceiling (admin-set; null = no ceiling). ADR |
tenant.abandon_seller_name | string | null | no | Seller name for the FTC TSR abandoned-call identification message (admin-set; null = not configured, predictive over-dial stays dark). ADR #14. |
tenant.abandon_seller_phone | string | null | no | E.164 callback number for the abandoned-call identification message (admin-set; null = not configured). ADR #14. |
tenant.tax_country | string | null | no | ISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.tax_region | string | null | no | State/province code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.tax_postal_code | string | null | no | Postal code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.created_at | string (date-time) | no | |
tenant.updated_at | string (date-time) | no | |
origins | array of string | yes | The tenant's enabled EMBED origins (widget_origins) for the frame's CSP frame-ancestors / postMessage peer — NOT the auth boundary (that is the widget-host Origin). |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401
Invalid login — unknown tenant/email, wrong password, a non-widget-host Origin and a login-disabled agent are indistinguishable.
{
"error": {
"code": "unauthorized",
"message": "invalid email or password"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}404
The widget lane is disabled (:widget_auth_enabled off).
429 — LoginRateLimited
The per-ACCOUNT login brake refused this attempt (fixed window; retryable later). code rate_limited. Keyed per email (/v1/auth/login) or per {tenant_id, email} (/v1/auth/agent-login), so it bounds many passwords against ONE account. Credential spraying across many accounts is currently COUNTED AND ALERTED but not refused, because the only per-source key available to core is collapsed by the dashboard's reverse proxy — a spraying attempt therefore still returns the ordinary 401. No Retry-After header is set.
{
"error": {
"code": "rate_limited",
"message": "too many login attempts; retry later"
}
}Public widget bootstrap config (the tenant's enabled origins)
GET /v1/auth/widget-configThe tenant's ENABLED EMBED origins (widget_origins) — the per-tenant allowlist of CRM origins that may embed this tenant's widget. PUBLIC (security: []): these strings already ship inside CSP headers. It feeds the two EMBED-side gates from ONE source: the frame host's Content-Security-Policy: frame-ancestors (who may EMBED the widget) and the frame's postMessage peer-origin validation (who it will TALK to). This is the EMBED boundary ONLY — DISTINCT from the AUTH boundary, which is the widget-host Origin (WIDGET_HOST_ORIGINS, a deploy constant) re-value-matched on every ddw_ request; the two allowlists deliberately diverge. CORS-reflected like agent-login; cacheable 60s. An unknown tenant_id answers origins: [] — no tenant-existence oracle. Flag-gated on the widget lane (:widget_auth_enabled): while dark, this route is 404.
Auth: none — authenticated by request signature (see below).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant_id | query | string (uuid) | yes | The widget's public workspace id (non-secret). |
Responses
200
The tenant's enabled origins (possibly empty).
| Field | Type | Required | Description |
|---|---|---|---|
origins | array of string | yes |
{
"origins": [
"https://crm.acme-collections.test"
]
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}404
The widget lane is disabled (:widget_auth_enabled off).
Rotate the caller's own password
POST /v1/me/passwordRequires a ddu_ login session (a dd_ machine key has no person to rotate — 403). Verifies the current password, stores the new hash and revokes every OTHER live session of the user; the session performing the rotation survives.
Because this route verifies the caller's REAL password, wrong confirmations are rate-limited per USER (429): a stolen session cannot be used to read the password out by brute force. A correct confirmation is never counted, so normal rotation is unaffected.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
current_password | string (password) | yes | |
new_password | string (password) | yes |
Responses
204
Password rotated; other sessions revoked.
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}403 — Forbidden
The tenant is suspended.
{
"error": {
"code": "forbidden",
"message": "tenant is suspended"
}
}422
The current password is incorrect.
{
"error": {
"code": "unprocessable",
"message": "current password is incorrect"
}
}429 — PasswordConfirmThrottled
Too many FAILED current-password confirmations for this USER inside the fixed window (retryable later). code rate_limited. Keyed on the authenticated user — never on a network address, which a reverse proxy in front of core would collapse — and evaluated before the password is verified. A correct confirmation is not counted.
{
"error": {
"code": "rate_limited",
"message": "too many failed password confirmations; retry later"
}
}