Skip to content

Auth

Generated from DialerDigital/core commit 6e9f026f0a72 — spec openapi/dialer-v1.yaml v0.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

MethodPathSummary
POST/v1/auth/loginEmail + password login
POST/v1/auth/logoutRevoke the presented session
POST/v1/auth/password-resetRequest a one-shot password-reset link
POST/v1/auth/password-reset/confirmRedeem a reset link and set the new password
POST/v1/auth/agent-loginWidget agent login (email + password -> a ddw_ token)
GET/v1/auth/widget-configPublic widget bootstrap config (the tenant's enabled origins)
POST/v1/me/passwordRotate the caller's own password

Email + password login

http
POST /v1/auth/login

Mints 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)

FieldTypeRequiredDescription
emailstring (email)yes
passwordstring (password)yes
json
{
  "email": "[email protected]",
  "password": "local-dev-password"
}

Responses

200

Session minted; token is the only time the plaintext is visible.

FieldTypeRequiredDescription
tokenstringyesPlaintext ddu_ bearer, shown exactly once. Fixed expiry (no sliding renew): re-login when it ends.
sessionobject (UserSession)yesA ddu_ login session — the token hash is never exposed.
session.idstring (uuid)no
session.user_idstring (uuid)no
session.expires_atstring (date-time)noFixed at login (no sliding renew); 24h engine ceiling.
session.revoked_atstring (date-time) | nullno
session.created_atstring (date-time)no
userobject (User)yesA dashboard user — the password hash is never exposed.
user.idstring (uuid)no
user.emailstring (email)noGlobal login identifier, stored lowercase.
user.namestringno
user.statusactive · disabledno
user.rolemember · account_adminnoIntra-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_atstring (date-time)no
user.last_login_atstring (date-time) | nullno
user.created_atstring (date-time)no
user.updated_atstring (date-time)no
tenantobject (Tenant)yes
tenant.idstring (uuid)no
tenant.namestringno
tenant.statusactive · suspendedno
tenant.retention_monthsintegerno
tenant.seatsintegerno
tenant.tierstarter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shieldno
tenant.voice_ai_enabledbooleanno
tenant.ai_monthly_budget_usdnumber | nullnoCost-integrity ceiling (admin-set; null = no cap).
tenant.ai_max_call_secondsinteger | nullnoPer-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_effectiveintegernoThe 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_usdnumber | nullnoMonth-to-date SMS spend cap.
tenant.human_initiated_enabledbooleannoD17 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_cpsinteger | nullnoPer-tenant CPS ceiling (admin-set; null = no ceiling). ADR
tenant.max_concurrent_channelsinteger | nullnoPer-tenant concurrent-channel ceiling (admin-set; null = no ceiling). ADR
tenant.abandon_seller_namestring | nullnoSeller name for the FTC TSR abandoned-call identification message (admin-set; null = not configured, predictive over-dial stays dark). ADR #14.
tenant.abandon_seller_phonestring | nullnoE.164 callback number for the abandoned-call identification message (admin-set; null = not configured). ADR #14.
tenant.tax_countrystring | nullnoISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77.
tenant.tax_regionstring | nullnoState/province code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77.
tenant.tax_postal_codestring | nullnoPostal code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77.
tenant.created_atstring (date-time)no
tenant.updated_atstring (date-time)no
json
{
  "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).

json
{
  "error": {
    "code": "bad_request",
    "message": "from must be RFC-3339"
  }
}

401

Invalid credentials — unknown email, wrong password and disabled user are indistinguishable.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid email or password"
  }
}

403 — Forbidden

The tenant is suspended.

json
{
  "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.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many login attempts; retry later"
  }
}

Revoke the presented session

http
POST /v1/auth/logout

Revokes 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).

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

Request a one-shot password-reset link

http
POST /v1/auth/password-reset

Mails 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)

FieldTypeRequiredDescription
emailstring (email)yes
json
{
  "email": "[email protected]"
}

Responses

202

Accepted. Identical for every outcome (see the description).

FieldTypeRequiredDescription
statusacceptedyes
messagestringyes
json
{
  "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).

json
{
  "error": {
    "code": "bad_request",
    "message": "from must be RFC-3339"
  }
}

Redeem a reset link and set the new password

http
POST /v1/auth/password-reset/confirm

Burns 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)

FieldTypeRequiredDescription
tokenstringyesThe plaintext from the e-mailed link. Single use.
new_passwordstring (password)yes
json
{
  "token": "ddr_example_plaintext_from_the_link",
  "new_password": "an-even-better-password"
}

Responses

200

Password written, token burned, every live session revoked.

FieldTypeRequiredDescription
statusokyes
json
{
  "status": "ok"
}

