---
title: "Authentication endpoints — API reference | Data Tactical Group"
route: "/docs/api/reference/authentication"
updated: "2026-10-23"
---

# Authentication

40 endpoints, generated from the API's own OpenAPI document. Sign-up, sign-in, verification, password reset and the second factor (better-auth). These answer \`{code, message}\` JSON, not problem documents, except where noted.

## POST `/v2/auth/sign-up/email`

**Create an account with email and password (Turnstile-guarded; a verification mail is sent)**

Credentials: none. Operation id `authSignUpEmail`.

Anonymous. Creates the better-auth user, its credential, the Taigan account and the person's own organization (named from `orgName`, else from the address), records acceptance of the Terms and Privacy Policy versions in force (submitting the form is the acceptance; a `legal.accepted` audit row is written), stores the marketing choice (opted out unless `marketingOptIn` is true), and mails a verification link. No session is created: sign-in is refused until the address is verified (the link signs the person in). Requires a Cloudflare Turnstile token in the `x-captcha-response` header, checked by Taigan before better-auth sees the request (the token is read from the header only). Anti-enumeration: signing up with an address that already has a user answers the same 200 with a synthetic user and sends nothing; if that existing user is still UNVERIFIED, its stored password is discarded (AUTH-2), and when the real owner later verifies they are signed in and sent a password-reset link to choose one. Accepts `application/json` or `application/x-www-form-urlencoded`. Not the invitation sign-up: that is `POST /v2/invites/sign-up`.

### Parameters

**`x-captcha-response` header · string required**

The Cloudflare Turnstile widget token for this form submission.

### Request body

Required; `application/json`.

**`name` string required**

The person's name (becomes the account's display name).

**`email` string (email) required**

The sign-in address. Stored lower-cased.

**`password` string required**

The password: at least 10 and at most 128 characters. (at most 128 characters)

**`orgName` string**

Name for the person's own organization. Optional; read once at creation (an auto name is used when absent).

**`marketingOptIn` boolean**

True if the person ticked the marketing-consent box. Absent or false means opted out (O26 R2). (default false)

**`callbackURL` string**

Where the verification link sends the browser after verifying (relative, or on a trusted origin). Defaults to `/`.

**`image` string**

A profile image URL. Stored, never shown.

**`rememberMe` boolean**

Accepted for compatibility; has no effect here because sign-up never creates a session.

### Responses

#### `200` success

Accepted. The same body whether or not the address was new.

As `application/json`.

**`token` null always present**

Always null: no session is created until the address is verified.

**`user` AuthUser always present**

The better-auth user (the identity behind a Taigan account; one to one with `accounts`). Timestamps are ISO 8601 strings.

**`user.id` string (uuid) always present**

The auth user's id (`auth_users.id`). Not the Taigan account id that `/v2/account` reports.

**`user.name` string always present**

The name given at sign-up or set through `POST /v2/auth/update-user`. Mirrored into the account's `display_name`.

**`user.email` string (email) always present**

The sign-in address, lower-cased.

**`user.emailVerified` boolean always present**

Whether the address has been proved by its verification link. Sign-in is refused until it is true.

**`user.image` string or null**

A profile image URL. Stored if supplied; Taigan and Kelpie never show it.

**`user.createdAt` string (date-time) always present**

When the user was created.

**`user.updatedAt` string (date-time) always present**

When the user was last changed.

**`user.orgName` string or null**

The organization name typed on the sign-up form, used once to name the person's own organization. Never read again; the organization's real name is on `/v2/account`.

**`user.marketingOptIn` boolean or null**

The person's marketing consent, kept equal to the opposite of the account's `marketing_opt_out`: set from the sign-up form's box, then by `update-user`, `PATCH /v2/account/profile` and the unsubscribe link alike.

**`user.twoFactorEnabled` boolean or null**

Whether a second factor is live for this user (set when an enrolment is confirmed, cleared by `/two-factor/disable`).

#### `400` error

Bad request. From Taigan's Turnstile guard (problem+json): [`captcha_missing`](/docs/api/errors#code-captcha-missing) (T108: the problem's own code; `errors: [{code: "captcha_missing"}]` repeats it) (no `x-captcha-response` header). From better-auth (JSON `{code, message}`): `VALIDATION_ERROR` (a required field missing or the wrong type), `INVALID_EMAIL`, `PASSWORD_TOO_SHORT` (under 10), `PASSWORD_TOO_LONG` (over 128), `BAD_REQUEST` (malformed JSON).

As `application/problem+json`, `application/json`.

#### `403` error

Forbidden. From Taigan's Turnstile guard (problem+json): [`captcha_failed`](/docs/api/errors#code-captcha-failed) (Cloudflare rejected the token). From better-auth (JSON): `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (the request carries an `Origin`, `Referer` or cookie and the origin is not one of the app's trusted origins), `CROSS_SITE_NAVIGATION_LOGIN_BLOCKED` (a cross-site form navigation), `INVALID_CALLBACK_URL` (`callbackURL` not on a trusted origin).

As `application/problem+json`, `application/json`.

#### `415` error

Unsupported media type (better-auth JSON). Code: `UNSUPPORTED_MEDIA_TYPE` (the body is neither JSON nor form-encoded).

As `application/json`.

#### `422` error

Unprocessable (better-auth JSON). Code: `FAILED_TO_CREATE_USER` (the user could not be written).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 3 sign-ups per 10 seconds per IP. Body `{message}`, no `code`.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

**`X-Retry-After` header**

