> For the complete documentation index, see [llms.txt](https://docs.otari.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.otari.ai/otari/for-integrators/api-reference/auth.md).

# Auth

## Authorize

> Start an OAuth sign-in: where to send the browser, and the state to keep.\
> \
> A GET that writes, which is the one thing to know about it. It records the\
> authorization it is about to start (the state's hash, the PKCE verifier the\
> exchange will need, and the digest of a flow secret it sets as an HttpOnly\
> cookie) so the callback has something to check against, and that record is\
> the whole reason the callback can refuse a code this deployment never asked\
> for, or one presented from a browser other than the one that asked.\
> \
> Still safe to repeat: each call mints its own state, and only the one the\
> browser kept is the one it sends back. The rows the others leave expire on\
> their own and are swept by the next call. The cookie is reused when the\
> browser already holds one, so a second tab does not break the first.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/oauth/{provider}/authorize":{"get":{"description":"Start an OAuth sign-in: where to send the browser, and the state to keep.\n\nA GET that writes, which is the one thing to know about it. It records the\nauthorization it is about to start (the state's hash, the PKCE verifier the\nexchange will need, and the digest of a flow secret it sets as an HttpOnly\ncookie) so the callback has something to check against, and that record is\nthe whole reason the callback can refuse a code this deployment never asked\nfor, or one presented from a browser other than the one that asked.\n\nStill safe to repeat: each call mints its own state, and only the one the\nbrowser kept is the one it sends back. The rows the others leave expire on\ntheir own and are swept by the next call. The cookie is reused when the\nbrowser already holds one, so a second tab does not break the first.","operationId":"auth-authorize","parameters":[{"description":"Which OAuth provider to sign in with.","in":"path","name":"provider","required":true,"schema":{"description":"Which OAuth provider to sign in with.","pattern":"^(github|google)$","title":"Provider","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthorizeResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Authorize","tags":["auth"]}}},"components":{"schemas":{"AuthorizeResponse":{"description":"Where to send the browser, and the state to check when it comes back.","properties":{"authorization_url":{"description":"The provider consent screen to navigate to.","title":"Authorization Url","type":"string"},"state":{"description":"An opaque CSRF value to keep for the length of the redirect, compare against the 'state' the provider returns, and send back with the authorization code. A callback whose state does not match the one held by the browser that started the flow should be abandoned by the client rather than sent here; one that does is checked again against this deployment's own record of it. The response also sets an HttpOnly cookie that the callback requires, so the exchange can only be completed from the browser this call was made from.","title":"State","type":"string"}},"required":["authorization_url","state"],"title":"AuthorizeResponse","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}}}
```

## Callback

> Exchange an authorization code and set the HttpOnly session cookie.\
> \
> The session is bound to the identity the provider's account resolves to,\
> exactly as a password sign-in binds one to the identity that authenticated,\
> so every request it later authenticates resolves the same caller.\
> \
> A refusal is counted like the other sign-in failures\
> (\`\`record\_auth\_failure\`\`) and rendered by the tenancy error handler. Like\
> the passkey route there is no separate post-failure throttle: this route is\
> throttled unconditionally on the way in, because there is no legitimate\
> caller here whose correct credential must never be blocked. An authorization\
> code is single-use and minted by a redirect, not something a person retries\
> by hand.\
> \
> \*\*Maintenance mode freezes this the way it freezes the other two sign-ins.\*\*\
> The freeze is on starting a session, not on a credential, so an OAuth sign-in\
> has to answer to it or the switch is bypassable by anybody holding a Google\
> account. Refused before the exchange, so a frozen deployment makes no\
> outbound call, spends nobody's authorization code, and counts no auth\
> failure: nobody failed to authenticate, the gateway declined to try.\
> \
> A sign-in that created the account, or is the first way in to one, is\
> reported to \`\`GrowthSignalPort\`\` as a signup once it has committed. Where\
> the deployment publishes terms, that sign-in is refused with\
> \`\`terms\_not\_accepted\`\` unless the body carries \`\`terms\_accepted\`\`, and\
> nothing it would have created is kept.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/oauth/{provider}/callback":{"post":{"description":"Exchange an authorization code and set the HttpOnly session cookie.\n\nThe session is bound to the identity the provider's account resolves to,\nexactly as a password sign-in binds one to the identity that authenticated,\nso every request it later authenticates resolves the same caller.\n\nA refusal is counted like the other sign-in failures\n(``record_auth_failure``) and rendered by the tenancy error handler. Like\nthe passkey route there is no separate post-failure throttle: this route is\nthrottled unconditionally on the way in, because there is no legitimate\ncaller here whose correct credential must never be blocked. An authorization\ncode is single-use and minted by a redirect, not something a person retries\nby hand.\n\n**Maintenance mode freezes this the way it freezes the other two sign-ins.**\nThe freeze is on starting a session, not on a credential, so an OAuth sign-in\nhas to answer to it or the switch is bypassable by anybody holding a Google\naccount. Refused before the exchange, so a frozen deployment makes no\noutbound call, spends nobody's authorization code, and counts no auth\nfailure: nobody failed to authenticate, the gateway declined to try.\n\nA sign-in that created the account, or is the first way in to one, is\nreported to ``GrowthSignalPort`` as a signup once it has committed. Where\nthe deployment publishes terms, that sign-in is refused with\n``terms_not_accepted`` unless the body carries ``terms_accepted``, and\nnothing it would have created is kept.","operationId":"auth-callback","parameters":[{"description":"Which OAuth provider to sign in with.","in":"path","name":"provider","required":true,"schema":{"description":"Which OAuth provider to sign in with.","pattern":"^(github|google)$","title":"Provider","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthCallbackRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthSessionResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Callback","tags":["auth"]}}},"components":{"schemas":{"OAuthCallbackRequest":{"description":"The authorization code a provider handed the browser.\n\nNo ``redirect_uri``: this deployment derives its own from ``public_base_url``\nso the URI used to build the authorization request and the one sent with the\nexchange are the same string by construction, and a browser cannot choose\nwhat this server sends to a provider.\n\n``state`` is required, and is what binds this callback to an authorization\nrequest this deployment actually made: it is claimed from\n``oauth_pending_state`` before the code is sent anywhere, and the row it\nclaims is what carries the PKCE verifier the exchange needs. The flow\ncookie ``/authorize`` set travels alongside and binds it to the browser.","properties":{"code":{"description":"The authorization code from the provider's redirect.","maxLength":2048,"title":"Code","type":"string"},"state":{"description":"The 'state' from the provider's redirect, as issued by /authorize.","maxLength":512,"title":"State","type":"string"},"terms_accepted":{"default":false,"description":"Whether the person accepted this deployment's terms before going to the provider. Required where the bootstrap reports a terms_url and the sign-in creates the account, or is the first way in to one nobody has claimed yet; ignored otherwise.","title":"Terms Accepted","type":"boolean"}},"required":["code","state"],"title":"OAuthCallbackRequest","type":"object"},"OAuthSessionResponse":{"description":"A dashboard session minted by an OAuth sign-in (the token travels only in the cookie).\n\nThe same three fields ``POST /api/v1/auth/session`` answers, deliberately: the\ndashboard's sign-in path does not care which credential got it here.","properties":{"active_organization_id":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}],"description":"The organization that identity is acting in, which scopes every tenancy surface. Null when it is an active member of none, as an invitee who has not accepted yet is not.","title":"Active Organization Id"},"expires_at":{"description":"When the session cookie stops being accepted.","format":"date-time","title":"Expires At","type":"string"},"password_removed":{"default":false,"description":"Whether this sign-in removed a password the account held: the first provider sign-in on an address nobody had proven removes the password set before then. The person can set a new one from Account settings.","title":"Password Removed","type":"boolean"},"user_id":{"description":"The identity this session speaks for.","format":"uuid","title":"User Id","type":"string"}},"required":["expires_at","user_id","active_organization_id"],"title":"OAuthSessionResponse","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}}}
```

## Set Dashboard Password

> Set or change the password the caller signs in to the dashboard with.\
> \
> Always the caller's own identity. Supply \`\`email\`\` when it has no sign-in\
> address yet, which is the state first boot leaves the operator in, and\
> \`\`current\_password\`\` when it already has a password and the request is\
> authenticated by the session cookie. The master key in a header is what\
> excuses \`\`current\_password\`\`, which is how a forgotten password is\
> recovered; it does not excuse \`\`email\`\`, because an identity with no address\
> has nothing to sign in with whoever is asking. The operator setting a password\
> for the first time retires master-key sign-in on this deployment.\
> \
> With the master key the caller is the deployment's operator, so an \`\`email\`\`\
> sent then is the operator's own sign-in address: on an unclaimed deployment it\
> claims the deployment with that address. This never sets another account's\
> password; an address another account holds is refused with 409. To help a\
> member who forgot their password, send them a link with\
> \`\`POST /api/v1/admin/users/{user\_id}/password-link\`\`.\
> \
> Every other session this identity holds ends, the caller's own excepted, so\
> a cookie stolen before the change does not outlive it.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"security":[{"ApiKeyAuth":[]},{"XApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Enter your API key here (sent as Otari-Key header).","in":"header","name":"Otari-Key","type":"apiKey"},"XApiKeyAuth":{"description":"Anthropic-native clients send credentials here (no Bearer prefix).","in":"header","name":"x-api-key","type":"apiKey"}},"schemas":{"SetPasswordRequest":{"description":"Set or change the signed-in identity's password.\n\nThe example is the first-boot claim, because that is the call an operator\nmakes first and the one whose required fields are not obvious from the\nschema: ``email`` is optional here in general and *required* when the\nidentity has no address yet. Without it the generated Postman body carries\nonly ``new_password``, which is the one shape that cannot complete the flow\nthe docs walk through.","properties":{"current_password":{"anyOf":[{"maxLength":1024,"type":"string"},{"type":"null"}],"description":"The password being replaced. Required when the identity already has one and the request is authenticated by the session cookie; ignored when the master key is sent in a header, which needs no proof of the old password (it still needs `email` when the identity has no sign-in address yet).","title":"Current Password"},"email":{"anyOf":[{"maxLength":255,"type":"string"},{"type":"null"}],"description":"The address to sign in with. Required when the identity has none, which is the state first boot leaves the operator in, including when the master key is what authenticates the call. Resubmitting the address the identity already holds is accepted and ignored; only a *different* address is refused, because changing one is not supported yet. It is always the caller's own address, the operator's when the master key authenticates the call, and never names another account.","title":"Email"},"new_password":{"description":"The password to sign in with from now on. At least 8 characters, and at most 72 bytes, which is bcrypt's ceiling.","maxLength":1024,"minLength":8,"title":"New Password","type":"string"}},"required":["new_password"],"title":"SetPasswordRequest","type":"object"},"PasswordResponse":{"description":"What the identity signs in with now.","properties":{"email":{"description":"The address this identity signs in with.","title":"Email","type":"string"},"master_key_sign_in_retired":{"description":"Whether POST /api/v1/auth/session has stopped accepting the master key as a dashboard login. True once the operator identity has a password, which is what claiming the deployment means; a member setting their own password leaves an unclaimed deployment on the master key. Either way the master key stays the credential for the management API.","title":"Master Key Sign In Retired","type":"boolean"}},"required":["email","master_key_sign_in_retired"],"title":"PasswordResponse","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}},"paths":{"/api/v1/auth/password":{"put":{"description":"Set or change the password the caller signs in to the dashboard with.\n\nAlways the caller's own identity. Supply ``email`` when it has no sign-in\naddress yet, which is the state first boot leaves the operator in, and\n``current_password`` when it already has a password and the request is\nauthenticated by the session cookie. The master key in a header is what\nexcuses ``current_password``, which is how a forgotten password is\nrecovered; it does not excuse ``email``, because an identity with no address\nhas nothing to sign in with whoever is asking. The operator setting a password\nfor the first time retires master-key sign-in on this deployment.\n\nWith the master key the caller is the deployment's operator, so an ``email``\nsent then is the operator's own sign-in address: on an unclaimed deployment it\nclaims the deployment with that address. This never sets another account's\npassword; an address another account holds is refused with 409. To help a\nmember who forgot their password, send them a link with\n``POST /api/v1/admin/users/{user_id}/password-link``.\n\nEvery other session this identity holds ends, the caller's own excepted, so\na cookie stolen before the change does not outlive it.","operationId":"auth-set_dashboard_password","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPasswordRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Set Dashboard Password","tags":["auth"]}}}}
```

## Request Reset

> Mail a password-reset link, or do nothing: the response never says which.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/password/reset":{"post":{"description":"Mail a password-reset link, or do nothing: the response never says which.","operationId":"auth-request_reset","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestPasswordResetRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestPasswordResetResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Request Reset","tags":["auth"]}}},"components":{"schemas":{"RequestPasswordResetRequest":{"properties":{"email":{"description":"The address to send a reset link to.","maxLength":255,"title":"Email","type":"string"}},"required":["email"],"title":"RequestPasswordResetRequest","type":"object"},"RequestPasswordResetResponse":{"properties":{"message":{"description":"The same message whether or not a link was sent.","title":"Message","type":"string"}},"required":["message"],"title":"RequestPasswordResetResponse","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}}}
```

## Confirm Reset

> Complete a password reset, or choose a first password. Single-use: the token stops working after this.\
> \
> Not throttled with the address-bearing routes, for the reason the\
> verification route gives: the token is the proof.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/password/reset/confirm":{"post":{"description":"Complete a password reset, or choose a first password. Single-use: the token stops working after this.\n\nNot throttled with the address-bearing routes, for the reason the\nverification route gives: the token is the proof.","operationId":"auth-confirm_reset","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetPasswordRequest"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Confirm Reset","tags":["auth"]}}},"components":{"schemas":{"ResetPasswordRequest":{"properties":{"new_password":{"description":"The password to sign in with from now on. At least 8 characters, at most 72 bytes.","maxLength":1024,"minLength":8,"title":"New Password","type":"string"},"terms_accepted":{"default":false,"description":"Whether the caller accepted this deployment's terms. Required where the bootstrap reports a terms_url and the account has not accepted them yet.","title":"Terms Accepted","type":"boolean"},"token":{"description":"The token from the reset link.","maxLength":512,"title":"Token","type":"string"}},"required":["token","new_password"],"title":"ResetPasswordRequest","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}}}
```

