Skip to content

Auth ​

Generated from DialerDigital/core commit 0fc18008b3c8 — spec openapi/dialer-v1.yaml v0.134.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.

Español

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
GET/v1/auth/sign-in-eventsAudit trail of the tenant's sign-ins
POST/v1/auth/mfa/verifySecond step of a login with a second factor
POST/v1/auth/mfa/enrollStart the second-factor enrollment a tenant requires at login
POST/v1/auth/mfa/enroll/confirmConfirm the forced enrollment and finish the login
POST/v1/auth/password-resetRequest a one-shot password-reset link
POST/v1/auth/password-reset/confirmRedeem a reset or invitation link and set the 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
GET/v1/me/mfaThe caller's own second-factor state
POST/v1/me/mfa/enrollStart TOTP enrollment
POST/v1/me/mfa/confirmConfirm enrollment with the first code
POST/v1/me/mfa/disableRemove the second factor
POST/v1/me/mfa/recovery-codesRegenerate the recovery codes

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. A tenant that has an ENABLED SAML IdP and the founder-governed flag sso_required_enforced on has closed this lane: proven-correct credentials of that tenant are a 403sso_required (the way in is POST /v1/auth/sso/start). With the founder-governed flag single_session_enforced on, a login of ANY lane (password, second factor, forced enrollment, SSO) ends the person's other live ddu_ sessions; the screen that held one gets 401 session_superseded on its next request.

A tenant that published a login policy (allowed addresses and hours per role, ADR 227) in mode enforce refuses a sign-in from an address outside it or at an hour outside it with ONE 403 login_policy_denied, the same for the address and the hour: which of them failed is told only by the tenant's sign-in trail (GET /v1/auth/sign-in-events). Asked AFTER the credential proved right and before anything is issued. With the policy off or absent the sign-in is the one of before.

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 ​

Either the session (token is the only time the plaintext is visible) or, when the account has a second factor active, mfa_required: true and a one-shot challenge to redeem at POST /v1/auth/mfa/verify — NO session exists yet and the challenge opens no other route. When the tenant REQUIRES a second factor (flag mfa_required_enforced, off by default) and the account has none yet, the answer is mfa_required: true with purpose: enroll and a challenge that only opens POST /v1/auth/mfa/enroll and POST /v1/auth/mfa/enroll/confirm.

session

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"
  }
}

mfa_required

json
{
  "mfa_required": true,
  "challenge": "ddm_example_challenge_shown_once",
  "expires_at": "2026-07-10T10:05:00Z",
  "methods": [
    "totp",
    "recovery_code"
  ]
}

mfa_enrollment_required

json
{
  "mfa_required": true,
  "purpose": "enroll",
  "challenge": "ddm_example_enroll_challenge_shown_once",
  "expires_at": "2026-07-10T10:05:00Z"
}

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. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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. Retry-After is the time left in the fixed window.