Seconds to wait (better-auth's own header; `Retry-After` carries the same value).

#### `503` error

Service unavailable (problem+json). Code: [`unavailable`](/docs/api/errors#code-unavailable) (Taigan could not reach Cloudflare's Turnstile verification service).

As `application/problem+json`.

Problem codes: [`captcha_missing`](/docs/api/errors#code-captcha-missing) (400), [`captcha_failed`](/docs/api/errors#code-captcha-failed) (403), [`unavailable`](/docs/api/errors#code-unavailable) (503). Every problem is a [problem document](/docs/api/errors).

## POST `/v2/auth/sign-in/email`

**Sign in with email and password (sets the session cookie, or starts a second-factor challenge)**

Credentials: none. Operation id `authSignInEmail`.

Anonymous. On success sets the session cookie (`dtg.session_token`, `__Secure-` prefixed on https; HttpOnly, SameSite=Lax) and answers the user. Creating the session deletes every other session of the person (one active session per person, newest wins; an `auth.session_evicted` audit row is written when one was ended), records the device (an `auth.signin` audit row; a 'new sign-in from a new device' mail and bell notice when a device appears after others are known) and stamps presence (which cancels an open ownership claim on an organization the person owns). If the person has a live second factor, no session is created: the answer is `{twoFactorRedirect: true, twoFactorMethods}` and a challenge cookie (`dtg.two_factor`, 10 minutes) is set; finish with `POST /v2/auth/two-factor/verify-totp` or `/two-factor/verify-backup-code` (a device trusted within 30 days skips the challenge). Every refused attempt writes an `auth.signin_failed` audit row naming a hash of the address. Taigan's per-address throttle counts every attempt and is cleared by a success. Accepts `application/json` or `application/x-www-form-urlencoded`. API integrations do not sign in: they use an API key (`Authorization: Bearer`).

### Request body

Required; `application/json`.

**`email` string (email) required**

The sign-in address (case-insensitive).

**`password` string required**

The password.

**`rememberMe` boolean**

False makes the session cookie a browser-session cookie (no Max-Age) and sets `dtg.dont_remember`; the server-side session still slides as usual. (default true)

**`callbackURL` string**

If given, the answer carries `redirect: true`, `url` and a `Location` header with this URL (still status 200). Must be relative or on a trusted origin.

### Responses

#### `200` success

Signed in, or a second-factor challenge started.

As `application/json`.

**`Location` header**

The `callbackURL`, when one was sent and the sign-in completed.

**`redirect` boolean**

True when `callbackURL` was sent (the client should navigate to `url`).

**`token` string**

The new session's token (also in the session cookie).

**`url` string or null**

The `callbackURL`, or absent/null.

**`user` AuthUser**

The better-auth user (the identity behind a Taigan account; one to one with `accounts`). Timestamps are ISO 8601 strings.

**`user.id` string (uuid) always present**

The auth user's id (`auth_users.id`). Not the Taigan account id that `/v2/account` reports.

**`user.name` string always present**

The name given at sign-up or set through `POST /v2/auth/update-user`. Mirrored into the account's `display_name`.

**`user.email` string (email) always present**

The sign-in address, lower-cased.

**`user.emailVerified` boolean always present**

Whether the address has been proved by its verification link. Sign-in is refused until it is true.

**`user.image` string or null**

A profile image URL. Stored if supplied; Taigan and Kelpie never show it.

**`user.createdAt` string (date-time) always present**

When the user was created.

**`user.updatedAt` string (date-time) always present**

When the user was last changed.

**`user.orgName` string or null**

The organization name typed on the sign-up form, used once to name the person's own organization. Never read again; the organization's real name is on `/v2/account`.

**`user.marketingOptIn` boolean or null**

The person's marketing consent, kept equal to the opposite of the account's `marketing_opt_out`: set from the sign-up form's box, then by `update-user`, `PATCH /v2/account/profile` and the unsubscribe link alike.

**`user.twoFactorEnabled` boolean or null**

Whether a second factor is live for this user (set when an enrolment is confirmed, cleared by `/two-factor/disable`).

**`twoFactorRedirect` true**

Always true: the password was right and a second factor is required. No session cookie was set; the `dtg.two_factor` challenge cookie was.

**`twoFactorMethods` array of "totp" | "otp"**

The factors that can answer the challenge. In this deployment `["totp"]` (a backup code is always accepted too); `otp` never appears because mailed codes are not configured.

#### `400` error

Bad request (better-auth JSON). Codes: `VALIDATION_ERROR` (email or password missing or not a string), `INVALID_EMAIL` (not an address), `BAD_REQUEST` (malformed JSON).

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Codes: `INVALID_EMAIL_OR_PASSWORD` (no such user, no password set, or the wrong password: one answer for all three), `FAILED_TO_CREATE_SESSION`.

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `EMAIL_NOT_VERIFIED` (the password was right but the address is not verified yet; no mail is re-sent, use `/v2/auth/send-verification-email`), `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (origin not trusted), `CROSS_SITE_NAVIGATION_LOGIN_BLOCKED`, `INVALID_CALLBACK_URL`.

As `application/json`.

#### `415` error

Unsupported media type (better-auth JSON). Code: `UNSUPPORTED_MEDIA_TYPE`.

As `application/json`.

#### `429` error

Too many requests. Two different refusals, both with `Retry-After`: Taigan's per-address throttle, code [`signin_throttled`](/docs/api/errors#code-signin-throttled) (more than 10 attempts for one address in an hour; body `AuthSigninThrottled`), or better-auth's per-IP limiter in production (3 sign-ins per 10 seconds; body `{message}` with no code, plus `X-Retry-After`).

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

Problem codes: [`signin_throttled`](/docs/api/errors#code-signin-throttled) (429). Every problem is a [problem document](/docs/api/errors).

## POST `/v2/auth/sign-in/social`

**Start (or complete with an ID token) a social sign-in. Not configured in this deployment**

Credentials: none. Operation id `authSignInSocial`.

Anonymous. better-auth's OAuth sign-in. No social provider is configured in this deployment (Google is dormant until `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` are set, and O27 rules it off at launch), so every request that names a valid provider answers 404 `PROVIDER_NOT_FOUND`. Were a provider configured, the redirect form answers `{url, redirect}` with a `Location` header for the browser to follow to the provider (status 200), and the `idToken` form verifies the provider's ID token and signs in directly. A browser flow; an API client never needs it.

### Request body

Required; `application/json`.

**`provider` string required**

The provider id (one of better-auth's known providers, for example `google`). An unknown name is a 400 `VALIDATION_ERROR`; a known but unconfigured one is a 404.

**`callbackURL` string**

Where to send the browser after a successful sign-in (relative or trusted origin).

**`newUserCallbackURL` string**

Where to send a newly created user instead of `callbackURL`.

**`errorCallbackURL` string**

Where to send the browser if the flow fails.

**`disableRedirect` boolean**

True to get the provider URL back without a `Location` header.

**`idToken` object**

An ID token already obtained from the provider, to sign in without a redirect (providers that support it).

**`idToken.token` string required**

The provider's ID token.

**`idToken.nonce` string**

The nonce used when the token was issued.

**`idToken.accessToken` string**

The provider access token, if any.

**`idToken.refreshToken` string**

The provider refresh token, if any.

**`idToken.expiresAt` number**

Token expiry (epoch).

**`idToken.user` object**

Profile data some providers send only once (Apple).

**`idToken.user.name` object**

The person's name parts.

**`idToken.user.name.firstName` string**

Given name.

**`idToken.user.name.lastName` string**

Family name.

**`idToken.user.email` string**

The address the provider reported.

**`scopes` array of string**

Scopes to request instead of the provider defaults.

**`requestSignUp` boolean**

Explicitly allow creating a user when the provider disables implicit sign-up.

**`loginHint` string**

A login hint forwarded to the provider.

**`additionalParams` object**

Extra query parameters for the provider's authorization URL (reserved OAuth keys are refused).

**`additionalData` object**

Opaque data carried through the OAuth state.

### Responses

#### `200` success

Redirect form: `{url, redirect}`. ID-token form: `{redirect: false, token, user}` and the session cookie. Not reachable in this deployment.

As `application/json`.

**`redirect` boolean always present**

True when a `Location` header to the provider was set.

**`url` string**

The provider's authorization URL (redirect form).

**`token` string**

The new session token (ID-token form).

**`user` AuthUser**

The better-auth user (the identity behind a Taigan account; one to one with `accounts`). Timestamps are ISO 8601 strings.

**`user.id` string (uuid) always present**

The auth user's id (`auth_users.id`). Not the Taigan account id that `/v2/account` reports.

**`user.name` string always present**

The name given at sign-up or set through `POST /v2/auth/update-user`. Mirrored into the account's `display_name`.

**`user.email` string (email) always present**

The sign-in address, lower-cased.

**`user.emailVerified` boolean always present**

Whether the address has been proved by its verification link. Sign-in is refused until it is true.

**`user.image` string or null**

A profile image URL. Stored if supplied; Taigan and Kelpie never show it.

**`user.createdAt` string (date-time) always present**

When the user was created.

**`user.updatedAt` string (date-time) always present**

When the user was last changed.

**`user.orgName` string or null**

The organization name typed on the sign-up form, used once to name the person's own organization. Never read again; the organization's real name is on `/v2/account`.

**`user.marketingOptIn` boolean or null**

The person's marketing consent, kept equal to the opposite of the account's `marketing_opt_out`: set from the sign-up form's box, then by `update-user`, `PATCH /v2/account/profile` and the unsubscribe link alike.

**`user.twoFactorEnabled` boolean or null**

Whether a second factor is live for this user (set when an enrolment is confirmed, cleared by `/two-factor/disable`).

#### `400` error

Bad request (better-auth JSON). Code: `VALIDATION_ERROR` (missing or unknown `provider`, bad field types).

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Codes (ID-token form, only with a provider configured): `INVALID_TOKEN`, `FAILED_TO_GET_USER_INFO`, `USER_EMAIL_NOT_FOUND`, `OAUTH_LINK_ERROR`.

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (a cookie-carrying request from an untrusted origin), `INVALID_CALLBACK_URL`, `INVALID_ERROR_CALLBACK_URL`, `INVALID_NEW_USER_CALLBACK_URL`, `EMAIL_NOT_VERIFIED` (ID-token form).

As `application/json`.

#### `404` error

Not found (better-auth JSON). Codes: `PROVIDER_NOT_FOUND` (always, in this deployment), `ID_TOKEN_NOT_SUPPORTED`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 3 per 10 seconds per IP on `/sign-in/*`. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/sign-out`

**Sign out: end the current session and clear the session cookie**

Credentials: App session. Operation id `authSignOut`.

Deletes the session named by the cookie (if any) and expires the session cookies. Succeeds without a session too (nothing to end). Other `/v2` routes cache a session lookup for up to 15 seconds, so the ended cookie can still be honored there for that long. The optional body only matters for a social provider with an end-session URL, which this deployment does not have.

### Request body

Optional; `application/json`.

**`callbackURL` string**

Post-logout redirect for a provider logout (unused here).

**`disableRedirect` boolean**

Return a provider logout URL without a `Location` header (unused here).

**`state` string**

State passed to a provider logout endpoint (unused here).

### Responses

#### `200` success

Signed out.

As `application/json`.

**`success` boolean always present**

Always true.

**`url` string**

A provider logout URL. Never present in this deployment.

**`redirect` boolean**

Whether to follow `url`. Never present in this deployment.

#### `400` error

Bad request (better-auth JSON). Code: `VALIDATION_ERROR` (body fields of the wrong type).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (a cookie-carrying POST must come from a trusted origin), `INVALID_CALLBACK_URL`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## GET `/v2/auth/get-session`

**The current session and user, or null**

Credentials: App session. Operation id `authGetSession`.

Reads the session cookie. Answers `null` (status 200) when there is no cookie, or the session has expired or been revoked (an expired session's row and cookie are then deleted). Extends a session used more than a day after its last extension (a fresh `expiresAt` 7 days out and a re-set cookie). Answers `Cache-Control: no-store`. Note this is better-auth's view: the rest of `/v2` additionally refuses a session 30 days after sign-in, an unverified user or a closed account, so a non-null answer here does not guarantee `/v2/account` will accept the cookie. Kelpie uses it to restore a signed-in tab.

### Parameters

**`disableRefresh` query · boolean**

True to read the session without extending it.

**`disableCookieCache` query · boolean**

True to bypass better-auth's cookie cache. The cache is not enabled here, so it has no effect.

### Responses

#### `200` success

The session and user, or null when signed out.

As `application/json`.

**`session` AuthSession**

A better-auth session row. Taigan keeps one active session per person: creating a session deletes every other session of that person (ORG\_LICENSING 4.2), so lists hold at most one entry.

**`session.id` string (uuid) always present**

The session's id (the same id `GET /v2/account/sessions` reports).

**`session.token` string always present**

The session's bearer secret: the unsigned value inside the session cookie. Treat it as a credential. `POST /v2/auth/revoke-session` takes it.

**`session.userId` string (uuid) always present**

The auth user's id.

**`session.expiresAt` string (date-time) always present**

When the session expires if not used. Sliding: a use more than a day after the last extension moves it to 7 days (`SESSION_SLIDING_DAYS`) from then. Taigan also ends every session 30 days (`SESSION_MAX_DAYS`) after `createdAt` whatever this says.

**`session.createdAt` string (date-time) always present**

When the session was created (the sign-in).

**`session.updatedAt` string (date-time) always present**

When the session was last extended.

**`session.ipAddress` string or null**

The client IP that created the session, as Taigan's proxy configuration resolved it.

**`session.userAgent` string or null**

The User-Agent that created the session.

**`user` AuthUser**

The better-auth user (the identity behind a Taigan account; one to one with `accounts`). Timestamps are ISO 8601 strings.

**`user.id` string (uuid) always present**

The auth user's id (`auth_users.id`). Not the Taigan account id that `/v2/account` reports.

**`user.name` string always present**

The name given at sign-up or set through `POST /v2/auth/update-user`. Mirrored into the account's `display_name`.

**`user.email` string (email) always present**

The sign-in address, lower-cased.

**`user.emailVerified` boolean always present**

Whether the address has been proved by its verification link. Sign-in is refused until it is true.

**`user.image` string or null**

A profile image URL. Stored if supplied; Taigan and Kelpie never show it.

**`user.createdAt` string (date-time) always present**

When the user was created.

**`user.updatedAt` string (date-time) always present**

When the user was last changed.

**`user.orgName` string or null**

The organization name typed on the sign-up form, used once to name the person's own organization. Never read again; the organization's real name is on `/v2/account`.

**`user.marketingOptIn` boolean or null**

The person's marketing consent, kept equal to the opposite of the account's `marketing_opt_out`: set from the sign-up form's box, then by `update-user`, `PATCH /v2/account/profile` and the unsubscribe link alike.

**`user.twoFactorEnabled` boolean or null**

Whether a second factor is live for this user (set when an enrolment is confirmed, cleared by `/two-factor/disable`).

#### `400` error

Bad request (better-auth JSON). Code: `VALIDATION_ERROR` (a query flag that is not a boolean).

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `FAILED_TO_GET_SESSION` (the session vanished while being extended; the cookie is cleared).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

#### `500` error

Server error (better-auth JSON). Code: `FAILED_TO_GET_SESSION`.

As `application/json`.

## POST `/v2/auth/get-session`

**POST form of get-session. Always refused in this deployment**

Credentials: App session. Operation id `authGetSessionPost`.

better-auth serves `POST /get-session` only when `session.deferSessionRefresh` is on, which Taigan does not set, so a well-formed request always answers 405 `METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED`. Use `GET /v2/auth/get-session`.

### Parameters

**`disableRefresh` query · boolean**

As on the GET form (never reached).

**`disableCookieCache` query · boolean**

As on the GET form (never reached).

### Responses

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (a cookie-carrying POST from an untrusted origin is refused before the method check).

As `application/json`.

#### `405` error

Method not allowed (better-auth JSON). Code: `METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED` (always).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## GET `/v2/auth/verify-email`

**The mailed verification link: verifies the address (or a change of address) and signs the person in**

Credentials: App session. Operation id `authVerifyEmail`.

Anonymous; a browser follows it from the verification mail (through the app's `/v2` proxy). The token is a signed link valid for `EMAIL_VERIFICATION_LINK_SECONDS` (1 hour). Three cases. (1) Sign-up verification: marks the address verified, stamps the account verified, and signs the person in (sets the session cookie; the session-create effects of sign-in apply). If a contested sign-up discarded the password (AUTH-2), a password-reset link is mailed so the person can choose one. An already-verified address changes nothing and creates no session. (2) Change of address (from `POST /v2/auth/change-email`): moves the sign-in address to the new one, verified, and sets a session. Membership is never touched. (3) Errors: with `callbackURL` the browser is redirected to `callbackURL?error=<CODE>` (with `&lifetime_seconds=` for `TOKEN_EXPIRED` and `INVALID_TOKEN`); without it the error is a JSON 401. A browser uses this; an API client never needs it.

### Parameters

**`token` query · string required**

The verification token from the mailed link.

**`callbackURL` query · string**

Where to send the browser afterwards (relative or on a trusted origin). Without it the answer is JSON.

### Responses

#### `200` success

Verified (no `callbackURL`). `user` is null for a sign-up verification or an already-verified address, and the updated user for a change of address.

As `application/json`.

**`status` boolean always present**

Always true.

**`user` AuthUser or null always present**

The user after a change of address; null otherwise.

**`user.id` string (uuid)**

The auth user's id (`auth_users.id`). Not the Taigan account id that `/v2/account` reports.

**`user.name` string**

The name given at sign-up or set through `POST /v2/auth/update-user`. Mirrored into the account's `display_name`.

**`user.email` string (email)**

The sign-in address, lower-cased.

**`user.emailVerified` boolean**

Whether the address has been proved by its verification link. Sign-in is refused until it is true.

**`user.image` string or null**

A profile image URL. Stored if supplied; Taigan and Kelpie never show it.

**`user.createdAt` string (date-time)**

When the user was created.

**`user.updatedAt` string (date-time)**

When the user was last changed.

**`user.orgName` string or null**

The organization name typed on the sign-up form, used once to name the person's own organization. Never read again; the organization's real name is on `/v2/account`.

**`user.marketingOptIn` boolean or null**

The person's marketing consent, kept equal to the opposite of the account's `marketing_opt_out`: set from the sign-up form's box, then by `update-user`, `PATCH /v2/account/profile` and the unsubscribe link alike.

**`user.twoFactorEnabled` boolean or null**

Whether a second factor is live for this user (set when an enrolment is confirmed, cleared by `/two-factor/disable`).

#### `302` redirect

Redirect to `callbackURL` on success, or to `callbackURL?error=<CODE>` on failure (`TOKEN_EXPIRED` or `INVALID_TOKEN` with `&lifetime_seconds=<seconds>`, `USER_NOT_FOUND`, `INVALID_USER`).

**`Location` header**

The destination.

#### `400` error

Bad request (better-auth JSON). Code: `VALIDATION_ERROR` (no `token`).

As `application/json`.

#### `401` error

Unauthorized (no `callbackURL`; better-auth JSON). Codes: `TOKEN_EXPIRED` and `INVALID_TOKEN` (both with `lifetime_seconds`, body `AuthLinkExpired`), `USER_NOT_FOUND` (the address no longer has a user), `INVALID_USER` (a change-of-address link opened while signed in as someone else).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Code: `INVALID_CALLBACK_URL` (`callbackURL` is not relative and not on a trusted origin).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

#### `500` error

Server error (better-auth JSON). Code: `FAILED_TO_CREATE_SESSION`.

As `application/json`.

## POST `/v2/auth/send-verification-email`

**Send a new verification link**

Credentials: App session. Operation id `authSendVerificationEmail`.

Anonymous (the 'Send a new link' button after sign-up or after `EMAIL_NOT_VERIFIED`). Without a session: always answers `{status: true}` after at least half a second, whether or not the address exists; a mail is sent only when the address has an unverified user. With a session: the address must be the session's own and still unverified, which a Taigan session never is (sign-in requires verification), so a signed-in call answers `EMAIL_ALREADY_VERIFIED`. The new link is valid for `EMAIL_VERIFICATION_LINK_SECONDS` (1 hour).

### Request body

Required; `application/json`.

**`email` string (email) required**

The address to send the link to.

**`callbackURL` string**

Where the link sends the browser after verifying (relative or trusted origin). Defaults to `/`.

### Responses

#### `200` success

Accepted (the same answer whether or not a mail was sent).

As `application/json`.

**`status` boolean always present**

Always true on success.

#### `400` error

Bad request (better-auth JSON). Codes: `VALIDATION_ERROR` (missing or malformed `email`), `EMAIL_MISMATCH` (signed in, and `email` is not the session's address), `EMAIL_ALREADY_VERIFIED` (signed in).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (cookie-carrying POST from an untrusted origin), `INVALID_CALLBACK_URL`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 3 per 60 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/request-password-reset`

**Mail a password-reset link (Turnstile-guarded)**

Credentials: none. Operation id `authRequestPasswordReset`.

Anonymous. Requires a Turnstile token in the `x-captcha-response` header (checked by Taigan first). Answers the same 200 whether or not the address has a user (anti-enumeration); when it does, a reset link valid for `PASSWORD_RESET_LINK_SECONDS` (1 hour) is mailed. The link is `GET /v2/auth/reset-password/{token}?callbackURL=<redirectTo>`, which sends the browser to `redirectTo?token=...`; the page then calls `POST /v2/auth/reset-password`.

### Parameters

**`x-captcha-response` header · string required**

The Cloudflare Turnstile widget token for this form submission.

### Request body

Required; `application/json`.

**`email` string (email) required**

The account's address.

**`redirectTo` string**

The app page that sets the new password (Kelpie: `<app>/reset`). Relative or on a trusted origin. The mailed link sends the browser there with `?token=` (or `?error=INVALID_TOKEN&lifetime_seconds=` when the link is spent or expired).

### Responses

#### `200` success

Accepted.

As `application/json`.

**`status` boolean always present**

Always true.

**`message` string always present**

`If this email exists in our system, check your email for the reset link`.

#### `400` error

Bad request. From Taigan's Turnstile guard (problem+json): [`captcha_missing`](/docs/api/errors#code-captcha-missing) (T108: the problem's own code; `errors: [{code: "captcha_missing"}]` repeats it). From better-auth (JSON): `VALIDATION_ERROR` (missing or malformed `email`).

As `application/problem+json`, `application/json`.

#### `403` error

Forbidden. From Taigan's Turnstile guard (problem+json): [`captcha_failed`](/docs/api/errors#code-captcha-failed). From better-auth (JSON): `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (cookie-carrying POST from an untrusted origin), `INVALID_REDIRECT_URL` / `INVALID_CALLBACK_URL` (`redirectTo` not relative and not on a trusted origin).

As `application/problem+json`, `application/json`.

#### `429` error

better-auth's request limiter (production): 3 per 60 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

#### `503` error

Service unavailable (problem+json). Code: [`unavailable`](/docs/api/errors#code-unavailable) (Cloudflare's Turnstile verification could not be reached).

As `application/problem+json`.

Problem codes: [`captcha_missing`](/docs/api/errors#code-captcha-missing) (400), [`captcha_failed`](/docs/api/errors#code-captcha-failed) (403), [`unavailable`](/docs/api/errors#code-unavailable) (503). Every problem is a [problem document](/docs/api/errors).

## GET `/v2/auth/reset-password/{token}`

**The mailed reset link: checks the token and redirects to the app's reset page**

Credentials: none. Operation id `authResetPasswordLink`.

Anonymous; a browser follows it from the reset mail. Never changes anything: it redirects to `callbackURL?token=<token>` when the token is live, or to `callbackURL?error=INVALID_TOKEN&lifetime_seconds=<seconds>` when it is unknown, spent or expired. A browser uses this; an API client never needs it.

### Parameters

**`token` path · string required**

The reset token from the mailed link.

**`callbackURL` query · string required**

The app page that sets the new password (relative or on a trusted origin); the mail fills it from `redirectTo`.

### Responses

#### `302` redirect

Redirect to `callbackURL?token=<token>`, or to `callbackURL?error=INVALID_TOKEN&lifetime_seconds=<PASSWORD_RESET_LINK_SECONDS>`.

**`Location` header**

The destination.

#### `400` error

Bad request (better-auth JSON). Code: `VALIDATION_ERROR` (no `callbackURL`).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Code: `INVALID_CALLBACK_URL` (not relative and not on a trusted origin).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/reset-password`

**Set a new password with a reset token**

Credentials: none. Operation id `authResetPassword`.

Anonymous (the reset page posts the token it was redirected with). Consumes the token (one use), sets the password (creating the email-and-password credential if the account had none, as after an AUTH-2 discard), mails a 'your password was changed' confirmation, writes a bell notice saying other sessions were signed out, and deletes every session of the person. It revokes no API key: the mail and the notice list the person's active keys (name, prefix and last four, organization, created, last used) and say to revoke any that is not theirs. It does not sign the person in: they sign in with the new password. Not Turnstile-guarded.

### Parameters

**`token` query · string**

The reset token, if not sent in the body (the body wins).

### Request body

Required; `application/json`.

**`newPassword` string required**

The new password: 10 to 128 characters. (at most 128 characters)

**`token` string**

The reset token from the redirect's `?token=`.

### Responses

#### `200` success

Password set; every session ended.

As `application/json`.

**`status` boolean always present**

Always true on success.

#### `400` error

Bad request (better-auth JSON). Codes: `INVALID_TOKEN` (no token, or unknown, spent or expired: indistinguishable; carries `lifetime_seconds`, body `AuthLinkExpired`), `PASSWORD_TOO_SHORT`, `PASSWORD_TOO_LONG`, `USER_NOT_FOUND` (the user was deleted after the mail), `VALIDATION_ERROR` (no `newPassword`).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (cookie-carrying POST from an untrusted origin).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/change-password`

**Change the password (current password required)**

Credentials: App session. Operation id `authChangePassword`.

Session only. Checks `currentPassword`, then sets `newPassword`. With `revokeOtherSessions: true` every session is deleted and a new one is created and set in the cookie (its token is returned). No mail or notice is sent for a change (only a reset sends them). Kelpie does not call this today.

### Request body

Required; `application/json`.

**`currentPassword` string required**

The current password.

**`newPassword` string required**

The new password: 10 to 128 characters. (at most 128 characters)

**`revokeOtherSessions` boolean**

True to end every session and start a fresh one for this browser.

### Responses

#### `200` success

Password changed.

As `application/json`.

**`token` string or null always present**

The new session's token when `revokeOtherSessions` was true; otherwise null.

**`user` AuthUser always present**

The better-auth user (the identity behind a Taigan account; one to one with `accounts`). Timestamps are ISO 8601 strings.

**`user.id` string (uuid) always present**

The auth user's id (`auth_users.id`). Not the Taigan account id that `/v2/account` reports.

**`user.name` string always present**

The name given at sign-up or set through `POST /v2/auth/update-user`. Mirrored into the account's `display_name`.

**`user.email` string (email) always present**

The sign-in address, lower-cased.

**`user.emailVerified` boolean always present**

Whether the address has been proved by its verification link. Sign-in is refused until it is true.

**`user.image` string or null**

A profile image URL. Stored if supplied; Taigan and Kelpie never show it.

**`user.createdAt` string (date-time) always present**

When the user was created.

**`user.updatedAt` string (date-time) always present**

When the user was last changed.

**`user.orgName` string or null**

The organization name typed on the sign-up form, used once to name the person's own organization. Never read again; the organization's real name is on `/v2/account`.

**`user.marketingOptIn` boolean or null**

The person's marketing consent, kept equal to the opposite of the account's `marketing_opt_out`: set from the sign-up form's box, then by `update-user`, `PATCH /v2/account/profile` and the unsubscribe link alike.

**`user.twoFactorEnabled` boolean or null**

Whether a second factor is live for this user (set when an enrolment is confirmed, cleared by `/two-factor/disable`).

#### `400` error

Bad request (better-auth JSON). Codes: `INVALID_PASSWORD` (`currentPassword` is wrong), `PASSWORD_TOO_SHORT`, `PASSWORD_TOO_LONG`, `CREDENTIAL_ACCOUNT_NOT_FOUND` (the account has no password yet; use the reset flow), `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (the POST must come from a trusted origin).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 3 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

#### `500` error

Server error (better-auth JSON). Code: `FAILED_TO_GET_SESSION` (the replacement session could not be created).

As `application/json`.

## POST `/v2/auth/change-email`

**Request a change of sign-in address (verified at the new address)**

Credentials: App session. Operation id `authChangeEmail`.

Session only. Nothing changes yet: a verification link is mailed to the NEW address, and a notice naming the new address is mailed to the OLD one at once. The address moves when the link (`GET /v2/auth/verify-email`) is followed, valid for `EMAIL_VERIFICATION_LINK_SECONDS`; `accounts.email` follows it. Membership is never affected. If the new address already belongs to another user, the answer is the same `{status: true}` and nothing is sent (anti-enumeration).

### Request body

Required; `application/json`.

**`newEmail` string (email) required**

The address to move to (stored lower-cased).

**`callbackURL` string**

Where the verification link sends the browser afterwards (relative or trusted origin). Defaults to `/`.

### Responses

#### `200` success

Accepted.

As `application/json`.

**`status` boolean always present**

Always true on success.

#### `400` error

Bad request (better-auth JSON). Codes: `VALIDATION_ERROR` (missing or malformed `newEmail`); no code, message `Email is the same` (the new address is the current one).

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`, `INVALID_CALLBACK_URL`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 3 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/update-user`

**Update the auth user's name (and other user fields)**

Credentials: App session. Operation id `authUpdateUser`.

Session only. Sets any of `name`, `image`, `orgName`, `marketingOptIn` on the auth user; `name` is mirrored into the account's `display_name`. `marketingOptIn` is the person's consent to release notes and product news: it is stored as the account's `marketing_opt_out` (its opposite), which is what every mail reads and what `GET /v2/account/profile` shows, in both directions, and a change writes an `account.marketing_changed` audit row. `orgName` here only changes the auth user's copy, which nothing reads after sign-up, so it renames no organization (use `PATCH /v2/account/organization`). The address cannot be changed here (use `/v2/auth/change-email`).

### Request body

Required; `application/json`.

**`name` string**

The person's name (mirrored to `display_name`).

**`image` string or null**

A profile image URL (stored, never shown).

**`orgName` string**

Overwrites the sign-up form's copy only; renames nothing.

**`marketingOptIn` boolean**

Consent to release notes and product news; stored as the account's `marketing_opt_out` (true here is `marketing_opt_out: false`), audited as `account.marketing_changed` when it changes.

### Responses

#### `200` success

Updated (the session cookie is re-set).

As `application/json`.

**`status` boolean always present**

Always true on success.

#### `400` error

Bad request (better-auth JSON). Codes: `EMAIL_CAN_NOT_BE_UPDATED` (`email` sent), `FIELD_NOT_ALLOWED` (`twoFactorEnabled` sent), `BODY_MUST_BE_AN_OBJECT`, `VALIDATION_ERROR`; no code, message `No fields to update` (none of the four fields sent).

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/update-session`

**Update custom session fields. None exist in this deployment**

Credentials: App session. Operation id `authUpdateSession`.

Session only. better-auth lets a deployment declare extra session fields and set them here; Taigan declares none, so every body (with a live session) answers 400 with the message `No fields to update`.

### Request body

Required; `application/json`.

### Responses

#### `200` success

Never answered in this deployment. Would be `{session}`.

As `application/json`.

**`session` AuthSession always present**

A better-auth session row. Taigan keeps one active session per person: creating a session deletes every other session of that person (ORG\_LICENSING 4.2), so lists hold at most one entry.

**`session.id` string (uuid) always present**

The session's id (the same id `GET /v2/account/sessions` reports).

**`session.token` string always present**

The session's bearer secret: the unsigned value inside the session cookie. Treat it as a credential. `POST /v2/auth/revoke-session` takes it.

**`session.userId` string (uuid) always present**

The auth user's id.

**`session.expiresAt` string (date-time) always present**

When the session expires if not used. Sliding: a use more than a day after the last extension moves it to 7 days (`SESSION_SLIDING_DAYS`) from then. Taigan also ends every session 30 days (`SESSION_MAX_DAYS`) after `createdAt` whatever this says.

**`session.createdAt` string (date-time) always present**

When the session was created (the sign-in).

**`session.updatedAt` string (date-time) always present**

When the session was last extended.

**`session.ipAddress` string or null**

The client IP that created the session, as Taigan's proxy configuration resolved it.

**`session.userAgent` string or null**

The User-Agent that created the session.

#### `400` error

Bad request (better-auth JSON). Codes: `BODY_MUST_BE_AN_OBJECT`, `VALIDATION_ERROR`; no code, message `No fields to update` (always, for an object body).

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Codes: `UNAUTHORIZED` (no live session), `FAILED_TO_GET_SESSION`.

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/verify-password`

**Check the signed-in person's password**

Credentials: App session. Operation id `authVerifyPassword`.

Session only. Answers `{status: true}` if `password` is the person's current password; changes nothing. better-auth marks it server-scoped for its own client, but the router serves it over HTTP.

### Request body

Required; `application/json`.

**`password` string required**

The password to check.

### Responses

#### `200` success

The password is correct.

As `application/json`.

**`status` boolean always present**

Always true on success.

#### `400` error

Bad request (better-auth JSON). Codes: `INVALID_PASSWORD` (wrong, or the account has no password), `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/delete-user`

**Delete the user. Disabled in this deployment (use POST /v2/account/close)**

Credentials: App session. Operation id `authDeleteUser`.

Session only. better-auth's self-deletion is not enabled in Taigan (`user.deleteUser` is not configured), so a signed-in request answers 404 with an empty body. Closing an account is `POST /v2/account/close`, which handles memberships, keys, billing and the audit trail.

### Request body

Optional; `application/json`.

**`password` string**

The current password (would be checked if the feature were on).

**`token` string**

A deletion-confirmation token (unused here).

**`callbackURL` string**

Where a confirmation link would send the browser (unused here).

### Responses

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`, `INVALID_CALLBACK_URL`.

As `application/json`.

#### `404` error

Not found: the feature is disabled. Always, for a signed-in caller. Empty body.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## GET `/v2/auth/delete-user/callback`

**Deletion-confirmation link. Disabled in this deployment**

Credentials: none. Operation id `authDeleteUserCallback`.

The link better-auth would mail to confirm a self-deletion. Disabled here like `/delete-user`: a request with a `token` answers 404 `NOT_FOUND`. A browser link; an API client never needs it.

### Parameters

**`token` query · string required**

The deletion-confirmation token.

**`callbackURL` query · string**

Where to send the browser afterwards (relative or trusted origin).

### Responses

#### `400` error

Bad request (better-auth JSON). Code: `VALIDATION_ERROR` (no `token`).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Code: `INVALID_CALLBACK_URL`.

As `application/json`.

#### `404` error

Not found (better-auth JSON). Code: `NOT_FOUND` (always: the feature is disabled).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## GET `/v2/auth/list-sessions`

**List the person's active sessions (better-auth's view)**

Credentials: App session. Operation id `authListSessions`.

Session only, and the session must be fresh (created within the last 24 hours), else 403 `SESSION_NOT_FRESH`. Lists unexpired sessions, each with its `token`. Under Taigan's one-session policy the list holds the current session only. The Taigan surface for this (with `current`, IP and user agent, working for API keys too) is `GET /v2/account/sessions`.

### Responses

#### `200` success

The sessions.

As `application/json`.

**`[].id` string (uuid) always present**

The session's id (the same id `GET /v2/account/sessions` reports).

**`[].token` string always present**

The session's bearer secret: the unsigned value inside the session cookie. Treat it as a credential. `POST /v2/auth/revoke-session` takes it.

**`[].userId` string (uuid) always present**

The auth user's id.

**`[].expiresAt` string (date-time) always present**

When the session expires if not used. Sliding: a use more than a day after the last extension moves it to 7 days (`SESSION_SLIDING_DAYS`) from then. Taigan also ends every session 30 days (`SESSION_MAX_DAYS`) after `createdAt` whatever this says.

**`[].createdAt` string (date-time) always present**

When the session was created (the sign-in).

**`[].updatedAt` string (date-time) always present**

When the session was last extended.

**`[].ipAddress` string or null**

The client IP that created the session, as Taigan's proxy configuration resolved it.

**`[].userAgent` string or null**

The User-Agent that created the session.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Code: `SESSION_NOT_FRESH` (the session is more than 24 hours old; sign in again).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

#### `500` error

Server error (better-auth JSON, `INTERNAL_SERVER_ERROR` status text): the sessions could not be read.

## POST `/v2/auth/revoke-session`

**End one of the person's sessions by its token**

Credentials: App session. Operation id `authRevokeSession`.

Session only. Deletes the session whose token is given, if it is the caller's; a token that is unknown or someone else's is ignored and the answer is the same `{status: true}`. Revoking the current session's token signs this browser out (the cookie stays until it is next refused). Other `/v2` routes may honor an ended session for up to 15 seconds (their lookup cache). The Taigan equivalent by session id is `DELETE /v2/account/sessions/{id}`.

### Request body

Required; `application/json`.

**`token` string required**

The session token to end (from `/v2/auth/list-sessions`).

### Responses

#### `200` success

Done (or nothing to do).

As `application/json`.

**`status` boolean always present**

Always true on success.

#### `400` error

Bad request (better-auth JSON). Code: `VALIDATION_ERROR` (no `token`).

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

#### `500` error

Server error (better-auth JSON). Code: `INTERNAL_SERVER_ERROR` (the delete failed).

As `application/json`.

## POST `/v2/auth/revoke-sessions`

**End every session of the person, this one included**

Credentials: App session. Operation id `authRevokeSessions`.

Session only. Deletes all of the person's sessions, the current one included (the browser is signed out; the cookie is not cleared but is refused from then on, within 15 seconds on other `/v2` routes). The Taigan equivalent is `POST /v2/account/sessions/sign-out-everywhere`.

### Responses

#### `200` success

Every session ended.

As `application/json`.

**`status` boolean always present**

Always true on success.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

#### `500` error

Server error (better-auth JSON). Code: `INTERNAL_SERVER_ERROR`.

As `application/json`.

## POST `/v2/auth/revoke-other-sessions`

**End every session of the person except this one**

Credentials: App session. Operation id `authRevokeOtherSessions`.

Session only. Deletes the person's other unexpired sessions and keeps the current one. Under Taigan's one-session policy there are normally none to end (every sign-in already evicts the others), so this is usually a no-op that answers `{status: true}`.

### Responses

#### `200` success

Done.

As `application/json`.

**`status` boolean always present**

Always true on success.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## GET `/v2/auth/list-accounts`

**List the sign-in methods linked to the person**

Credentials: App session. Operation id `authListAccounts`.

Session only. In this deployment the list holds one entry, `providerId: "credential"` (email and password), because no social provider is configured. An account whose password was discarded (AUTH-2) still has the entry.

### Responses

#### `200` success

The linked sign-in methods.

As `application/json`.

**`[].id` string (uuid) always present**

The linked account's id (what `accountId` means in `/unlink-account`, `/get-access-token`, `/refresh-token` and `/account-info`).

**`[].providerId` string always present**

`credential` for email and password (the only kind this deployment creates), or a social provider id such as `google`.

**`[].accountId` string always present**

The provider's own id for the person (for `credential`, the auth user's id).

**`[].issuer` string always present**

The issuer the provider id is scoped to (a local issuer for `credential`).

**`[].userId` string (uuid) always present**

The auth user's id.

**`[].scopes` array of string always present**

OAuth scopes granted by the provider; empty for `credential`.

**`[].createdAt` string (date-time) always present**

When the method was linked.

**`[].updatedAt` string (date-time) always present**

When it was last changed (for `credential`, the last password change).

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/link-social`

**Link a social sign-in to the person. Not configured in this deployment**

Credentials: App session. Operation id `authLinkSocial`.

Session only. No social provider is configured, so a request naming a valid provider answers 404 `PROVIDER_NOT_FOUND`. Were one configured, the redirect form answers `{url, redirect}` (with a `Location` header) and the provider returns the browser to `/v2/auth/callback/{id}`; the ID-token form links directly and answers `{url: "", status: true, redirect: false}`.

### Request body

Required; `application/json`.

**`provider` string required**

The provider id (one of better-auth's known providers). Unknown names are a 400.

**`callbackURL` string**

Where to send the browser after linking.

**`errorCallbackURL` string**

Where to send the browser if linking fails.

**`disableRedirect` boolean**

Return the provider URL without a `Location` header.

**`idToken` object**

A provider ID token, to link without a redirect.

**`idToken.token` string required**

The provider's ID token.

**`idToken.nonce` string**

The nonce used when it was issued.

**`idToken.accessToken` string**

The provider access token, if any.

**`idToken.refreshToken` string**

The provider refresh token, if any.

**`scopes` array of string**

Additional scopes to request.

**`requestSignUp` boolean**

Allow sign-up where the provider disables implicit sign-up.

**`loginHint` string**

A login hint forwarded to the provider.

**`additionalParams` object**

Extra query parameters for the provider's authorization URL.

**`additionalData` object**

Opaque data carried through the OAuth state.

### Responses

#### `200` success

Not reachable in this deployment.

As `application/json`.

**`url` string**

The provider authorization URL (redirect form), or empty (ID-token form).

**`redirect` boolean always present**

True when a `Location` header was set.

**`status` boolean**

True when linked directly (ID-token form).

#### `400` error

Bad request (better-auth JSON). Code: `VALIDATION_ERROR` (missing or unknown `provider`).

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Codes: `UNAUTHORIZED` (no live session); with a provider configured also `INVALID_TOKEN`, `FAILED_TO_GET_USER_INFO`, `USER_EMAIL_NOT_FOUND`, `LINKING_NOT_ALLOWED`, `LINKING_DIFFERENT_EMAILS_NOT_ALLOWED`.

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`, `INVALID_CALLBACK_URL`, `INVALID_ERROR_CALLBACK_URL`.

As `application/json`.

#### `404` error

Not found (better-auth JSON). Codes: `PROVIDER_NOT_FOUND` (always, in this deployment), `ID_TOKEN_NOT_SUPPORTED`.

As `application/json`.

#### `409` error

Conflict (better-auth JSON). Code: `SOCIAL_ACCOUNT_ALREADY_LINKED` (provider configured only).

As `application/json`.

#### `417` error

Expectation failed (better-auth JSON). Code: `LINKING_FAILED` (provider configured only).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/unlink-account`

**Remove a linked sign-in method**

Credentials: App session. Operation id `authUnlinkAccount`.

Session only, and the session must be fresh (under 24 hours old). The last remaining method can never be removed, and in this deployment every person has exactly one (`credential`), so the answer is 400 `FAILED_TO_UNLINK_LAST_ACCOUNT`.

### Request body

Required; `application/json`.

**`accountId` string required**

The linked account's `id` from `/v2/auth/list-accounts`.

### Responses

#### `200` success

Removed (not reachable while a person has one method).

As `application/json`.

**`status` boolean always present**

Always true on success.

#### `400` error

Bad request (better-auth JSON). Codes: `FAILED_TO_UNLINK_LAST_ACCOUNT` (always, here), `ACCOUNT_NOT_FOUND`, `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `SESSION_NOT_FRESH` (session over 24 hours old), `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/get-access-token`

**A social provider's access token for the person. Not usable in this deployment**

Credentials: App session. Operation id `authGetAccessToken`.

Session only. Returns (refreshing if near expiry) the OAuth access token stored for a linked social account. With no social provider configured the only linked account is `credential`, which answers 400 `PROVIDER_NOT_SUPPORTED`. The body selects the account by `accountId` or by `useAccountCookie: true` (the account cookie is not enabled here, so that form answers `ACCOUNT_NOT_FOUND`); `userId` is ignored for an HTTP caller (the session's user always wins). No other properties are allowed.

### Request body

Required; `application/json`.

**`accountId` string**

The linked account's `id`. Required unless `useAccountCookie` is sent.

**`useAccountCookie` true**

Select the account from better-auth's account cookie instead of `accountId` (not enabled here).

**`userId` string**

Ignored over HTTP.

### Responses

#### `200` success

Not reachable in this deployment.

As `application/json`.

**`accessToken` string**

The provider access token.

**`accessTokenExpiresAt` string (date-time) or null**

When it expires, if known.

**`scopes` array of string**

Granted scopes.

**`idToken` string**

The provider ID token, if stored.

#### `400` error

Bad request (better-auth JSON). Codes: `PROVIDER_NOT_SUPPORTED` (always for `credential`), `ACCOUNT_NOT_FOUND` (unknown `accountId`, or the cookie form), `FAILED_TO_GET_ACCESS_TOKEN`, `VALIDATION_ERROR` (neither selector, or an extra property).

As `application/json`.

#### `401` error

Unauthorized: no live session. Empty body.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/refresh-token`

**Refresh a social provider's tokens for the person. Not usable in this deployment**

Credentials: App session. Operation id `authRefreshToken`.

Session only. Uses the stored OAuth refresh token of a linked social account to mint new provider tokens. Selection works as for `/v2/auth/get-access-token`. With no social provider configured, the `credential` account answers 400 `PROVIDER_NOT_SUPPORTED`. These are the provider's tokens, not Taigan session or API-key tokens: a Taigan session is extended by use, and API keys do not expire this way.

### Request body

Required; `application/json`.

**`accountId` string**

The linked account's `id`. Required unless `useAccountCookie` is sent.

**`useAccountCookie` true**

Select from the account cookie (not enabled here).

**`userId` string**

Ignored over HTTP.

### Responses

#### `200` success

Not reachable in this deployment.

As `application/json`.

**`accessToken` string**

The new provider access token.

**`refreshToken` string**

The provider refresh token (new, or the existing one).

**`accessTokenExpiresAt` string (date-time) or null**

When the access token expires.

**`refreshTokenExpiresAt` string (date-time) or null**

When the refresh token expires.

**`scope` string or null**

Granted scopes, comma-separated.

**`idToken` string or null**

The provider ID token.

**`providerId` string**

The provider id.

**`accountId` string**

The linked account's `id`.

#### `400` error

Bad request (better-auth JSON). Codes: `PROVIDER_NOT_SUPPORTED` (always for `credential`), `ACCOUNT_NOT_FOUND`, `TOKEN_REFRESH_NOT_SUPPORTED`, `REFRESH_TOKEN_NOT_FOUND`, `FAILED_TO_REFRESH_ACCESS_TOKEN`, `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized: no live session. Empty body.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## GET `/v2/auth/account-info`

**The social provider's profile for a linked account. Not usable in this deployment**

Credentials: App session. Operation id `authAccountInfo`.

Session only. Fetches the person's profile from the provider of a linked social account. With no social provider configured, the `credential` account answers 400 `PROVIDER_NOT_CONFIGURED`. Selection is by query: `accountId`, or `useAccountCookie=true`; no other query parameters are allowed. For the person's Taigan profile use `GET /v2/account`.

### Parameters

**`accountId` query · string**

The linked account's `id` from `/v2/auth/list-accounts`. Required unless `useAccountCookie` is sent.

**`useAccountCookie` query · true**

`true` to select from better-auth's account cookie (not enabled here).

**`userId` query · string**

Ignored over HTTP (the session's user wins).

### Responses

#### `200` success

Not reachable in this deployment.

As `application/json`.

**`user` object always present**

The profile as the provider reports it.

**`user.id` string**

The provider's id for the person.

**`user.name` string**

Name.

**`user.email` string or null**

Address.

**`user.image` string or null**

Picture URL.

**`user.emailVerified` boolean always present**

Whether the provider verified the address.

**`data` object always present**

The provider's raw profile payload.

**`account` object always present**

The linked account the profile came from.

**`account.id` string always present**

The linked account's id.

**`account.providerId` string always present**

The provider id.

**`account.issuer` string always present**

The provider issuer.

**`account.accountId` string always present**

The provider's id for the person.

#### `400` error

Bad request (better-auth JSON). Codes: `PROVIDER_NOT_CONFIGURED` (always for `credential`), `ACCOUNT_NOT_FOUND`, `ACCESS_TOKEN_NOT_FOUND`, `FAILED_TO_GET_ACCESS_TOKEN`, `PROVIDER_NOT_SUPPORTED`, `VALIDATION_ERROR` (neither selector, or an extra query parameter).

As `application/json`.

#### `401` error

Unauthorized. No live session (empty body), or (better-auth JSON) `FAILED_TO_GET_USER_INFO` from the provider.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## GET `/v2/auth/callback/{id}`

**OAuth redirect target for a social provider (browser only)**

Credentials: none. Operation id `authOAuthCallback`.

Anonymous; the provider sends the browser here after a social sign-in or link. Always answers a redirect: to the flow's `callbackURL` (or `newUserCallbackURL`) with a session cookie on success, or to the error target (`errorCallbackURL`, else `/v2/auth/error`) with `?error=<code>`. No social provider is configured in this deployment, so every request ends at the error target (`state_not_found` without `state`, `state_mismatch` or `please_restart_the_process` with an unknown one). A browser uses this; an API client never needs it.

### Parameters

**`id` path · string required**

The provider id (for example `google`).

**`code` query · string**

The authorization code from the provider.

**`state` query · string**

The OAuth state better-auth issued.

**`error` query · string**

An error the provider reported.

**`error_description` query · string**

The provider's error description.

**`device_id` query · string**

A provider-specific device id (some providers).

**`user` query · string**

A provider-specific user payload (Apple).

**`iss` query · string**

The issuer the provider reports (checked against the provider).

### Responses

#### `302` redirect

Redirect to the flow's destination, or to the error target with `?error=<code>` (always the latter here). Error codes include `invalid_callback_request`, `state_not_found`, `state_mismatch`, `no_code`, `provider_not_found`, `invalid_code`, `unable_to_get_user_info`, `email_not_found`, `account_already_linked_to_different_user`, `unable_to_link_account`.

**`Location` header**

The destination.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/callback/{id}`

**OAuth form-post redirect target (browser only)**

Credentials: none. Operation id `authOAuthCallbackPost`.

Anonymous; for providers that return with an HTML form POST (Apple). Merges the body and query fields and redirects to the GET form of the same URL, which does the work. Accepts `application/x-www-form-urlencoded` or JSON. Not configured in this deployment (see the GET form). A browser uses this; an API client never needs it.

### Parameters

**`id` path · string required**

The provider id.

### Request body

Optional; `application/x-www-form-urlencoded`.

### Responses

#### `302` redirect

Redirect to `GET /v2/auth/callback/{id}?<the same fields>`.

**`Location` header**

The GET form's URL.

#### `400` error

Bad request (better-auth JSON). Code: `VALIDATION_ERROR` (a field that is not a string).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (a cookie-carrying POST from an untrusted origin).

As `application/json`.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## GET `/v2/auth/error`

**better-auth's error page (browser only)**

Credentials: none. Operation id `authErrorPage`.

Anonymous; where a failed OAuth flow lands when no error URL was given. In production it redirects to the API host's `/?error=<code>[&error_description=...]`; elsewhere it renders a small HTML page naming the code. A browser uses this; an API client never needs it.

### Parameters

**`error` query · string**

The error code to show (letters, digits, `_`, `-`, `'`; anything else shows as `UNKNOWN`).

**`error_description` query · string**

A description to show (HTML-escaped).

### Responses

#### `200` success

The HTML error page (not production).

As `text/html`.

#### `302` redirect

Production: redirect to `/?error=<code>`.

**`Location` header**

The destination.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## GET `/v2/auth/ok`

**Liveness of the auth handler**

Credentials: none. Operation id `authOk`.

Anonymous. Answers `{ok: true}` when better-auth is mounted and answering. Touches no database. The service's own health check is `GET /healthz`.

### Responses

#### `200` success

The auth handler is up.

As `application/json`.

**`ok` boolean always present**

Always true.

#### `429` error

better-auth's request limiter (production): 100 per 10 seconds per IP. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/two-factor/enable`

**Start authenticator-app enrolment: returns the TOTP URI and backup codes**

Credentials: App session. Operation id `authTwoFactorEnable`.

Session only; the current password is required. Generates a new TOTP secret and 10 backup codes and returns them; it does NOT arm the factor: enrolment takes effect when a code from the app is confirmed with `POST /v2/auth/two-factor/verify-totp` on this session (that writes the `auth.mfa_enabled` audit row). Calling it again replaces the secret and codes; if a factor is already live it stays live, so an authenticator set up from the old secret stops working. Only `method: "totp"` works here: mailed codes (`otp`) are not configured. Support operators must have a live factor.

### Request body

Required; `application/json`.

**`password` string required**

The person's current password.

**`method` "totp" | "otp"**

`totp` (an authenticator app). `otp` answers 400 `OTP_NOT_CONFIGURED` here. (default "totp")

**`issuer` string**

Issuer label in the authenticator app. Defaults to `Data Tactical Group`.

### Responses

#### `200` success

The secret's URI and the backup codes. Show both once; they are not retrievable later (`two_factor.backup_codes_remaining` on `/v2/account` gives only a count).

As `application/json`.

**`method` "totp" always present**

Always `totp` here.

**`totpURI` string always present**

An `otpauth://totp/...` URI (6 digits, 30-second period) to render as a QR code.

**`backupCodes` array of string always present**

10 one-time backup codes, each `xxxxx-xxxxx` (letters and digits).

#### `400` error

Bad request (better-auth JSON). Codes: `INVALID_PASSWORD` (missing or wrong), `OTP_NOT_CONFIGURED` (`method: otp`), `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 3 per 10 seconds per IP on `/two-factor/*`. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/two-factor/verify-totp`

**Verify an authenticator code: confirm enrolment, or answer a sign-in challenge**

Credentials: App session. Operation id `authTwoFactorVerifyTotp`.

Two uses. (1) With a session: confirms enrolment. The first correct code arms the factor (`twoFactorEnabled` true), replaces the session with a new one (new cookie and token) and writes an `auth.mfa_enabled` audit row; a correct code on an already-armed factor keeps the session and marks it as having passed the challenge (T110: how an unchallenged DTG admin session reaches `/v2/admin`). (2) Without a session, with the `dtg.two_factor` challenge cookie that `POST /v2/auth/sign-in/email` set: completes the sign-in, creating the session (with all the sign-in effects) and, with `trustDevice: true`, a `dtg.trust_device` cookie that skips the challenge on this browser for 30 days. Challenge limits: 5 wrong codes on one challenge end it (the sixth attempt answers 400 `TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE`; sign in again), and 10 wrong codes in a row across challenges lock the factor for 15 minutes (429 `ACCOUNT_TEMPORARILY_LOCKED`, checked before the code, so a correct code is refused while it stands). The lockout and per-challenge cap apply only to sign-in challenges, not to enrolment with a session.

### Request body

Required; `application/json`.

**`code` string required**

The 6-digit code from the authenticator app.

**`trustDevice` boolean**

Sign-in challenge only: trust this browser for 30 days.

### Responses

#### `200` success

Verified.

As `application/json`.

**`token` string or null**

The session token now in the session cookie (the new session on a sign-in challenge or on a first enrolment, otherwise the current one). Null or absent only where the operation says so.

**`user` AuthUser always present**

The better-auth user (the identity behind a Taigan account; one to one with `accounts`). Timestamps are ISO 8601 strings.

**`user.id` string (uuid) always present**

The auth user's id (`auth_users.id`). Not the Taigan account id that `/v2/account` reports.

**`user.name` string always present**

The name given at sign-up or set through `POST /v2/auth/update-user`. Mirrored into the account's `display_name`.

**`user.email` string (email) always present**

The sign-in address, lower-cased.

**`user.emailVerified` boolean always present**

Whether the address has been proved by its verification link. Sign-in is refused until it is true.

**`user.image` string or null**

A profile image URL. Stored if supplied; Taigan and Kelpie never show it.

**`user.createdAt` string (date-time) always present**

When the user was created.

**`user.updatedAt` string (date-time) always present**

When the user was last changed.

**`user.orgName` string or null**

The organization name typed on the sign-up form, used once to name the person's own organization. Never read again; the organization's real name is on `/v2/account`.

**`user.marketingOptIn` boolean or null**

The person's marketing consent, kept equal to the opposite of the account's `marketing_opt_out`: set from the sign-up form's box, then by `update-user`, `PATCH /v2/account/profile` and the unsubscribe link alike.

**`user.twoFactorEnabled` boolean or null**

Whether a second factor is live for this user (set when an enrolment is confirmed, cleared by `/two-factor/disable`).

#### `400` error

Bad request (better-auth JSON). Codes: `TOTP_NOT_ENABLED` (no enrolment started, or a sign-in challenge for a factor never confirmed), `TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE` (the challenge is spent; sign in again; does not count toward the lockout), `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Codes: `INVALID_CODE` (wrong code; on a challenge it counts toward both limits), `INVALID_TWO_FACTOR_COOKIE` (no session and no live challenge cookie: it expires after 10 minutes).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN` (cookie-carrying POST from an untrusted origin).

As `application/json`.

#### `429` error

Too many requests, with `Retry-After`. Either the second-factor lock, code `ACCOUNT_TEMPORARILY_LOCKED` (body `AuthAccountLocked`), or better-auth's per-IP limiter in production (3 per 10 seconds on `/two-factor/*`; body `{message}`, no code, plus `X-Retry-After`).

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

#### `500` error

Server error (better-auth JSON). Codes: `FAILED_TO_CREATE_SESSION`, `FAILED_TO_INVALIDATE_TWO_FACTOR_CHALLENGE`.

As `application/json`.

## POST `/v2/auth/two-factor/verify-backup-code`

**Answer a sign-in challenge (or check a code) with a one-time backup code**

Credentials: App session. Operation id `authTwoFactorVerifyBackupCode`.

Without a session, with the `dtg.two_factor` challenge cookie: completes the sign-in like `/two-factor/verify-totp` (same per-challenge cap of 5 and the same 10-failure lock). With a session: checks the code. Either way a correct code is consumed and cannot be used again. With `disableSession: true` the code is consumed but no session is created or changed (on a challenge, `token` is then absent and the challenge stays open). A backup code never arms or disarms a factor, so no audit row is written.

### Request body

Required; `application/json`.

**`code` string required**

One backup code, `xxxxx-xxxxx`.

**`disableSession` boolean**

True to consume the code without creating a session or setting a cookie.

**`trustDevice` boolean**

Sign-in challenge only: trust this browser for 30 days.

### Responses

#### `200` success

Accepted; the code is used up.

As `application/json`.

**`token` string or null**

The session token now in the session cookie (the new session on a sign-in challenge or on a first enrolment, otherwise the current one). Null or absent only where the operation says so.

**`user` AuthUser always present**

The better-auth user (the identity behind a Taigan account; one to one with `accounts`). Timestamps are ISO 8601 strings.

**`user.id` string (uuid) always present**

The auth user's id (`auth_users.id`). Not the Taigan account id that `/v2/account` reports.

**`user.name` string always present**

The name given at sign-up or set through `POST /v2/auth/update-user`. Mirrored into the account's `display_name`.

**`user.email` string (email) always present**

The sign-in address, lower-cased.

**`user.emailVerified` boolean always present**

Whether the address has been proved by its verification link. Sign-in is refused until it is true.

**`user.image` string or null**

A profile image URL. Stored if supplied; Taigan and Kelpie never show it.

**`user.createdAt` string (date-time) always present**

When the user was created.

**`user.updatedAt` string (date-time) always present**

When the user was last changed.

**`user.orgName` string or null**

The organization name typed on the sign-up form, used once to name the person's own organization. Never read again; the organization's real name is on `/v2/account`.

**`user.marketingOptIn` boolean or null**

The person's marketing consent, kept equal to the opposite of the account's `marketing_opt_out`: set from the sign-up form's box, then by `update-user`, `PATCH /v2/account/profile` and the unsubscribe link alike.

**`user.twoFactorEnabled` boolean or null**

Whether a second factor is live for this user (set when an enrolment is confirmed, cleared by `/two-factor/disable`).

#### `400` error

Bad request (better-auth JSON). Codes: `BACKUP_CODES_NOT_ENABLED` (no factor enrolled), `TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE` (challenge spent; sign in again), `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Codes: `INVALID_BACKUP_CODE` (wrong or already used), `INVALID_TWO_FACTOR_COOKIE` (no session and no live challenge).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `409` error

Conflict (better-auth JSON, no code): message `Failed to verify backup code. Please try again.` (two requests used codes at the same instant; retry).

As `application/json`.

#### `429` error

Too many requests, with `Retry-After`. Either `ACCOUNT_TEMPORARILY_LOCKED` (body `AuthAccountLocked`; sign-in challenges only) or better-auth's per-IP limiter (3 per 10 seconds on `/two-factor/*`; body `{message}`, no code).

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

#### `500` error

Server error (better-auth JSON). Codes: `FAILED_TO_CREATE_SESSION`, `FAILED_TO_INVALIDATE_TWO_FACTOR_CHALLENGE`.

As `application/json`.

## POST `/v2/auth/two-factor/send-otp`

**Mail a one-time second-factor code. Not configured in this deployment**

Credentials: App session. Operation id `authTwoFactorSendOtp`.

Mailed (OTP) codes are not configured in Taigan (the plugin has no `sendOTP`), so every request answers 400 `OTP_NOT_CONFIGURED` before anything else is checked. The authenticator app (`verify-totp`) and backup codes are the factors.

### Request body

Optional; `application/json`.

**`trustDevice` boolean**

Unused here.

### Responses

#### `400` error

Bad request (better-auth JSON). Codes: `OTP_NOT_CONFIGURED` (always), `VALIDATION_ERROR`.

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 3 per 10 seconds on `/two-factor/*`. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/two-factor/verify-otp`

**Verify a mailed second-factor code. Not usable in this deployment**

Credentials: App session. Operation id `authTwoFactorVerifyOtp`.

Mailed codes are not configured (see `/two-factor/send-otp`), so no code can ever be pending: with a session or a live challenge cookie the answer is 400 `OTP_HAS_EXPIRED`, except that on a sign-in challenge a standing second-factor lock is reported first (429). Without either, 401 `INVALID_TWO_FACTOR_COOKIE`.

### Request body

Required; `application/json`.

**`code` string required**

The mailed code.

**`trustDevice` boolean**

Sign-in challenge only: trust this browser for 30 days.

### Responses

#### `200` success

Never answered in this deployment.

As `application/json`.

**`token` string or null**

The session token now in the session cookie (the new session on a sign-in challenge or on a first enrolment, otherwise the current one). Null or absent only where the operation says so.

**`user` AuthUser always present**

The better-auth user (the identity behind a Taigan account; one to one with `accounts`). Timestamps are ISO 8601 strings.

**`user.id` string (uuid) always present**

The auth user's id (`auth_users.id`). Not the Taigan account id that `/v2/account` reports.

**`user.name` string always present**

The name given at sign-up or set through `POST /v2/auth/update-user`. Mirrored into the account's `display_name`.

**`user.email` string (email) always present**

The sign-in address, lower-cased.

**`user.emailVerified` boolean always present**

Whether the address has been proved by its verification link. Sign-in is refused until it is true.

**`user.image` string or null**

A profile image URL. Stored if supplied; Taigan and Kelpie never show it.

**`user.createdAt` string (date-time) always present**

When the user was created.

**`user.updatedAt` string (date-time) always present**

When the user was last changed.

**`user.orgName` string or null**

The organization name typed on the sign-up form, used once to name the person's own organization. Never read again; the organization's real name is on `/v2/account`.

**`user.marketingOptIn` boolean or null**

The person's marketing consent, kept equal to the opposite of the account's `marketing_opt_out`: set from the sign-up form's box, then by `update-user`, `PATCH /v2/account/profile` and the unsubscribe link alike.

**`user.twoFactorEnabled` boolean or null**

Whether a second factor is live for this user (set when an enrolment is confirmed, cleared by `/two-factor/disable`).

#### `400` error

Bad request (better-auth JSON). Codes: `OTP_HAS_EXPIRED` (always, with a session or challenge), `TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE`, `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Codes: `INVALID_TWO_FACTOR_COOKIE` (no session and no live challenge), `INVALID_CODE`.

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

Too many requests, with `Retry-After`. Either `ACCOUNT_TEMPORARILY_LOCKED` (body `AuthAccountLocked`; sign-in challenges only) or better-auth's per-IP limiter (3 per 10 seconds on `/two-factor/*`; body `{message}`, no code).

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/two-factor/get-totp-uri`

**Show the authenticator URI again (current password required)**

Credentials: App session. Operation id `authTwoFactorGetTotpUri`.

Session only. Returns the `otpauth://` URI of the person's current TOTP secret (to add the factor to another device). Changes nothing.

### Request body

Required; `application/json`.

**`password` string required**

The person's current password.

### Responses

#### `200` success

The URI.

As `application/json`.

**`totpURI` string always present**

An `otpauth://totp/...` URI (6 digits, 30-second period, issuer `Data Tactical Group`).

#### `400` error

Bad request (better-auth JSON). Codes: `TOTP_NOT_ENABLED` (no enrolment started), `INVALID_PASSWORD` (missing or wrong), `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 3 per 10 seconds on `/two-factor/*`. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/two-factor/generate-backup-codes`

**Replace the backup codes (current password required)**

Credentials: App session. Operation id `authTwoFactorGenerateBackupCodes`.

Session only, with a live second factor. Generates 10 new backup codes and invalidates every old one. Show them once: they are not retrievable later (`/v2/account` gives only `two_factor.backup_codes_remaining`).

### Request body

Required; `application/json`.

**`password` string required**

The person's current password.

### Responses

#### `200` success

The new codes.

As `application/json`.

**`status` true always present**

Always true.

**`backupCodes` array of string always present**

10 one-time codes, each `xxxxx-xxxxx`.

#### `400` error

Bad request (better-auth JSON). Codes: `TWO_FACTOR_NOT_ENABLED` (no live factor), `INVALID_PASSWORD` (missing or wrong), `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 3 per 10 seconds on `/two-factor/*`. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

## POST `/v2/auth/two-factor/disable`

**Turn the second factor off (current password required)**

Credentials: App session. Operation id `authTwoFactorDisable`.

Session only. Deletes the factor (secret and backup codes), clears `twoFactorEnabled`, replaces the session with a new one (new cookie), forgets a trusted device, and writes an `auth.mfa_disabled` audit row when a live factor was turned off. An operator whose factor is off is refused operator access until they enrol again.

### Request body

Required; `application/json`.

**`password` string required**

The person's current password.

### Responses

#### `200` success

Turned off.

As `application/json`.

**`status` boolean always present**

Always true on success.

#### `400` error

Bad request (better-auth JSON). Codes: `INVALID_PASSWORD` (missing or wrong), `VALIDATION_ERROR`.

As `application/json`.

#### `401` error

Unauthorized (better-auth JSON). Code: `UNAUTHORIZED` (no live session).

As `application/json`.

#### `403` error

Forbidden (better-auth JSON). Codes: `INVALID_ORIGIN` / `MISSING_OR_NULL_ORIGIN`.

As `application/json`.

#### `429` error

better-auth's request limiter (production): 3 per 10 seconds on `/two-factor/*`. Body `{message}`, no code.

As `application/json`.

**`Retry-After` header**

Seconds to wait before retrying.

Generated from `taigan/openapi.yaml` at catahoula `77925fab712f`(API document version 2.0).