## Validate Reset Link

> Say whether a password reset link can still be used, without using it.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"security":[{"ApiKeyAuth":[]},{"XApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Enter your API key here (sent as Otari-Key header).","in":"header","name":"Otari-Key","type":"apiKey"},"XApiKeyAuth":{"description":"Anthropic-native clients send credentials here (no Bearer prefix).","in":"header","name":"x-api-key","type":"apiKey"}},"schemas":{"MailedLinkRequest":{"description":"A token from a verification or password reset link.","properties":{"token":{"description":"The token from the link.","maxLength":512,"title":"Token","type":"string"}},"required":["token"],"title":"MailedLinkRequest","type":"object"},"MailedLinkStatusResponse":{"description":"Whether the link can still be used. Checking it does not use it.","properties":{"status":{"$ref":"#/components/schemas/MailedLinkStatus","description":"valid, expired, or invalid. invalid covers a link that was never issued, one already used, and one whose account was deactivated."}},"required":["status"],"title":"MailedLinkStatusResponse","type":"object"},"MailedLinkStatus":{"description":"Whether a mailed verification or password reset link can still be used.\n\nNOTE: ``INVALID`` covers a link that was never issued and one already used, because a used link's hash is gone.","enum":["valid","expired","invalid"],"title":"MailedLinkStatus","type":"string"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}},"paths":{"/api/v1/auth/password/reset/validate":{"post":{"description":"Say whether a password reset link can still be used, without using it.","operationId":"auth-validate_reset_link","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailedLinkRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailedLinkStatusResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Validate Reset Link","tags":["auth"]}}}}
```

## Get Own Profile

> Report who the caller is, whether or not they belong to an organization.\
> \
> The same shape \`\`GET /api/v1/organizations/me\`\` carries as \`\`caller\`\`, for\
> an identity that context cannot describe because it has no active\
> membership.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"security":[{"ApiKeyAuth":[]},{"XApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Enter your API key here (sent as Otari-Key header).","in":"header","name":"Otari-Key","type":"apiKey"},"XApiKeyAuth":{"description":"Anthropic-native clients send credentials here (no Bearer prefix).","in":"header","name":"x-api-key","type":"apiKey"}},"schemas":{"CallerIdentityPublic":{"description":"Who the caller is, as against what they may do.\n\nThe account control at the foot of the dashboard's sidebar draws a person,\nand no authenticated route reported the caller's own name or address, so it\ndrew a role for everybody who could reach it: \"Operator\", which is not a\nname and on a multi-tenant deployment was not even true\n(mozilla-ai/otari#832).\n\nCarried on the membership context for the reason ``deployment_operator`` is:\nthe shell reads that context before it paints, so an identity taken from it\nneeds no request of its own and cannot arrive a beat after the chrome it\nnames. Publishing it costs nothing either, since it is the caller's own\nidentity and they are holding the credential that resolved to it.\n\n``email`` and ``full_name`` are nullable for opposite reasons. A local\noperator identity has no address, because first boot provisions it with a\nname and nothing to sign in with but the master key; a member added by\naddress has no name until they claim the identity and supply one. So a shell\nhas to be ready to draw either one alone.","properties":{"claims_deployment":{"description":"Whether setting this identity's password claims the deployment, which stops the master key signing in to the dashboard. True for the deployment's operator until it holds a password, whether or not it already has an address; false for everybody else.","title":"Claims Deployment","type":"boolean"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"full_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Full Name"},"has_password":{"description":"Whether this identity holds a dashboard password. False for one that signs in only through an OAuth provider or a passkey, and for a roster entry nobody has claimed yet. PUT /api/v1/auth/password requires current_password from a cookie-authenticated caller exactly while this is true.","title":"Has Password","type":"boolean"},"user_id":{"format":"uuid","title":"User Id","type":"string"}},"required":["user_id","has_password","claims_deployment"],"title":"CallerIdentityPublic","type":"object"}}},"paths":{"/api/v1/auth/profile":{"get":{"description":"Report who the caller is, whether or not they belong to an organization.\n\nThe same shape ``GET /api/v1/organizations/me`` carries as ``caller``, for\nan identity that context cannot describe because it has no active\nmembership.","operationId":"auth-get_own_profile","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallerIdentityPublic"}}},"description":"Successful Response"}},"summary":"Get Own Profile","tags":["auth"]}}}}
```

