{
  "openapi": "3.1.1",
  "info": {
    "title": "Titan2 API",
    "description": "The HTTP surface of NETIO Cloud's Titan2 backend, in one document.\n\nTwo surfaces share it, and the groups in the sidebar follow them:\n\n| Surface | Prefix | Credential |\n| --- | --- | --- |\n| App (web and mobile) | `/api/app` | `Authorization: Bearer <access token>` |\n| Customer (public integrations) | `/api/customer/v1` | `X-Api-Key: <organization key>` |\n\nThe realtime surface is not here. `/api/app/ws` speaks JSON-RPC 2.0 over a WebSocket, which\nOpenAPI has no way to describe; it has its own reference at [`/api/docs/rpc`](/api/docs/rpc),\nover an OpenRPC document at [`/api/app/openrpc.json`](/api/app/openrpc.json).\n\n### Reading the badges\n\nAn endpoint marked **UNSUPPORTED** came from no feature request. Nobody specified it, so it may\nbe faulty or diverge from whatever specification eventually lands, and it can change or\ndisappear without notice — its description says where it came from instead. Everything unmarked\nis a contract: it changes with a deprecation period, not without one.\n\n### Errors\n\nEvery auth endpoint answers a failure with the same body — `{ \"error\": \"<code>\" }`, sometimes\nwith `retryAfter` or `detail` — and the codes are listed per response below. Distinct causes\ndeliberately share a code where telling them apart would leak whether an account exists.",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://titan2.cloud-dev.netio.eu/"
    }
  ],
  "paths": {
    "/api/app/auth/v1/providers/public": {
      "get": {
        "tags": [
          "app.auth.sso"
        ],
        "summary": "List the providers anyone may use",
        "description": "The buttons to show on a login screen before an address is known. A provider tied to one organization is not listed here — `/login/start` reveals those, and only for an address that belongs to it.",
        "responses": {
          "200": {
            "description": "The public providers, in no particular order.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SsoProviderListResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/app/openrpc.json": {
      "get": {
        "tags": [
          "app.realtime"
        ],
        "summary": "The realtime API's OpenRPC document",
        "description": "The contract for `/api/app/ws`, which speaks JSON-RPC 2.0 over a WebSocket and so cannot be described in OpenAPI. Rendered at [`/api/docs/rpc`](/api/docs/rpc). Connect with a normal WebSocket upgrade, then call `auth.v1.authenticate` before anything else. Server-to-client notifications are listed under the document's `x-notifications` extension.",
        "responses": {
          "200": {
            "description": "An OpenRPC 1.x document.",
            "content": {
              "application/json": {
                "schema": { }
              }
            }
          }
        }
      }
    },
    "/api/app/auth/v1/login/start": {
      "post": {
        "tags": [
          "app.auth.login"
        ],
        "summary": "Discover how an address can sign in",
        "description": "Returns the methods to offer the user. `password` is always in the list, whether or not the address has an account, so this call cannot be used to find out. SSO providers appear only for an address whose organization has one.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LoginStartRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "The methods to offer, in the order to show them.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LoginStartResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/app/auth/v1/login/password": {
      "post": {
        "tags": [
          "app.auth.login"
        ],
        "summary": "Log in with a password",
        "description": "Answers with a token pair, or — when the account has a second factor enrolled — with `{ \"nextStep\": \"totp\" }`, in which case call `/login/totp` with the same credentials plus the code. `deliveryMode: \"cookie\"` puts the refresh token in an HttpOnly cookie instead of the body.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LoginPasswordRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "- `invalid_credentials` — wrong password, unknown address, or a barred account — one answer for all three, so the caller cannot tell which",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "- `password_not_set` — the account exists but has no password, because it signs in through a provider. Password recovery is how it gets one",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "- `locked` — too many failed attempts against this address. `retryAfter` is in seconds",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "Either a token pair, or the next step to take.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "required": [
                        "accessToken"
                      ],
                      "type": "object",
                      "properties": {
                        "accessToken": {
                          "type": "string"
                        },
                        "refreshToken": {
                          "type": [
                            "null",
                            "string"
                          ]
                        },
                        "emailVerified": {
                          "type": [
                            "null",
                            "boolean"
                          ],
                          "description": "False when the account's address has not been confirmed. Login is allowed either way; the client is expected to prompt."
                        }
                      }
                    },
                    {
                      "required": [
                        "nextStep"
                      ],
                      "type": "object",
                      "properties": {
                        "nextStep": {
                          "type": "string"
                        }
                      }
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/api/app/auth/v1/login/totp": {
      "post": {
        "tags": [
          "app.auth.login"
        ],
        "summary": "Finish a login with a second factor",
        "description": "Takes the same credentials as `/login/password` plus the code — from the authenticator app, or one of the account's recovery codes.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LoginTotpRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "Signed in.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenPairResponse"
                }
              }
            }
          },
          "401": {
            "description": "- `invalid_code` — the second factor is wrong, expired, or already spent\n- `invalid_credentials` — wrong password, unknown address, or a barred account — one answer for all three, so the caller cannot tell which",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "- `password_not_set` — the account exists but has no password, because it signs in through a provider. Password recovery is how it gets one",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "- `locked` — too many failed attempts against this address. `retryAfter` is in seconds",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/app/auth/v1/password-reset/request": {
      "post": {
        "tags": [
          "app.auth.password"
        ],
        "summary": "Ask for a password reset code",
        "description": "Mails a code that expires. The answer never says whether the address exists. An account that has only ever signed in through a provider gets a password this way.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PasswordResetRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "Always `if_account_exists`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/app/auth/v1/password-reset/confirm": {
      "post": {
        "tags": [
          "app.auth.password"
        ],
        "summary": "Set a new password with a reset code",
        "description": "Spends the code and replaces the password. Every session of the account ends, here and on its other devices, and any live WebSocket connection is closed.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PasswordResetConfirmRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `weak_password` — shorter than the configured minimum\n- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "The password is set. Log in again to get tokens."
          },
          "410": {
            "description": "- `expired_or_used` — the code has expired or was already spent",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "- `locked` — too many failed attempts against this address. `retryAfter` is in seconds",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/app/auth/v1/register": {
      "post": {
        "tags": [
          "app.auth.registration"
        ],
        "summary": "⛔ Create an account",
        "description": "> ### ⛔ Unsupported — experimental\n>\n> No one requested this endpoint. It is purely experimental: it may be faulty, and it could change or disappear without notice. A signed-in user can call it only while their experimental access is enabled: the first call opens a one-hour window, after which support has to enable it again.\n\nMails a six-character verification code. An address that already has an account is told so plainly, so the client can offer password recovery instead. An invitation is accepted after the address is confirmed, not here.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RegisterRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `weak_password` — shorter than the configured minimum\n- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "`verification_sent`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageResponse"
                }
              }
            }
          },
          "409": {
            "description": "- `account_exists` — the address already has an account; send the user to password recovery",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          }
        },
        "x-badges": [
          {
            "name": "UNSUPPORTED",
            "position": "before",
            "color": "#dc2626"
          }
        ],
        "x-scalar-stability": "experimental"
      }
    },
    "/api/app/auth/v1/verify-email": {
      "post": {
        "tags": [
          "app.auth.registration"
        ],
        "summary": "⛔ Confirm an email address",
        "description": "> ### ⛔ Unsupported — experimental\n>\n> No one requested this endpoint. It is purely experimental: it may be faulty, and it could change or disappear without notice. A signed-in user can call it only while their experimental access is enabled: the first call opens a one-hour window, after which support has to enable it again.\n\nSpends the single-use code from the registration mail.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VerifyEmailRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "The address is confirmed."
          },
          "410": {
            "description": "- `expired_or_used` — the code has expired or was already spent",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          }
        },
        "x-badges": [
          {
            "name": "UNSUPPORTED",
            "position": "before",
            "color": "#dc2626"
          }
        ],
        "x-scalar-stability": "experimental"
      }
    },
    "/api/app/auth/v1/legacy/promote": {
      "post": {
        "tags": [
          "app.auth.legacy"
        ],
        "summary": "Trade a Titan v1 token for a Titan2 session",
        "description": "Lets a client migrate off v1 without asking the user to log in again. The v1 token must still verify, and must not predate the account's session cutoff.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LegacyPromoteRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "A Titan2 session for the same account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenPairResponse"
                }
              }
            }
          },
          "401": {
            "description": "- `invalid_legacy_token` — the v1 token does not verify, has expired, or its account cannot log in",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/app/auth/v1/token/refresh": {
      "post": {
        "tags": [
          "app.auth.sessions"
        ],
        "summary": "Rotate a refresh token",
        "description": "Returns a new pair and spends the presented token. Presenting an already-rotated token revokes the whole family — every session descended from that login — on the assumption it leaked.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RefreshRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "A fresh pair. Store the new refresh token; the old one is dead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenPairResponse"
                }
              }
            }
          },
          "401": {
            "description": "- `invalid_refresh_token` — unknown, expired, already rotated, or revoked. Presenting a token that was already rotated also revokes the rest of its family",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/app/auth/v1/logout": {
      "post": {
        "tags": [
          "app.auth.sessions"
        ],
        "summary": "⛔ End this session",
        "description": "> ### ⛔ Unsupported — experimental\n>\n> No one requested this endpoint. It is purely experimental: it may be faulty, and it could change or disappear without notice. A signed-in user can call it only while their experimental access is enabled: the first call opens a one-hour window, after which support has to enable it again.\n\nRevokes the presented token's family. Other devices keep their sessions. Answers 200 whether or not the token was still valid.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LogoutRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "The session is gone."
          }
        },
        "x-badges": [
          {
            "name": "UNSUPPORTED",
            "position": "before",
            "color": "#dc2626"
          }
        ],
        "x-scalar-stability": "experimental"
      }
    },
    "/api/app/auth/v1/token/refresh/cookie": {
      "post": {
        "tags": [
          "app.auth.sessions"
        ],
        "summary": "Rotate the refresh token held in the cookie",
        "description": "As `/token/refresh`, but the token is read from the HttpOnly cookie and the new one written back to it. A failure clears the cookie, so the client cannot retry with a token that just revoked its own family.",
        "responses": {
          "200": {
            "description": "A fresh access token. The refresh token is in the cookie, not the body.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenPairResponse"
                }
              }
            }
          },
          "401": {
            "description": "- `invalid_refresh_token` — unknown, expired, already rotated, or revoked. Presenting a token that was already rotated also revokes the rest of its family",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/app/auth/v1/logout/cookie": {
      "post": {
        "tags": [
          "app.auth.sessions"
        ],
        "summary": "⛔ End the session held in the cookie",
        "description": "> ### ⛔ Unsupported — experimental\n>\n> No one requested this endpoint. It is purely experimental: it may be faulty, and it could change or disappear without notice. A signed-in user can call it only while their experimental access is enabled: the first call opens a one-hour window, after which support has to enable it again.\n\nAs `/logout`, reading the token from the cookie and clearing it.",
        "responses": {
          "200": {
            "description": "The session is gone and the cookie is cleared."
          }
        },
        "x-badges": [
          {
            "name": "UNSUPPORTED",
            "position": "before",
            "color": "#dc2626"
          }
        ],
        "x-scalar-stability": "experimental"
      }
    },
    "/api/app/auth/v1/sso/authorize": {
      "post": {
        "tags": [
          "app.auth.sso"
        ],
        "summary": "Begin a federated login",
        "description": "Returns the provider URL to send the browser to. Titan2 issues no redirect of its own: the provider's `redirect_uri` is a frontend route, and the frontend hands the code back to `/sso/callback`. An invitation is not carried through the handshake; accept it after signing in.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SsoAuthorizeRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "Send the browser here.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SsoAuthorizeResponse"
                }
              }
            }
          },
          "404": {
            "description": "- `unknown_provider` — no SSO provider has that id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "The provider is misconfigured (no client id or discovery URL) — a support problem, not the caller's.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/app/auth/v1/sso/callback": {
      "post": {
        "tags": [
          "app.auth.sso"
        ],
        "summary": "Finish a federated login",
        "description": "Exchanges the provider's code for a session. `action` says what happened to the account: `login`, `register`, or `pair`.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SsoCallbackRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "422": {
            "description": "- `invalid_request` — a field is missing, too long, or not a valid address — `detail` names it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "200": {
            "description": "Signed in.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SsoCallbackResponse"
                }
              }
            }
          },
          "401": {
            "description": "- `invalid_sso_response` — the provider's code or state did not verify",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "- `managed_auth` — the account's organization requires a specific provider, and this is not it",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "- `no_linked_account` — the provider authenticated somebody who has no Titan account, and this flow does not create one",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/customer/v1/organization": {
      "get": {
        "tags": [
          "customer.organizations"
        ],
        "summary": "⛔ Read the API key's own organization",
        "description": "> ### ⛔ Unsupported — experimental\n>\n> No one requested this endpoint. It is purely experimental: it may be faulty, and it could change or disappear without notice. A signed-in user can call it only while their experimental access is enabled: the first call opens a one-hour window, after which support has to enable it again.\n\nThere is no path parameter: a key can only ever see the organization it belongs to.",
        "responses": {
          "200": {
            "description": "The organization behind the API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OrganizationDetailResponse"
                }
              }
            }
          },
          "403": {
            "description": "The credential is not an organization API key."
          },
          "404": {
            "description": "The organization no longer exists."
          }
        },
        "security": [
          {
            "organizationApiKey": [ ]
          }
        ],
        "x-badges": [
          {
            "name": "UNSUPPORTED",
            "position": "before",
            "color": "#dc2626"
          }
        ],
        "x-scalar-stability": "experimental"
      }
    }
  },
  "components": {
    "schemas": {
      "AuthErrorResponse": {
        "required": [
          "error"
        ],
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "retryAfter": {
            "pattern": "^-?(?:0|[1-9]\\d*)$",
            "type": [
              "null",
              "integer",
              "string"
            ],
            "format": "int32"
          },
          "detail": {
            "type": [
              "null",
              "string"
            ]
          }
        }
      },
      "LegacyPromoteRequest": {
        "required": [
          "v1Token"
        ],
        "type": "object",
        "properties": {
          "v1Token": {
            "type": "string"
          },
          "deviceName": {
            "type": [
              "null",
              "string"
            ]
          },
          "deliveryMode": {
            "$ref": "#/components/schemas/TokenDelivery"
          }
        }
      },
      "LoginPasswordRequest": {
        "required": [
          "email",
          "password"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string"
          },
          "password": {
            "type": "string"
          },
          "deviceName": {
            "type": [
              "null",
              "string"
            ]
          },
          "deliveryMode": {
            "$ref": "#/components/schemas/TokenDelivery"
          }
        }
      },
      "LoginStartRequest": {
        "required": [
          "email"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string"
          }
        }
      },
      "LoginStartResponse": {
        "required": [
          "methods"
        ],
        "type": "object",
        "properties": {
          "methods": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Which methods the caller may try. Always includes \"password\", whether or not the address has an account."
          },
          "ssoProviders": {
            "type": [
              "null",
              "array"
            ],
            "items": {
              "$ref": "#/components/schemas/SsoProviderResponse"
            }
          }
        }
      },
      "LoginTotpRequest": {
        "required": [
          "email",
          "password",
          "code"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string"
          },
          "password": {
            "type": "string"
          },
          "code": {
            "type": "string"
          },
          "deviceName": {
            "type": [
              "null",
              "string"
            ]
          },
          "deliveryMode": {
            "$ref": "#/components/schemas/TokenDelivery"
          }
        }
      },
      "LogoutRequest": {
        "required": [
          "refreshToken"
        ],
        "type": "object",
        "properties": {
          "refreshToken": {
            "type": "string"
          }
        }
      },
      "MessageResponse": {
        "required": [
          "message"
        ],
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          }
        }
      },
      "OrganizationDetailResponse": {
        "required": [
          "id",
          "name",
          "timezone",
          "createdAt"
        ],
        "type": "object",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/PublicId"
          },
          "name": {
            "type": [
              "null",
              "string"
            ]
          },
          "timezone": {
            "type": [
              "null",
              "string"
            ]
          },
          "createdAt": {
            "type": [
              "null",
              "string"
            ],
            "format": "date-time"
          }
        }
      },
      "PasswordResetConfirmRequest": {
        "required": [
          "resetToken",
          "newPassword"
        ],
        "type": "object",
        "properties": {
          "resetToken": {
            "type": "string"
          },
          "newPassword": {
            "type": "string"
          }
        }
      },
      "PasswordResetRequest": {
        "required": [
          "email"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string"
          }
        }
      },
      "ProblemDetails": {
        "type": "object",
        "properties": {
          "type": {
            "type": [
              "null",
              "string"
            ]
          },
          "title": {
            "type": [
              "null",
              "string"
            ]
          },
          "status": {
            "pattern": "^-?(?:0|[1-9]\\d*)$",
            "type": [
              "null",
              "integer",
              "string"
            ],
            "format": "int32"
          },
          "detail": {
            "type": [
              "null",
              "string"
            ]
          },
          "instance": {
            "type": [
              "null",
              "string"
            ]
          }
        }
      },
      "PublicId": {
        "pattern": "^[a-z]{3}_([0-9A-Za-z]{7}|[0-9A-Za-z]{22})$",
        "type": "string",
        "description": "Typed opaque id: a three-letter kind prefix, an underscore and the payload, e.g. dev_3kTMd8n. Compare ids as strings; never derive anything from the payload."
      },
      "RefreshRequest": {
        "required": [
          "refreshToken"
        ],
        "type": "object",
        "properties": {
          "refreshToken": {
            "type": "string"
          },
          "deliveryMode": {
            "$ref": "#/components/schemas/TokenDelivery"
          }
        }
      },
      "RegisterRequest": {
        "required": [
          "email",
          "password"
        ],
        "type": "object",
        "properties": {
          "email": {
            "type": "string"
          },
          "password": {
            "type": "string"
          },
          "country": {
            "type": [
              "null",
              "string"
            ]
          },
          "language": {
            "type": [
              "null",
              "string"
            ]
          },
          "newsletter": {
            "type": "boolean",
            "default": false
          }
        }
      },
      "SsoAuthorizeRequest": {
        "required": [
          "providerId"
        ],
        "type": "object",
        "properties": {
          "providerId": {
            "$ref": "#/components/schemas/PublicId"
          },
          "redirectUri": {
            "type": [
              "null",
              "string"
            ],
            "description": "Which frontend route the provider returns to; one of the configured callbacks. Defaults to the first."
          }
        }
      },
      "SsoAuthorizeResponse": {
        "required": [
          "redirectUrl"
        ],
        "type": "object",
        "properties": {
          "redirectUrl": {
            "type": "string"
          }
        }
      },
      "SsoCallbackRequest": {
        "required": [
          "code",
          "state"
        ],
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "state": {
            "type": "string"
          },
          "deviceName": {
            "type": [
              "null",
              "string"
            ]
          },
          "deliveryMode": {
            "$ref": "#/components/schemas/TokenDelivery"
          }
        }
      },
      "SsoCallbackResponse": {
        "required": [
          "accessToken",
          "refreshToken",
          "action"
        ],
        "type": "object",
        "properties": {
          "accessToken": {
            "type": "string"
          },
          "refreshToken": {
            "type": [
              "null",
              "string"
            ]
          },
          "action": {
            "type": "string",
            "description": "What happened to the account: login, register or pair."
          },
          "emailVerified": {
            "type": [
              "null",
              "boolean"
            ],
            "description": "False when the account's address has not been confirmed. Login is allowed either way; the client is expected to prompt."
          }
        }
      },
      "SsoProviderListResponse": {
        "required": [
          "providers"
        ],
        "type": "object",
        "properties": {
          "providers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SsoProviderResponse"
            }
          }
        }
      },
      "SsoProviderResponse": {
        "required": [
          "id",
          "displayName",
          "providerType"
        ],
        "type": "object",
        "properties": {
          "id": {
            "$ref": "#/components/schemas/PublicId"
          },
          "displayName": {
            "type": "string"
          },
          "providerType": {
            "type": "string",
            "description": "Which button to draw: microsoft, google, okta, apple or other."
          }
        }
      },
      "TokenDelivery": {
        "enum": [
          "Body",
          "Cookie"
        ],
        "default": "Body"
      },
      "TokenPairResponse": {
        "required": [
          "accessToken"
        ],
        "type": "object",
        "properties": {
          "accessToken": {
            "type": "string"
          },
          "refreshToken": {
            "type": [
              "null",
              "string"
            ]
          },
          "emailVerified": {
            "type": [
              "null",
              "boolean"
            ],
            "description": "False when the account's address has not been confirmed. Login is allowed either way; the client is expected to prompt."
          }
        }
      },
      "VerifyEmailRequest": {
        "required": [
          "token"
        ],
        "type": "object",
        "properties": {
          "token": {
            "type": "string"
          }
        }
      }
    },
    "securitySchemes": {
      "appBearer": {
        "type": "http",
        "description": "Access token from the App auth surface. Short-lived; refresh it with a refresh token rather than logging in again.",
        "scheme": "bearer",
        "bearerFormat": "JWT"
      },
      "organizationApiKey": {
        "type": "apiKey",
        "description": "Organization API key. Its role decides what the caller may see, and every request is scoped to the key's own organization.",
        "name": "X-Api-Key",
        "in": "header"
      }
    }
  },
  "tags": [
    {
      "name": "app.auth.login",
      "description": "Signing in with a password and, where enrolled, a second factor. Every failure on this path answers with the same shape, so the caller cannot learn which field was wrong.",
      "x-displayName": "Login"
    },
    {
      "name": "app.auth.registration",
      "description": "Creating an account and confirming its address. Registration states plainly whether an address is already taken, so a user who has forgotten they signed up can be sent to password recovery.",
      "x-displayName": "Registration"
    },
    {
      "name": "app.auth.password",
      "description": "Requesting a reset code and spending it. Also the way an account that only ever signed in through a provider gets a password.",
      "x-displayName": "Password recovery"
    },
    {
      "name": "app.auth.sso",
      "description": "Federated login and account pairing. Titan2 issues no redirects of its own: the provider's redirect_uri is a frontend route, and the frontend forwards the code here.",
      "x-displayName": "Single sign-on"
    },
    {
      "name": "app.auth.sessions",
      "description": "Exchanging a refresh token for a new pair, and ending sessions. Refresh tokens rotate on every use and replaying a spent one kills the whole family.",
      "x-displayName": "Sessions & tokens"
    },
    {
      "name": "app.auth.legacy",
      "description": "Trading a Titan v1 token for a Titan2 session, so a client can migrate without asking the user to log in again.",
      "x-displayName": "v1 bridge"
    },
    {
      "name": "app.realtime",
      "description": "The WebSocket surface is JSON-RPC 2.0 and therefore not describable in OpenAPI; its contract is published as an OpenRPC document instead.",
      "x-displayName": "Realtime"
    },
    {
      "name": "customer.organizations",
      "description": "Organization data for public integrations, scoped to the API key's own organization.",
      "x-displayName": "Organizations"
    }
  ],
  "x-tagGroups": [
    {
      "name": "App API · Authentication",
      "tags": [
        "app.auth.login",
        "app.auth.registration",
        "app.auth.password",
        "app.auth.sso",
        "app.auth.sessions",
        "app.auth.legacy"
      ]
    },
    {
      "name": "App API · Realtime",
      "tags": [
        "app.realtime"
      ]
    },
    {
      "name": "Customer API",
      "tags": [
        "customer.organizations"
      ]
    }
  ]
}