400 — BadRequest

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "error": {
    "code": "bad_request",
    "message": "from must be RFC-3339"
  }
}

401

Unknown, tampered, expired, superseded or already-redeemed token — indistinguishable by design.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or expired token"
  }
}

422 — Unprocessable

Shape is fine, semantics are not (broken domain rule).

json
{
  "error": {
    "code": "unprocessable",
    "message": "scheduled_at must be at least 10 minutes out"
  }
}

Widget agent login (email + password -> a ddw_ token)

http
POST /v1/auth/agent-login

Signs 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)

FieldTypeRequiredDescription
tenant_idstring (uuid)yesThe widget's public workspace id (non-secret).
emailstring (email)yes
passwordstring (password)yes
json
{
  "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.

FieldTypeRequiredDescription
tokenstringyesPlaintext ddw_ browser token, shown exactly once.
widget_tokenobject (WidgetToken)yesA ddw_ browser widget token — the plaintext + hash are never exposed here.
widget_token.idstring (uuid)no
widget_token.agent_idstring (uuid)no
widget_token.labelstringno
widget_token.allowed_origin_idstring (uuid) | nullnoOptional 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_atstring (date-time)no
widget_token.session_expires_atstring (date-time)no
widget_token.revoked_atstring (date-time) | nullno
widget_token.last_used_atstring (date-time) | nullno
widget_token.created_atstring (date-time)no
widget_token.updated_atstring (date-time)no
agentobject (Agent)yes
agent.idstring (uuid)no
agent.tenant_idstring (uuid)no
agent.namestringno
agent.emailstringno
agent.roleagent · supervisor · adminno
agent.statusactive · inactivenoRoster flag — NOT presence.
agent.sip_extensionstring | nullno
agent.device_modebrowser · externalno
agent.login_enabledbooleannoWidget self-login state — true iff a password credential is set. The password hash itself is never exposed.
agent.presenceoffline · available · ringing · on_call · wrap_up · pausednoLive 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_atstring (date-time)no
agent.updated_atstring (date-time)no
tenantobject (Tenant)yes
tenant.idstring (uuid)no
tenant.namestringno
tenant.statusactive · suspendedno
tenant.retention_monthsintegerno
tenant.seatsintegerno
tenant.tierstarter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shieldno
tenant.voice_ai_enabledbooleanno
tenant.ai_monthly_budget_usdnumber | nullnoCost-integrity ceiling (admin-set; null = no cap).
tenant.ai_max_call_secondsinteger | nullnoPer-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_effectiveintegernoThe 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_usdnumber | nullnoMonth-to-date SMS spend cap.
tenant.human_initiated_enabledbooleannoD17 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_cpsinteger | nullnoPer-tenant CPS ceiling (admin-set; null = no ceiling). ADR
tenant.max_concurrent_channelsinteger | nullnoPer-tenant concurrent-channel ceiling (admin-set; null = no ceiling). ADR
tenant.abandon_seller_namestring | nullnoSeller name for the FTC TSR abandoned-call identification message (admin-set; null = not configured, predictive over-dial stays dark). ADR #14.
tenant.abandon_seller_phonestring | nullnoE.164 callback number for the abandoned-call identification message (admin-set; null = not configured). ADR #14.
tenant.tax_countrystring | nullnoISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77.
tenant.tax_regionstring | nullnoState/province code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77.
tenant.tax_postal_codestring | nullnoPostal code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77.
tenant.created_atstring (date-time)no
tenant.updated_atstring (date-time)no
originsarray of stringyesThe 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).

json
{
  "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.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid email or password"
  }
}

403 — Forbidden

The tenant is suspended.

json
{
  "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.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many login attempts; retry later"
  }
}

Public widget bootstrap config (the tenant's enabled origins)

http
GET /v1/auth/widget-config

The 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

NameInTypeRequiredDescription
tenant_idquerystring (uuid)yesThe widget's public workspace id (non-secret).

Responses

200

The tenant's enabled origins (possibly empty).

FieldTypeRequiredDescription
originsarray of stringyes
json
{
  "origins": [
    "https://crm.acme-collections.test"
  ]
}

400 — BadRequest

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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

http
POST /v1/me/password

Requires 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)

FieldTypeRequiredDescription
current_passwordstring (password)yes
new_passwordstring (password)yes

Responses

204

Password rotated; other sessions revoked.

400 — BadRequest

Malformed request (shape/type errors, invalid filters, bad timestamps).

json
{
  "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).

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid or revoked API key"
  }
}

403 — Forbidden

The tenant is suspended.

json
{
  "error": {
    "code": "forbidden",
    "message": "tenant is suspended"
  }
}

422

The current password is incorrect.

json
{
  "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.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many failed password confirmations; retry later"
  }
}

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.