## Update Own Profile

> Change the name the caller is known by on this deployment.\
> \
> Always the caller's own identity. The same shape\
> \`\`GET /api/v1/organizations/me\`\` carries as \`\`caller\`\` comes back, so a\
> client can seat the answer where it read the old value rather than refetch\
> the whole membership context.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"security":[{"ApiKeyAuth":[]},{"XApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Enter your API key here (sent as Otari-Key header).","in":"header","name":"Otari-Key","type":"apiKey"},"XApiKeyAuth":{"description":"Anthropic-native clients send credentials here (no Bearer prefix).","in":"header","name":"x-api-key","type":"apiKey"}},"schemas":{"UpdateProfileRequest":{"description":"The caller's own display name, or ``null`` to go back to having none.","properties":{"full_name":{"anyOf":[{"maxLength":255,"type":"string"},{"type":"null"}],"description":"The name to be known by on this deployment, or null to have none. Whitespace is collapsed, and a value with nothing else in it is stored as null, which leaves every surface naming this identity by its address again.","title":"Full Name"}},"required":["full_name"],"title":"UpdateProfileRequest","type":"object"},"CallerIdentityPublic":{"description":"Who the caller is, as against what they may do.\n\nThe account control at the foot of the dashboard's sidebar draws a person,\nand no authenticated route reported the caller's own name or address, so it\ndrew a role for everybody who could reach it: \"Operator\", which is not a\nname and on a multi-tenant deployment was not even true\n(mozilla-ai/otari#832).\n\nCarried on the membership context for the reason ``deployment_operator`` is:\nthe shell reads that context before it paints, so an identity taken from it\nneeds no request of its own and cannot arrive a beat after the chrome it\nnames. Publishing it costs nothing either, since it is the caller's own\nidentity and they are holding the credential that resolved to it.\n\n``email`` and ``full_name`` are nullable for opposite reasons. A local\noperator identity has no address, because first boot provisions it with a\nname and nothing to sign in with but the master key; a member added by\naddress has no name until they claim the identity and supply one. So a shell\nhas to be ready to draw either one alone.","properties":{"claims_deployment":{"description":"Whether setting this identity's password claims the deployment, which stops the master key signing in to the dashboard. True for the deployment's operator until it holds a password, whether or not it already has an address; false for everybody else.","title":"Claims Deployment","type":"boolean"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"full_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Full Name"},"has_password":{"description":"Whether this identity holds a dashboard password. False for one that signs in only through an OAuth provider or a passkey, and for a roster entry nobody has claimed yet. PUT /api/v1/auth/password requires current_password from a cookie-authenticated caller exactly while this is true.","title":"Has Password","type":"boolean"},"user_id":{"format":"uuid","title":"User Id","type":"string"}},"required":["user_id","has_password","claims_deployment"],"title":"CallerIdentityPublic","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}},"paths":{"/api/v1/auth/profile":{"patch":{"description":"Change the name the caller is known by on this deployment.\n\nAlways the caller's own identity. The same shape\n``GET /api/v1/organizations/me`` carries as ``caller`` comes back, so a\nclient can seat the answer where it read the old value rather than refetch\nthe whole membership context.","operationId":"auth-update_own_profile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallerIdentityPublic"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Update Own Profile","tags":["auth"]}}}}
```

## Resend Verification

> Mail a fresh verification link, or do nothing: the response never says which.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/resend-verification":{"post":{"description":"Mail a fresh verification link, or do nothing: the response never says which.","operationId":"auth-resend_verification","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResendVerificationRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResendVerificationResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Resend Verification","tags":["auth"]}}},"components":{"schemas":{"ResendVerificationRequest":{"properties":{"email":{"description":"The address to resend a verification link to.","maxLength":255,"title":"Email","type":"string"}},"required":["email"],"title":"ResendVerificationRequest","type":"object"},"ResendVerificationResponse":{"properties":{"message":{"description":"The same message whether or not the address has anything to verify.","title":"Message","type":"string"}},"required":["message"],"title":"ResendVerificationResponse","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}}}
```