/v1/auth/login has a second, CUMULATIVE reason for the same 429 (core#2335, NIST SP 800-63B-4 §3.2.2): once a person accumulates login_progressive_threshold_failures (default 20) wrong passwords since their last successful sign-in, inside 24 h, the login is held even for the CORRECT password for 15 min after the last failure, doubling for every further threshold-many failures (capped at 6 h); Retry-After is the time left. A pause, never a permanent lock. Held attempts are not recorded, so they do not lengthen it.

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.

A session born from SSO whose identity provider published a SingleLogoutService (slo_url of the IdP configuration) answers 200 with sso_logout_url: the URL of the LogoutRequest (HTTP-Redirect, unsigned) the browser may follow to end the session at the IdP too. The local revocation has ALREADY happened and never depends on the IdP; every other session is 204.

Responses

200 ​

The session is revoked and the IdP offers a Single Logout. no-store.

FieldTypeRequiredDescription
sso_logout_urlstring (uri)yes

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

Audit trail of the tenant's sign-ins ​

http
GET /v1/auth/sign-in-events

The append-only record of every sign-in attempt of the tenant's people — password, second factor and SSO; granted and refused — newest first, keyset-paginated. Each row carries the method, the outcome, a closed reason for a refusal, the caller's ip and user_agent (truncated to 256 characters), and, for a grant that minted a session, its session_id. It never carries a password, a code or a token.

reason is null on a grant, except mfa_pending: the password was right but the sign-in still owes the second factor (or its enrollment), so no session was minted; and observed_ip_outside / observed_hours_outside, written when the tenant's login policy is in mode observe: the sign-in was granted and the row says it would have been refused. On a refusal it is one of bad_password, unknown_user, disabled, suspended, sso_required, mfa_failed, sso_rejected, or, from the tenant's login policy, ip_not_allowed and outside_hours (mode enforce) and policy_unavailable (the policy could not be read or understood, in either mode: the sign-in is refused). An address that matched nobody in THIS tenant appears only as email_attempted, a sha256 digest of the normalized address taken with a fixed purpose prefix (not the bare sha256 of the address) — never the text.

A grant is written in the same transaction that mints the session, so a session without its row cannot exist; a refusal is written right after and is best-effort. An SSO response whose signature does not verify is NOT recorded (the endpoint is anonymous); one that does, and is then refused, is. Rows are never edited or deleted.

Filters: user_id, outcome (success | failure), since (inclusive) and until (exclusive), both RFC-3339 on occurred_at. A filter that does not parse is 400 naming the accepted values — never a silently empty page. Tenant-scoped: another tenant's rows are never visible, whatever the filter.

account_admin dashboard login only (see 403).

Parameters

NameInTypeRequiredDescription
user_idquerystring (uuid)no
outcomequerysuccess · failureno
sincequerystring (date-time)noOnly events at or after this instant.
untilquerystring (date-time)noOnly events strictly before this instant.
limitqueryintegerno
cursorquerystringnoOpaque keyset cursor — the next_cursor of the previous page.

Responses

200 ​

One page of sign-in events, newest first.

FieldTypeRequiredDescription
eventsarray of SignInEventyes
events[].idstring (uuid)yes
events[].user_idstring (uuid) | nullyesThe person the attempt is attributed to; null when the address matched nobody.
events[].email_attemptedstring | nullyessha256 (hex) of the normalized address taken with a fixed purpose prefix (not the bare sha256 of the address); only when no person matched. Never the address.
events[].methodpassword · mfa · ssoyes
events[].outcomesuccess · failureyes
events[].reasonbad_password · unknown_user · disabled · suspended · sso_required · mfa_failed · sso_rejected · mfa_pending · ip_not_allowed · outside_hours · policy_unavailable · observed_ip_outside · observed_hours_outside | nullyes
events[].ipstring | nullyesThe caller's address as the platform attributes it (trusted proxies only).
events[].user_agentstring | nullyes
events[].session_idstring (uuid) | nullyesThe session the grant minted; null on a refusal and on mfa_pending.
events[].occurred_atstring (date-time)yes
next_cursorstring | nullyesCursor of the next page; null on the last one.

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 ​

role_forbidden: anything but a dashboard login (ddu_) whose person has the account_admin role — a dd_ API key and a member login included; forbidden: a widget token (ddw_) or a suspended tenant.

json
{
  "error": {
    "code": "role_forbidden",
    "message": "the sign-in audit trail takes a dashboard login with the account_admin role"
  }
}

429 — ApiKeyRateLimited ​

This API key is over its request quota, or the tenant's keys together are over the tenant ceiling (core#2503) (code rate_limited). The request was not processed. Wait Retry-After seconds and retry; a refusal is not counted against the budget, so that wait is always enough. Bounded per API key and, above the keys, per tenant (api_tenant_requests_per_minute): a sibling key is unaffected until the tenant's keys together reach that ceiling. The quota applies to every operation reachable with a dd_ key, so the operations that name a more specific 429 (call control, dispositions, SMS, …) can ALSO answer this one with its X-RateLimit-* headers.

json
{
  "error": {
    "code": "rate_limited",
    "message": "API key request quota exceeded; retry after the interval in Retry-After"
  }
}

Second step of a login with a second factor ​

http
POST /v1/auth/mfa/verify

Redeems the challenge returned by POST /v1/auth/login with a TOTP code or ONE single-use recovery_code (exactly one of the two) and mints the ddu_ session the first step withheld. The response body is the same as a one-step login.

The challenge is single-use and dies after 5 minutes, after 5 wrong codes, or when the person's password changes in between; an unknown, expired, spent or out-of-attempts challenge is ONE identical 401. A TOTP code is accepted at most once (a replay of the same code is refused) and a recovery code is burned by its first use. Wrong codes are rate-limited per USER (429).

A tenant that published a login policy (allowed addresses and hours per role, ADR 227) in mode enforce refuses a sign-in from an address outside it or at an hour outside it with ONE 403 login_policy_denied, the same for the address and the hour: which of them failed is told only by the tenant's sign-in trail (GET /v1/auth/sign-in-events). Asked AFTER the credential proved right and before anything is issued. With the policy off or absent the sign-in is the one of before. It is asked again here, before the code is compared, so a refusal spends neither the challenge nor an attempt.

Auth: none — authenticated by request signature (see below).

Request body (JSON, required)

FieldTypeRequiredDescription
challengestringyesThe ddm_ challenge from the login.
codestringnoTOTP code, 6 digits.
recovery_codestringnoOne recovery code, XXXX-XXXX-XXXX-XXXX.

Responses

200 ​

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

FieldTypeRequiredDescription
tokenstringyesPlaintext ddu_ bearer, shown exactly once.
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.mfa_enabledbooleannoWhether the person has an ACTIVE second factor (an enrolment still pending confirmation is false). Emitted ONLY when the caller is an account_admin dashboard login — the one who can act on it with POST /v1/users/{id}/mfa/remove; for a member, a dd_ key, or when the caller's role cannot be determined the field is ABSENT (not false). Wherever a User is rendered, the same rule holds: GET /v1/users, GET /v1/me, the user returned by DELETE /v1/users/{id} and POST /v1/users/{id}/enable, and the user in the session responses (POST /v1/auth/login, POST /v1/auth/sso/exchange, and the two MFA completion routes) — there, for the account_admin who just signed in.
user.created_atstring (date-time)no
user.updated_atstring (date-time)no
tenantobject (Tenant)yes
tenant.idstring (uuid)no
tenant.namestringno
tenant.account_idstring (uuid)noThe EMPRESA this tenant (sede) belongs to (DA-2).
tenant.billing_account_idstring (uuid)noThe billing account the sede is invoiced under (DA-3). Shared by every sede of a consolidated empresa; one per sede when per_site.
tenant.statusactive · suspendedno
tenant.syntheticbooleannotrue for a demonstration tenant. It originates no traffic: manual dial, SMS send, the external leg of a transfer and the sandbox answer 403 synthetic_tenant. Always present, never null, so a console can warn BEFORE the action fails (core#2512).
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 #12.
tenant.max_concurrent_channelsinteger | nullnoPer-tenant ceiling of concurrent calls, one unit per call with or without an agent (admin-set; null = no ceiling). ADR #12, ADR #185.
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.kyc_verified_atstring (date-time) | nullnoWhen the platform verified this customer (admin-set; null = not yet). A 1-seat account that is not verified cannot create or start a campaign that runs without an agent (403 verification_required, ADR #279).
tenant.created_atstring (date-time)no
tenant.updated_atstring (date-time)no

400 — BadRequest ​

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

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

401 ​

Wrong code, or an unknown/expired/spent challenge.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid code"
  }
}

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

429 — MfaVerifyThrottled ​

Too many FAILED second-factor attempts for this USER inside the fixed window (retryable later). code rate_limited. Keyed on the person, never on a network address, evaluated BEFORE the code is compared, and SHARED by POST /v1/auth/mfa/verify, the forced-enrollment routes (/v1/auth/mfa/enroll*) and every /v1/me/mfa/* route that compares a code or password. A correct attempt is not counted once it completes; while one is being verified it holds a slot, so simultaneous requests never get more comparisons than the budget (ADR #144). Retry-After is the time left in the fixed window.

It is also the answer of the CUMULATIVE brake (core#1572): once a person piles up mfa_progressive_threshold_failures refused codes (default 20) inside mfa_progressive_window_seconds (default 24 h), a TOTP is not even compared until mfa_progressive_cooldown_seconds (default 15 min) have passed since the last failure, and Retry-After is that wait. A recovery code is never held by it, and an account_admin can lift it (POST /v1/users/{id}/mfa/reset-failures). It is not a lockout: the person is slowed, not shut out.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many failed second-factor attempts; retry later"
  }
}

Start the second-factor enrollment a tenant requires at login ​

http
POST /v1/auth/mfa/enroll

First half of the FORCED enrollment: presents the challenge of purpose enroll that POST /v1/auth/login returned (mfa_required: true, purpose: enroll) and gets a fresh TOTP secret, left pending until POST /v1/auth/mfa/enroll/confirm. No password is asked again: the challenge exists only because the right password was just proven.

Starting spends no attempt of the challenge; repeating it replaces the pending secret. The secret is shown exactly once. A challenge of purpose login (and an unknown, expired, spent or out-of-attempts one) is ONE identical 401. An account that already has an active factor is 409. Rate-limited per USER with the same budget as POST /v1/auth/mfa/verify.

A tenant that published a login policy (allowed addresses and hours per role, ADR 227) in mode enforce refuses a sign-in from an address outside it or at an hour outside it with ONE 403 login_policy_denied, the same for the address and the hour: which of them failed is told only by the tenant's sign-in trail (GET /v1/auth/sign-in-events). Asked AFTER the credential proved right and before anything is issued. With the policy off or absent the sign-in is the one of before. The secret is not handed out to a refused address or hour.

Auth: none — authenticated by request signature (see below).

Request body (JSON, required)

FieldTypeRequiredDescription
challengestringyesThe ddm_ enroll challenge from the login.

Responses

200 ​

Pending secret; encode otpauth_uri as the QR.

FieldTypeRequiredDescription
secretstringyesTOTP secret, base32; shown exactly once.
otpauth_uristringyesotpauth://totp/... — encode it as the QR.

400 — BadRequest ​

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

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

401 ​

Unknown, expired, spent, out-of-attempts or wrong-purpose challenge.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

409 — Conflict ​

Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.

json
{
  "error": {
    "code": "conflict",
    "message": "invalid state transition"
  }
}

429 — MfaVerifyThrottled ​

Too many FAILED second-factor attempts for this USER inside the fixed window (retryable later). code rate_limited. Keyed on the person, never on a network address, evaluated BEFORE the code is compared, and SHARED by POST /v1/auth/mfa/verify, the forced-enrollment routes (/v1/auth/mfa/enroll*) and every /v1/me/mfa/* route that compares a code or password. A correct attempt is not counted once it completes; while one is being verified it holds a slot, so simultaneous requests never get more comparisons than the budget (ADR #144). Retry-After is the time left in the fixed window.

It is also the answer of the CUMULATIVE brake (core#1572): once a person piles up mfa_progressive_threshold_failures refused codes (default 20) inside mfa_progressive_window_seconds (default 24 h), a TOTP is not even compared until mfa_progressive_cooldown_seconds (default 15 min) have passed since the last failure, and Retry-After is that wait. A recovery code is never held by it, and an account_admin can lift it (POST /v1/users/{id}/mfa/reset-failures). It is not a lockout: the person is slowed, not shut out.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many failed second-factor attempts; retry later"
  }
}

Confirm the forced enrollment and finish the login ​

http
POST /v1/auth/mfa/enroll/confirm

Second half of the FORCED enrollment: the first TOTP code of the pending secret activates the factor, issues the recovery codes, consumes the challenge and mints the ddu_ session the login withheld — all in one transaction. The body is the login's session body plus recovery_codes, shown in clear exactly once.

The challenge dies after 5 minutes, after 5 wrong codes, or when the person's password changes in between. A wrong code is 401 invalid code and counts against the per-USER budget (429); an unknown, expired, spent or wrong-purpose challenge is 401 invalid or expired challenge. 409 when no enrollment was started.

A tenant that published a login policy (allowed addresses and hours per role, ADR 227) in mode enforce refuses a sign-in from an address outside it or at an hour outside it with ONE 403 login_policy_denied, the same for the address and the hour: which of them failed is told only by the tenant's sign-in trail (GET /v1/auth/sign-in-events). Asked AFTER the credential proved right and before anything is issued. With the policy off or absent the sign-in is the one of before. A refusal spends neither the challenge nor an attempt.

Auth: none — authenticated by request signature (see below).

Request body (JSON, required)

FieldTypeRequiredDescription
challengestringyesThe ddm_ enroll challenge from the login.
codestringyesTOTP code, 6 digits.

Responses

200 ​

Factor active, session minted; token is the only time the plaintext is visible.

FieldTypeRequiredDescription
tokenstringyesPlaintext ddu_ bearer, shown exactly once.
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.mfa_enabledbooleannoWhether the person has an ACTIVE second factor (an enrolment still pending confirmation is false). Emitted ONLY when the caller is an account_admin dashboard login — the one who can act on it with POST /v1/users/{id}/mfa/remove; for a member, a dd_ key, or when the caller's role cannot be determined the field is ABSENT (not false). Wherever a User is rendered, the same rule holds: GET /v1/users, GET /v1/me, the user returned by DELETE /v1/users/{id} and POST /v1/users/{id}/enable, and the user in the session responses (POST /v1/auth/login, POST /v1/auth/sso/exchange, and the two MFA completion routes) — there, for the account_admin who just signed in.
user.created_atstring (date-time)no
user.updated_atstring (date-time)no
tenantobject (Tenant)yes
tenant.idstring (uuid)no
tenant.namestringno
tenant.account_idstring (uuid)noThe EMPRESA this tenant (sede) belongs to (DA-2).
tenant.billing_account_idstring (uuid)noThe billing account the sede is invoiced under (DA-3). Shared by every sede of a consolidated empresa; one per sede when per_site.
tenant.statusactive · suspendedno
tenant.syntheticbooleannotrue for a demonstration tenant. It originates no traffic: manual dial, SMS send, the external leg of a transfer and the sandbox answer 403 synthetic_tenant. Always present, never null, so a console can warn BEFORE the action fails (core#2512).
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 #12.
tenant.max_concurrent_channelsinteger | nullnoPer-tenant ceiling of concurrent calls, one unit per call with or without an agent (admin-set; null = no ceiling). ADR #12, ADR #185.
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.kyc_verified_atstring (date-time) | nullnoWhen the platform verified this customer (admin-set; null = not yet). A 1-seat account that is not verified cannot create or start a campaign that runs without an agent (403 verification_required, ADR #279).
tenant.created_atstring (date-time)no
tenant.updated_atstring (date-time)no
recovery_codesarray of stringyesSingle-use codes, shown in clear exactly once.

400 — BadRequest ​

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

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

401 ​

Wrong code, or an unknown/expired/spent/wrong-purpose challenge.

json
{
  "error": {
    "code": "unauthorized",
    "message": "invalid code"
  }
}

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

409 — Conflict ​

Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.

json
{
  "error": {
    "code": "conflict",
    "message": "invalid state transition"
  }
}

429 — MfaVerifyThrottled ​

Too many FAILED second-factor attempts for this USER inside the fixed window (retryable later). code rate_limited. Keyed on the person, never on a network address, evaluated BEFORE the code is compared, and SHARED by POST /v1/auth/mfa/verify, the forced-enrollment routes (/v1/auth/mfa/enroll*) and every /v1/me/mfa/* route that compares a code or password. A correct attempt is not counted once it completes; while one is being verified it holds a slot, so simultaneous requests never get more comparisons than the budget (ADR #144). Retry-After is the time left in the fixed window.

It is also the answer of the CUMULATIVE brake (core#1572): once a person piles up mfa_progressive_threshold_failures refused codes (default 20) inside mfa_progressive_window_seconds (default 24 h), a TOTP is not even compared until mfa_progressive_cooldown_seconds (default 15 min) have passed since the last failure, and Retry-After is that wait. A recovery code is never held by it, and an account_admin can lift it (POST /v1/users/{id}/mfa/reset-failures). It is not a lockout: the person is slowed, not shut out.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many failed second-factor attempts; retry later"
  }
}

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
localees · ennoLanguage the person has the console in; the mail goes out in it. Absent or anything else: the cell's language (DEFAULT_LOCALE). Never changes the answer — the 202 is the same byte for byte.
json
{
  "email": "[email protected]",
  "locale": "es"
}

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 or invitation link and set the 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.

The token may also come from an INVITATION (POST /v1/users, the /accept-invite link; ADR #182): then this call CREATES the person, in the inviting tenant, with this password, and burns the invitation.

Fail-closed and single-valued: an unknown, tampered, expired, superseded or already-redeemed token — and an invitation whose address became a user anywhere in the meantime — are ONE identical 401. The password policy (12–128 characters, the same one every dashboard password follows) 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 (or, for an invitation, the person created), 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, or an invitation whose address is taken — indistinguishable by design.

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

422 ​

The new password is refused and NOTHING changed (the link stays redeemable): unprocessable when it does not meet the length policy, or password_blocklisted (core#2335, NIST SP 800-63B-4 §3.1.1.2) when it is on the local blocklist of commonly used or compromised passwords, contains the person's e-mail or the workspace's name, or is trivially repetitive. The message names the reason (common, repetitive, contains_email, contains_tenant). Existing passwords are never checked.

json
{
  "error": {
    "code": "password_blocklisted",
    "message": "password is on the list of commonly used or compromised passwords (reason: common)"
  }
}

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, a tenant without the widget lane, unknown email, wrong password and a login-not-enabled agent are ONE identical 401 at the same PBKDF2 cost, and none of them mints a token; only proven-correct credentials of a suspended tenant/account earn the 403. Per-(tenant, email) fixed-window brake. Flag-gated on the widget lane (governed key widget_auth_enabled, per tenant under a cell ceiling): while the cell's ceiling is closed, 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.

The door is not softer than the console's (core#2406, ADR #227 addendum; tenant flag widget_login_honors_policy, ON by default). Asked AFTER the password proved right and before anything is minted: the tenant's login policy (address and hours) — refused is 403 login_policy_denied, the same body as the console's, and the same question is asked again on every later ddw_ request, so a live token that moves to a refused address stops working there. A seat linked to a person is judged with that person's role; a seat with no person with the role agent (it is bound by the address list, and by no hours window, which belong to a person). And, in a tenant with mfa_required_enforced ON, the second factor: a seat linked to a person must send that person's current TOTP as mfa_code (a wrong one is the same 401 as a wrong password and counts in the brake); no mfa_code, a seat with no person, or a person with no active factor is 403 mfa_required — a widget cannot enroll one. With the flag OFF, or a tenant with no policy and no second factor, the login is the one of before.

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
mfa_codestringnoThe current TOTP of the person linked to the seat. Needed only when the tenant has mfa_required_enforced ON.
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) | nullnoRotation-hygiene clock, same meaning as ApiKey.last_used_at: the last time this token resolved SUCCESSFULLY (every gate cleared), written at most once a minute per token, so a fresh reading may lag by up to that much. null means it has not resolved successfully since the column acquired a hot-path writer — not that the token was never presented. Renewal also stamps it.
widget_token.created_atstring (date-time)no
widget_token.updated_atstring (date-time)noDates the ROW, not the mint: the use clock above and the lifecycle writes (renew, teardown) all move it.
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.pause_reasonmanual · break · lunch · training · meeting | nullnoWhy the seat is paused; null unless presence is paused (core#2542).
agent.state_sincestring (date-time) | nullnoWhen the seat entered its current presence (RFC 3339); null when the agent has no seat (core#2542).
agent.pause_armedbooleannoA pause is armed for when the current call ends (POST /v1/agents/{id}/pause with on_release: true).
agent.created_atstring (date-time)no
agent.updated_atstring (date-time)no
tenantobject (Tenant)yes
tenant.idstring (uuid)no
tenant.namestringno
tenant.account_idstring (uuid)noThe EMPRESA this tenant (sede) belongs to (DA-2).
tenant.billing_account_idstring (uuid)noThe billing account the sede is invoiced under (DA-3). Shared by every sede of a consolidated empresa; one per sede when per_site.
tenant.statusactive · suspendedno
tenant.syntheticbooleannotrue for a demonstration tenant. It originates no traffic: manual dial, SMS send, the external leg of a transfer and the sandbox answer 403 synthetic_tenant. Always present, never null, so a console can warn BEFORE the action fails (core#2512).
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 #12.
tenant.max_concurrent_channelsinteger | nullnoPer-tenant ceiling of concurrent calls, one unit per call with or without an agent (admin-set; null = no ceiling). ADR #12, ADR #185.
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.kyc_verified_atstring (date-time) | nullnoWhen the platform verified this customer (admin-set; null = not yet). A 1-seat account that is not verified cannot create or start a campaign that runs without an agent (403 verification_required, ADR #279).
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, a tenant without the widget lane and a login-disabled agent are indistinguishable.

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

403 ​

The tenant is suspended (forbidden); or the right credentials were refused: login_policy_denied (the tenant's login policy refuses this address or hour, the same body as the console's) or mfa_required (the tenant requires a second factor and the seat cannot present one).

json
{
  "error": {
    "code": "mfa_required",
    "message": "this workspace requires a second factor for this sign-in"
  }
}

404 ​

The widget lane's cell ceiling is closed (WIDGET_AUTH_ENABLED off on this deployment).

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. Retry-After is the time left in the fixed window.

/v1/auth/login has a second, CUMULATIVE reason for the same 429 (core#2335, NIST SP 800-63B-4 §3.2.2): once a person accumulates login_progressive_threshold_failures (default 20) wrong passwords since their last successful sign-in, inside 24 h, the login is held even for the CORRECT password for 15 min after the last failure, doubling for every further threshold-many failures (capped at 6 h); Retry-After is the time left. A pause, never a permanent lock. Held attempts are not recorded, so they do not lengthen it.

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, and a tenant without the widget lane, answer origins: [] — no tenant-existence oracle. Flag-gated on the widget lane (governed key widget_auth_enabled): while the cell's ceiling is closed, 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's cell ceiling is closed (WIDGET_AUTH_ENABLED off on this deployment).

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

422 ​

The current password is incorrect (unprocessable), or the new one is on the local blocklist of commonly used or compromised passwords, contains the person's e-mail or the workspace's name, or is trivially repetitive (password_blocklisted, core#2335, NIST SP 800-63B-4 §3.1.1.2; the message names the reason). Nothing changed, and a blocklisted new password does not spend the wrong-confirmation budget.

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 once it completes, but while any confirmation is being verified it holds one slot of the budget, so simultaneous requests can never get more verifications than the budget (core#1114); a burst larger than the budget answers 429 to the excess even if some of it carried the right password. Retry-After is the time left in the fixed window.

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

The caller's own second-factor state ​

http
GET /v1/me/mfa

Requires a ddu_ login session (403 for a dd_ key).

Responses

200 ​

State.

FieldTypeRequiredDescription
enabledbooleanyes
recovery_codes_remainingintegeryes

401 — Unauthorized ​

Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

Start TOTP enrollment ​

http
POST /v1/me/mfa/enroll

Issues a fresh TOTP secret, PENDING until POST /v1/me/mfa/confirm proves the authenticator holds it. The secret and its otpauth:// URI (the QR payload) are shown exactly once; the secret is stored encrypted. Repeating the call before confirming replaces the pending secret. With a factor already active it is 409: remove it first. Requires the current password (there is no factor yet to ask for), so a stolen session alone cannot lock the holder out with a factor of its own; a wrong password is rate-limited per USER (429). Requires a ddu_ session.

Request body (JSON, required)

FieldTypeRequiredDescription
passwordstring (password)yes

Responses

200 ​

Secret and URI, shown once.

FieldTypeRequiredDescription
secretstringyesTOTP secret, base32; shown exactly once.
otpauth_uristringyesotpauth://totp/... — encode it as the QR.

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

409 ​

A second factor is already active.

json
{
  "error": {
    "code": "conflict",
    "message": "a second factor is already active; remove it first"
  }
}

422 ​

The password is incorrect.

json
{
  "error": {
    "code": "unprocessable",
    "message": "the password is incorrect"
  }
}

429 — MfaVerifyThrottled ​

Too many FAILED second-factor attempts for this USER inside the fixed window (retryable later). code rate_limited. Keyed on the person, never on a network address, evaluated BEFORE the code is compared, and SHARED by POST /v1/auth/mfa/verify, the forced-enrollment routes (/v1/auth/mfa/enroll*) and every /v1/me/mfa/* route that compares a code or password. A correct attempt is not counted once it completes; while one is being verified it holds a slot, so simultaneous requests never get more comparisons than the budget (ADR #144). Retry-After is the time left in the fixed window.

It is also the answer of the CUMULATIVE brake (core#1572): once a person piles up mfa_progressive_threshold_failures refused codes (default 20) inside mfa_progressive_window_seconds (default 24 h), a TOTP is not even compared until mfa_progressive_cooldown_seconds (default 15 min) have passed since the last failure, and Retry-After is that wait. A recovery code is never held by it, and an account_admin can lift it (POST /v1/users/{id}/mfa/reset-failures). It is not a lockout: the person is slowed, not shut out.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many failed second-factor attempts; retry later"
  }
}

Confirm enrollment with the first code ​

http
POST /v1/me/mfa/confirm

Activates the pending factor with a valid TOTP code and returns the recovery codes, in clear, exactly once (only their sha256 is stored). Wrong codes are rate-limited per USER (429). Requires a ddu_ session.

Request body (JSON, required)

FieldTypeRequiredDescription
codestringyes

Responses

200 ​

Factor active; recovery codes shown once.

FieldTypeRequiredDescription
recovery_codesarray of stringyesSingle-use codes, shown in clear exactly once.

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

409 ​

No pending enrollment.

422 ​

The code is incorrect.

json
{
  "error": {
    "code": "unprocessable",
    "message": "the code is incorrect"
  }
}

429 — MfaVerifyThrottled ​

Too many FAILED second-factor attempts for this USER inside the fixed window (retryable later). code rate_limited. Keyed on the person, never on a network address, evaluated BEFORE the code is compared, and SHARED by POST /v1/auth/mfa/verify, the forced-enrollment routes (/v1/auth/mfa/enroll*) and every /v1/me/mfa/* route that compares a code or password. A correct attempt is not counted once it completes; while one is being verified it holds a slot, so simultaneous requests never get more comparisons than the budget (ADR #144). Retry-After is the time left in the fixed window.

It is also the answer of the CUMULATIVE brake (core#1572): once a person piles up mfa_progressive_threshold_failures refused codes (default 20) inside mfa_progressive_window_seconds (default 24 h), a TOTP is not even compared until mfa_progressive_cooldown_seconds (default 15 min) have passed since the last failure, and Retry-After is that wait. A recovery code is never held by it, and an account_admin can lift it (POST /v1/users/{id}/mfa/reset-failures). It is not a lockout: the person is slowed, not shut out.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many failed second-factor attempts; retry later"
  }
}

Remove the second factor ​

http
POST /v1/me/mfa/disable

Reauthentication required: the current password AND a second factor (a TOTP code or a recovery_code, exactly one). Revokes the unused recovery codes. Wrong credentials are rate-limited per USER (429). Requires a ddu_ session.

Request body (JSON, required)

FieldTypeRequiredDescription
passwordstring (password)yes
codestringno
recovery_codestringno

Responses

204 ​

Second factor removed.

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

409 ​

No active second factor.

422 ​

The password or the code is incorrect.

429 — MfaVerifyThrottled ​

Too many FAILED second-factor attempts for this USER inside the fixed window (retryable later). code rate_limited. Keyed on the person, never on a network address, evaluated BEFORE the code is compared, and SHARED by POST /v1/auth/mfa/verify, the forced-enrollment routes (/v1/auth/mfa/enroll*) and every /v1/me/mfa/* route that compares a code or password. A correct attempt is not counted once it completes; while one is being verified it holds a slot, so simultaneous requests never get more comparisons than the budget (ADR #144). Retry-After is the time left in the fixed window.

It is also the answer of the CUMULATIVE brake (core#1572): once a person piles up mfa_progressive_threshold_failures refused codes (default 20) inside mfa_progressive_window_seconds (default 24 h), a TOTP is not even compared until mfa_progressive_cooldown_seconds (default 15 min) have passed since the last failure, and Retry-After is that wait. A recovery code is never held by it, and an account_admin can lift it (POST /v1/users/{id}/mfa/reset-failures). It is not a lockout: the person is slowed, not shut out.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many failed second-factor attempts; retry later"
  }
}

Regenerate the recovery codes ​

http
POST /v1/me/mfa/recovery-codes

Same reauthentication as disable. Revokes every unused code and issues a new set, shown in clear exactly once. Requires a ddu_ session.

Request body (JSON, required)

FieldTypeRequiredDescription
passwordstring (password)yes
codestringno
recovery_codestringno

Responses

200 ​

New codes, shown once.

FieldTypeRequiredDescription
recovery_codesarray of stringyesSingle-use codes, shown in clear exactly once.

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). A dashboard session (ddu_) that a newer login of the same person ended answers the code session_superseded instead (tenant flag single_session_enforced), so the screen can say why it was signed out; a logout, an expired or an unknown token stay unauthorized.

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

403 — Forbidden ​

The tenant is suspended. A dashboard session (ddu_) of a tenant whose login policy (ADR 227) is in mode enforce answers 403 with the code login_policy_denied on a request from an address or at an hour the policy does not allow — the same body for both; the session is not revoked and a later request inside the policy goes through. A widget token (ddw_) of a seat is held to the same policy on every request (core#2406, flag widget_login_honors_policy). A synthetic (demo) tenant answers 403 with the code synthetic_tenant on every route that would reach a third party — manual dial, external transfer or conference leg, SMS send (core#2251, ADR #280 addendum). No payment changes it.

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

409 ​

No active second factor.

422 ​

The password or the code is incorrect.

429 — MfaVerifyThrottled ​

Too many FAILED second-factor attempts for this USER inside the fixed window (retryable later). code rate_limited. Keyed on the person, never on a network address, evaluated BEFORE the code is compared, and SHARED by POST /v1/auth/mfa/verify, the forced-enrollment routes (/v1/auth/mfa/enroll*) and every /v1/me/mfa/* route that compares a code or password. A correct attempt is not counted once it completes; while one is being verified it holds a slot, so simultaneous requests never get more comparisons than the budget (ADR #144). Retry-After is the time left in the fixed window.

It is also the answer of the CUMULATIVE brake (core#1572): once a person piles up mfa_progressive_threshold_failures refused codes (default 20) inside mfa_progressive_window_seconds (default 24 h), a TOTP is not even compared until mfa_progressive_cooldown_seconds (default 15 min) have passed since the last failure, and Retry-After is that wait. A recovery code is never held by it, and an account_admin can lift it (POST /v1/users/{id}/mfa/reset-failures). It is not a lockout: the person is slowed, not shut out.

json
{
  "error": {
    "code": "rate_limited",
    "message": "too many failed second-factor attempts; retry later"
  }
}

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