## Create Session

> Verify a sign-in credential and set the HttpOnly session cookie.\
> \
> The session is bound to the identity that authenticated, so every request it\
> later authenticates resolves a user and that user's active organization\
> rather than only "a credential was presented once". The response names both,\
> so a client knows who it is signed in as without a second call.\
> \
> The rate-limit check deliberately runs only after a failed verification,\
> not before it: a pre-verification gate can't know whether \*this\* attempt\
> would have succeeded, so once an IP has used up its failure quota it\
> would end up blocking that IP's legitimate owner too, not just further\
> attackers. Running after verification also means the throttle bounds how\
> many verdicts an IP gets, not how much work it can cause: a password attempt\
> pays for a bcrypt verification (cost 12, on the order of 200ms of CPU, and\
> one is burned against a stand-in hash even for an address nobody holds)\
> before the limit is consulted, so a 429 costs the same as a 401. A gateway\
> exposed to the internet should rate-limit this path at the proxy as well.\
> \
> The maintenance-mode check runs before either credential is verified, and\
> refuses both. Before, because a frozen deployment should not spend a bcrypt\
> verification per attempt and the refusal is not about the credential\
> anyway; both, because the way back out is the master key against\
> \`\`PATCH /api/v1/settings/maintenance-mode\`\` through the header, which never\
> passes through this door. That is what keeps the way back out off the frozen\
> path, and it is why no identity needs an exemption here; an operator who no\
> longer holds the master key recovers by setting \`\`OTARI\_MASTER\_KEY\`\` and\
> restarting, which is a restart rather than a click. It leaks nothing\
> either: \`\`GET /api/v1/bootstrap\`\` already publishes the same flag\
> unauthenticated, so the sign-in screen can render the right page.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/session":{"post":{"description":"Verify a sign-in credential and set the HttpOnly session cookie.\n\nThe session is bound to the identity that authenticated, so every request it\nlater authenticates resolves a user and that user's active organization\nrather than only \"a credential was presented once\". The response names both,\nso a client knows who it is signed in as without a second call.\n\nThe rate-limit check deliberately runs only after a failed verification,\nnot before it: a pre-verification gate can't know whether *this* attempt\nwould have succeeded, so once an IP has used up its failure quota it\nwould end up blocking that IP's legitimate owner too, not just further\nattackers. Running after verification also means the throttle bounds how\nmany verdicts an IP gets, not how much work it can cause: a password attempt\npays for a bcrypt verification (cost 12, on the order of 200ms of CPU, and\none is burned against a stand-in hash even for an address nobody holds)\nbefore the limit is consulted, so a 429 costs the same as a 401. A gateway\nexposed to the internet should rate-limit this path at the proxy as well.\n\nThe maintenance-mode check runs before either credential is verified, and\nrefuses both. Before, because a frozen deployment should not spend a bcrypt\nverification per attempt and the refusal is not about the credential\nanyway; both, because the way back out is the master key against\n``PATCH /api/v1/settings/maintenance-mode`` through the header, which never\npasses through this door. That is what keeps the way back out off the frozen\npath, and it is why no identity needs an exemption here; an operator who no\nlonger holds the master key recovers by setting ``OTARI_MASTER_KEY`` and\nrestarting, which is a restart rather than a click. It leaks nothing\neither: ``GET /api/v1/bootstrap`` already publishes the same flag\nunauthenticated, so the sign-in screen can render the right page.","operationId":"auth-create_session","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSessionRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Create Session","tags":["auth"]}}},"components":{"schemas":{"CreateSessionRequest":{"description":"Sign in to the dashboard with exactly one credential.\n\nA flat body with an optional field per credential, rather than a tagged\nunion: it is one extra key on the wire, it generates a client type a\nhand-written form can fill in, and the validator below makes the two forms\nexclusive anyway.\n\nThe example carries one credential, because a generated example is a body\nsomebody will post: the schema alone would produce every field at once,\nwhich is the one shape the validator below refuses.","properties":{"email":{"anyOf":[{"maxLength":255,"type":"string"},{"type":"null"}],"description":"The identity's sign-in address.","title":"Email"},"master_key":{"anyOf":[{"maxLength":512,"type":"string"},{"type":"null"}],"description":"The gateway master key; verified once and never stored by the browser. Accepted only while the operator identity has no password, which is to say while nobody has claimed this deployment (see GET /api/v1/bootstrap).","title":"Master Key"},"password":{"anyOf":[{"maxLength":72,"type":"string"},{"type":"null"}],"description":"The identity's password.","title":"Password"}},"title":"CreateSessionRequest","type":"object"},"SessionResponse":{"description":"A freshly minted dashboard session (the token travels only in the cookie).","properties":{"active_organization_id":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}],"description":"The organization that identity is acting in, which scopes every tenancy surface. Null when it is an active member of none, as an invitee who has not accepted yet is not.","title":"Active Organization Id"},"expires_at":{"description":"When the session cookie stops being accepted.","format":"date-time","title":"Expires At","type":"string"},"user_id":{"description":"The identity this session speaks for.","format":"uuid","title":"User Id","type":"string"}},"required":["expires_at","user_id","active_organization_id"],"title":"SessionResponse","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}}}
```

## Delete Session

> Sign out: revoke the cookie's session server-side and expire the cookie.\
> \
> Deliberately unauthenticated and idempotent: it only ever revokes the\
> session named by the caller's own cookie, and the dashboard calls it on the\
> 401-bounce path where no valid credential exists anymore. Unlike the read\
> path in \`\`deps.py\`\` it applies no Sec-Fetch-Site check: \`\`SameSite=Strict\`\`\
> already keeps cross-site requests from carrying the cookie, and the worst a\
> forged call could do is sign the operator out.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/session":{"delete":{"description":"Sign out: revoke the cookie's session server-side and expire the cookie.\n\nDeliberately unauthenticated and idempotent: it only ever revokes the\nsession named by the caller's own cookie, and the dashboard calls it on the\n401-bounce path where no valid credential exists anymore. Unlike the read\npath in ``deps.py`` it applies no Sec-Fetch-Site check: ``SameSite=Strict``\nalready keeps cross-site requests from carrying the cookie, and the worst a\nforged call could do is sign the operator out.","operationId":"auth-delete_session","responses":{"204":{"description":"Successful Response"}},"summary":"Delete Session","tags":["auth"]}}}}
```

## Signup

> Claim a roster identity, register a new one, or do nothing: the response never says which.\
> \
> Which of the three this deployment will do is \`\`open\_signup\`\`, published in\
> the bootstrap so the page can say so before anyone types an address.\
> \
> No session is minted. A newly claimed or registered identity is hard-blocked\
> from signing in until it verifies, so there is nothing yet to sign it into.\
> \
> Refused with a 400 where the deployment publishes terms and \`\`terms\_accepted\`\`\
> is not set, whatever the address.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/signup":{"post":{"description":"Claim a roster identity, register a new one, or do nothing: the response never says which.\n\nWhich of the three this deployment will do is ``open_signup``, published in\nthe bootstrap so the page can say so before anyone types an address.\n\nNo session is minted. A newly claimed or registered identity is hard-blocked\nfrom signing in until it verifies, so there is nothing yet to sign it into.\n\nRefused with a 400 where the deployment publishes terms and ``terms_accepted``\nis not set, whatever the address.","operationId":"auth-signup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Signup","tags":["auth"]}}},"components":{"schemas":{"SignupRequest":{"description":"Set a password for an address, claiming or registering it.","properties":{"email":{"description":"The address to sign in with. An address an admin added or invited where this deployment keeps signup closed; any address where the bootstrap reports open_signup.","maxLength":255,"title":"Email","type":"string"},"full_name":{"anyOf":[{"maxLength":255,"type":"string"},{"type":"null"}],"description":"Filled in only if not already set.","title":"Full Name"},"password":{"description":"The password to sign in with once verified. At least 8 characters, at most 72 bytes.","maxLength":1024,"minLength":8,"title":"Password","type":"string"},"terms_accepted":{"default":false,"description":"Whether the caller accepted this deployment's terms. Required where the bootstrap reports a terms_url.","title":"Terms Accepted","type":"boolean"}},"required":["email","password"],"title":"SignupRequest","type":"object"},"SignupResponse":{"description":"The same message whether or not the address had anything to claim.","properties":{"message":{"description":"What the caller should do next.","title":"Message","type":"string"}},"required":["message"],"title":"SignupResponse","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}}}
```

## Verify Email Route

> Confirm an address from its verification link and the password chosen at signup, lifting the sign-in gate.\
> \
> One refusal for a link that is unknown, expired or spent and for a password\
> that does not match, and a refusal consumes nothing. Refusals are counted\
> per client IP and answer 429 once over the limit, so a held link cannot be\
> used to guess its password; a verification that succeeds is never counted.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/verify-email":{"post":{"description":"Confirm an address from its verification link and the password chosen at signup, lifting the sign-in gate.\n\nOne refusal for a link that is unknown, expired or spent and for a password\nthat does not match, and a refusal consumes nothing. Refusals are counted\nper client IP and answer 429 once over the limit, so a held link cannot be\nused to guess its password; a verification that succeeds is never counted.","operationId":"auth-verify_email_route","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyEmailRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyEmailResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Verify Email Route","tags":["auth"]}}},"components":{"schemas":{"VerifyEmailRequest":{"properties":{"password":{"description":"The password chosen at signup. The link verifies the address only together with it, so a link that reaches someone who did not sign up verifies nothing.","maxLength":1024,"title":"Password","type":"string"},"token":{"description":"The token from the verification link.","maxLength":512,"title":"Token","type":"string"}},"required":["token","password"],"title":"VerifyEmailRequest","type":"object"},"VerifyEmailResponse":{"properties":{"email":{"description":"The address that is now verified.","title":"Email","type":"string"}},"required":["email"],"title":"VerifyEmailResponse","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}}}
```

## Validate Verification Link

> Say whether an email verification link can still be used, without using it.\
> \
> Verifying still needs the password chosen at signup, which this does not check.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"security":[{"ApiKeyAuth":[]},{"XApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Enter your API key here (sent as Otari-Key header).","in":"header","name":"Otari-Key","type":"apiKey"},"XApiKeyAuth":{"description":"Anthropic-native clients send credentials here (no Bearer prefix).","in":"header","name":"x-api-key","type":"apiKey"}},"schemas":{"MailedLinkRequest":{"description":"A token from a verification or password reset link.","properties":{"token":{"description":"The token from the link.","maxLength":512,"title":"Token","type":"string"}},"required":["token"],"title":"MailedLinkRequest","type":"object"},"MailedLinkStatusResponse":{"description":"Whether the link can still be used. Checking it does not use it.","properties":{"status":{"$ref":"#/components/schemas/MailedLinkStatus","description":"valid, expired, or invalid. invalid covers a link that was never issued, one already used, and one whose account was deactivated."}},"required":["status"],"title":"MailedLinkStatusResponse","type":"object"},"MailedLinkStatus":{"description":"Whether a mailed verification or password reset link can still be used.\n\nNOTE: ``INVALID`` covers a link that was never issued and one already used, because a used link's hash is gone.","enum":["valid","expired","invalid"],"title":"MailedLinkStatus","type":"string"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}},"paths":{"/api/v1/auth/verify-email/validate":{"post":{"description":"Say whether an email verification link can still be used, without using it.\n\nVerifying still needs the password chosen at signup, which this does not check.","operationId":"auth-validate_verification_link","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailedLinkRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MailedLinkStatusResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Validate Verification Link","tags":["auth"]}}}}
```

## Authenticate Passkey

> Verify an assertion and set the HttpOnly session cookie.\
> \
> The session is bound to the identity whose passkey signed, exactly as a\
> password sign-in binds one to the identity that authenticated, so every\
> request it later authenticates resolves the same caller.\
> \
> A refusal is counted like the other sign-in failures\
> (\`\`record\_auth\_failure\`\`) and answered as a 401 by the tenancy error\
> handler. Unlike the password path there is no separate post-failure\
> throttle: this route is throttled unconditionally on the way in, because\
> unlike a password there is no legitimate caller here whose correct\
> credential must never be blocked (a passkey ceremony is one round trip a\
> browser drives, not something a person retries by hand).\
> \
> \*\*Maintenance mode freezes this the way it freezes the password sign-in.\*\*\
> The freeze is on starting a session, not on a credential, so a passkey has\
> to answer to it or the switch is bypassable by anybody holding one, which is\
> the whole population it exists to hold off during a redeploy. Refused before\
> the assertion is verified, so a frozen deployment does no crypto and counts\
> no auth failure: nobody failed to authenticate, the gateway declined to try.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/webauthn/authenticate":{"post":{"description":"Verify an assertion and set the HttpOnly session cookie.\n\nThe session is bound to the identity whose passkey signed, exactly as a\npassword sign-in binds one to the identity that authenticated, so every\nrequest it later authenticates resolves the same caller.\n\nA refusal is counted like the other sign-in failures\n(``record_auth_failure``) and answered as a 401 by the tenancy error\nhandler. Unlike the password path there is no separate post-failure\nthrottle: this route is throttled unconditionally on the way in, because\nunlike a password there is no legitimate caller here whose correct\ncredential must never be blocked (a passkey ceremony is one round trip a\nbrowser drives, not something a person retries by hand).\n\n**Maintenance mode freezes this the way it freezes the password sign-in.**\nThe freeze is on starting a session, not on a credential, so a passkey has\nto answer to it or the switch is bypassable by anybody holding one, which is\nthe whole population it exists to hold off during a redeploy. Refused before\nthe assertion is verified, so a frozen deployment does no crypto and counts\nno auth failure: nobody failed to authenticate, the gateway declined to try.","operationId":"auth-authenticate_passkey","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthenticatePasskeyRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasskeySessionResponse"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Authenticate Passkey","tags":["auth"]}}},"components":{"schemas":{"AuthenticatePasskeyRequest":{"description":"A completed sign-in ceremony.","properties":{"credential":{"additionalProperties":true,"description":"The browser's PublicKeyCredential assertion, serialized.","title":"Credential","type":"object"}},"required":["credential"],"title":"AuthenticatePasskeyRequest","type":"object"},"PasskeySessionResponse":{"description":"A dashboard session minted by a passkey (the token travels only in the cookie).\n\nThe same three fields ``POST /api/v1/auth/session`` answers, deliberately: the\ndashboard's sign-in path does not care which credential got it here.","properties":{"active_organization_id":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}],"description":"The organization that identity is acting in, which scopes every tenancy surface. Null when it is an active member of none, as an invitee who has not accepted yet is not.","title":"Active Organization Id"},"expires_at":{"description":"When the session cookie stops being accepted.","format":"date-time","title":"Expires At","type":"string"},"user_id":{"description":"The identity this session speaks for.","format":"uuid","title":"User Id","type":"string"}},"required":["expires_at","user_id","active_organization_id"],"title":"PasskeySessionResponse","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}}}
```

## Authentication Options

> Start a passkey sign-in. Public, throttled, and names no credentials.\
> \
> The options carry no \`\`allowCredentials\`\`, so this publishes nothing about\
> who holds a passkey here; see \`\`webauthn\_service.begin\_authentication\`\`.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"paths":{"/api/v1/auth/webauthn/authenticate/options":{"post":{"description":"Start a passkey sign-in. Public, throttled, and names no credentials.\n\nThe options carry no ``allowCredentials``, so this publishes nothing about\nwho holds a passkey here; see ``webauthn_service.begin_authentication``.","operationId":"auth-authentication_options","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CeremonyOptions"}}},"description":"Successful Response"}},"summary":"Authentication Options","tags":["auth"]}}},"components":{"schemas":{"CeremonyOptions":{"additionalProperties":true,"description":"The `PublicKeyCredentialCreationOptions`/`RequestOptions` a browser needs.\n\nPassed through as an opaque object rather than modeled field by field. The\nshape is the W3C's, the browser is the only consumer, and it is what\n``navigator.credentials`` is handed verbatim after the two base64url fields\nare decoded. Restating it here would produce a second, slightly wrong copy\nof a spec this deployment does not own, and every field the library adds\nlater would have to be added again to keep the client from dropping it.","properties":{},"title":"CeremonyOptions","type":"object"}}}}
```

## List Passkeys

> The caller's own passkeys. Never anybody else's, and never key material.\
> \
> Deliberately \*not\* behind \`\`require\_passkey\_support\`\`, and not filtered to\
> the current relying-party ID. A deployment that has changed or lost that ID\
> still holds the rows registered under the old one, and refusing to list them\
> would leave somebody looking at an empty page with no way to clean up and no\
> hint as to why. Each row carries \`\`is\_usable\`\` instead, so an orphan is\
> visible, explained, and deletable.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"security":[{"ApiKeyAuth":[]},{"XApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Enter your API key here (sent as Otari-Key header).","in":"header","name":"Otari-Key","type":"apiKey"},"XApiKeyAuth":{"description":"Anthropic-native clients send credentials here (no Bearer prefix).","in":"header","name":"x-api-key","type":"apiKey"}},"schemas":{"WebAuthnCredentialsPublic":{"properties":{"count":{"title":"Count","type":"integer"},"data":{"items":{"$ref":"#/components/schemas/WebAuthnCredentialPublic"},"title":"Data","type":"array"}},"required":["data","count"],"title":"WebAuthnCredentialsPublic","type":"object"},"WebAuthnCredentialPublic":{"description":"A passkey as the settings page lists it.\n\nCarries no key material. ``credential_id`` is here because the browser needs\nit to tell the passkey it just used from the others in the list, and it is\na public identifier the authenticator hands to any site that asks: it is\nwhat ``allowCredentials`` publishes to an unauthenticated caller during a\nceremony.","properties":{"backed_up":{"title":"Backed Up","type":"boolean"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"credential_id":{"title":"Credential Id","type":"string"},"id":{"format":"uuid","title":"Id","type":"string"},"is_usable":{"title":"Is Usable","type":"boolean"},"last_used_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Last Used At"},"name":{"maxLength":255,"title":"Name","type":"string"},"rp_id":{"title":"Rp Id","type":"string"},"transports":{"items":{"type":"string"},"title":"Transports","type":"array"}},"required":["name","id","credential_id","rp_id","is_usable","transports","backed_up","created_at","last_used_at"],"title":"WebAuthnCredentialPublic","type":"object"}}},"paths":{"/api/v1/auth/webauthn/credentials":{"get":{"description":"The caller's own passkeys. Never anybody else's, and never key material.\n\nDeliberately *not* behind ``require_passkey_support``, and not filtered to\nthe current relying-party ID. A deployment that has changed or lost that ID\nstill holds the rows registered under the old one, and refusing to list them\nwould leave somebody looking at an empty page with no way to clean up and no\nhint as to why. Each row carries ``is_usable`` instead, so an orphan is\nvisible, explained, and deletable.","operationId":"auth-list_passkeys","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnCredentialsPublic"}}},"description":"Successful Response"}},"summary":"List Passkeys","tags":["auth"]}}}}
```

## Rename Passkey

> Relabel one of the caller's passkeys, which is all that is editable.\
> \
> Ungated like the list, and for the same reason: naming an orphan before\
> deleting it is not something a lost relying-party ID should prevent.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"security":[{"ApiKeyAuth":[]},{"XApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Enter your API key here (sent as Otari-Key header).","in":"header","name":"Otari-Key","type":"apiKey"},"XApiKeyAuth":{"description":"Anthropic-native clients send credentials here (no Bearer prefix).","in":"header","name":"x-api-key","type":"apiKey"}},"schemas":{"WebAuthnCredentialUpdate":{"description":"Renaming a passkey, which is the only thing about one that is editable.\n\nEverything else on the row is what the authenticator asserted, so there is\nnothing else a person could correct.","properties":{"name":{"maxLength":255,"title":"Name","type":"string"}},"required":["name"],"title":"WebAuthnCredentialUpdate","type":"object"},"WebAuthnCredentialPublic":{"description":"A passkey as the settings page lists it.\n\nCarries no key material. ``credential_id`` is here because the browser needs\nit to tell the passkey it just used from the others in the list, and it is\na public identifier the authenticator hands to any site that asks: it is\nwhat ``allowCredentials`` publishes to an unauthenticated caller during a\nceremony.","properties":{"backed_up":{"title":"Backed Up","type":"boolean"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"credential_id":{"title":"Credential Id","type":"string"},"id":{"format":"uuid","title":"Id","type":"string"},"is_usable":{"title":"Is Usable","type":"boolean"},"last_used_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Last Used At"},"name":{"maxLength":255,"title":"Name","type":"string"},"rp_id":{"title":"Rp Id","type":"string"},"transports":{"items":{"type":"string"},"title":"Transports","type":"array"}},"required":["name","id","credential_id","rp_id","is_usable","transports","backed_up","created_at","last_used_at"],"title":"WebAuthnCredentialPublic","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}},"paths":{"/api/v1/auth/webauthn/credentials/{credential_id}":{"patch":{"description":"Relabel one of the caller's passkeys, which is all that is editable.\n\nUngated like the list, and for the same reason: naming an orphan before\ndeleting it is not something a lost relying-party ID should prevent.","operationId":"auth-rename_passkey","parameters":[{"in":"path","name":"credential_id","required":true,"schema":{"format":"uuid","title":"Credential Id","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnCredentialUpdate"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnCredentialPublic"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Rename Passkey","tags":["auth"]}}}}
```

## Delete Passkey

> Remove one of the caller's passkeys.\
> \
> Removing the last one is allowed: an email and password is still this\
> deployment's login, so this is not a lockout, and refusing would strand\
> whoever lost the authenticator.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"security":[{"ApiKeyAuth":[]},{"XApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Enter your API key here (sent as Otari-Key header).","in":"header","name":"Otari-Key","type":"apiKey"},"XApiKeyAuth":{"description":"Anthropic-native clients send credentials here (no Bearer prefix).","in":"header","name":"x-api-key","type":"apiKey"}},"schemas":{"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}},"paths":{"/api/v1/auth/webauthn/credentials/{credential_id}":{"delete":{"description":"Remove one of the caller's passkeys.\n\nRemoving the last one is allowed: an email and password is still this\ndeployment's login, so this is not a lockout, and refusing would strand\nwhoever lost the authenticator.","operationId":"auth-delete_passkey","parameters":[{"in":"path","name":"credential_id","required":true,"schema":{"format":"uuid","title":"Credential Id","type":"string"}}],"responses":{"204":{"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Delete Passkey","tags":["auth"]}}}}
```

## Register Passkey

> Verify a registration ceremony and store the passkey it produced.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"security":[{"ApiKeyAuth":[]},{"XApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Enter your API key here (sent as Otari-Key header).","in":"header","name":"Otari-Key","type":"apiKey"},"XApiKeyAuth":{"description":"Anthropic-native clients send credentials here (no Bearer prefix).","in":"header","name":"x-api-key","type":"apiKey"}},"schemas":{"RegisterPasskeyRequest":{"description":"A completed registration ceremony, with the label to file it under.","properties":{"credential":{"additionalProperties":true,"description":"The browser's PublicKeyCredential, serialized.","title":"Credential","type":"object"},"name":{"anyOf":[{"maxLength":255,"type":"string"},{"type":"null"}],"description":"What to call this passkey in the credential list. Optional: an unnamed one is numbered rather than refused, so a browser that offers no prompt still works.","title":"Name"}},"required":["credential"],"title":"RegisterPasskeyRequest","type":"object"},"WebAuthnCredentialPublic":{"description":"A passkey as the settings page lists it.\n\nCarries no key material. ``credential_id`` is here because the browser needs\nit to tell the passkey it just used from the others in the list, and it is\na public identifier the authenticator hands to any site that asks: it is\nwhat ``allowCredentials`` publishes to an unauthenticated caller during a\nceremony.","properties":{"backed_up":{"title":"Backed Up","type":"boolean"},"created_at":{"format":"date-time","title":"Created At","type":"string"},"credential_id":{"title":"Credential Id","type":"string"},"id":{"format":"uuid","title":"Id","type":"string"},"is_usable":{"title":"Is Usable","type":"boolean"},"last_used_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Last Used At"},"name":{"maxLength":255,"title":"Name","type":"string"},"rp_id":{"title":"Rp Id","type":"string"},"transports":{"items":{"type":"string"},"title":"Transports","type":"array"}},"required":["name","id","credential_id","rp_id","is_usable","transports","backed_up","created_at","last_used_at"],"title":"WebAuthnCredentialPublic","type":"object"},"HTTPValidationError":{"properties":{"code":{"const":"invalid_request","description":"Stable error code, also sent as the Otari-Error-Code header.","title":"Code","type":"string"},"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"title":"Detail","type":"array"}},"title":"HTTPValidationError","type":"object"},"ValidationError":{"properties":{"ctx":{"title":"Context","type":"object"},"input":{"title":"Input"},"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"title":"Location","type":"array"},"msg":{"title":"Message","type":"string"},"type":{"title":"Error Type","type":"string"}},"required":["loc","msg","type"],"title":"ValidationError","type":"object"}}},"paths":{"/api/v1/auth/webauthn/register":{"post":{"description":"Verify a registration ceremony and store the passkey it produced.","operationId":"auth-register_passkey","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterPasskeyRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnCredentialPublic"}}},"description":"Successful Response"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}},"description":"Validation Error"}},"summary":"Register Passkey","tags":["auth"]}}}}
```

## Registration Options

> Start registering a passkey for the signed-in identity.\
> \
> A POST rather than a GET even though it reads like one: it issues a\
> server-side challenge and writes it, so it is not safe to repeat, cache, or\
> prefetch.

```json
{"openapi":"3.1.0","info":{"title":"otari","version":"0.33.0"},"security":[{"ApiKeyAuth":[]},{"XApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"description":"Enter your API key here (sent as Otari-Key header).","in":"header","name":"Otari-Key","type":"apiKey"},"XApiKeyAuth":{"description":"Anthropic-native clients send credentials here (no Bearer prefix).","in":"header","name":"x-api-key","type":"apiKey"}},"schemas":{"CeremonyOptions":{"additionalProperties":true,"description":"The `PublicKeyCredentialCreationOptions`/`RequestOptions` a browser needs.\n\nPassed through as an opaque object rather than modeled field by field. The\nshape is the W3C's, the browser is the only consumer, and it is what\n``navigator.credentials`` is handed verbatim after the two base64url fields\nare decoded. Restating it here would produce a second, slightly wrong copy\nof a spec this deployment does not own, and every field the library adds\nlater would have to be added again to keep the client from dropping it.","properties":{},"title":"CeremonyOptions","type":"object"}}},"paths":{"/api/v1/auth/webauthn/register/options":{"post":{"description":"Start registering a passkey for the signed-in identity.\n\nA POST rather than a GET even though it reads like one: it issues a\nserver-side challenge and writes it, so it is not safe to repeat, cache, or\nprefetch.","operationId":"auth-registration_options","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CeremonyOptions"}}},"description":"Successful Response"}},"summary":"Registration Options","tags":["auth"]}}}}
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.otari.ai/otari/for-integrators/api-reference/auth.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
