{"openapi":"3.1.0","info":{"title":"TillAuth — end-user API","version":"1.0.0","description":"The surface an end user's browser calls: sign-in and sign-up, magic links, passwords, passkeys, TOTP,\npush approval polling, sessions and email verification. The hosted pages (`<slug>.tilldev.app`) are\nreference clients of exactly this API — an app's own settings UI can call it from any **registered\nredirect origin** with the user's Bearer access token. See the Embedded settings guide:\nhttps://tilldev.dev/docs/tillauth/embedded\n\n## App resolution\nEvery route resolves its target app (realm) from the `X-TillAuth-App` header (the app public id `tau_…`,\nor its publishable key `tau_pub_…`, the same id respelled), an `Authorization: Bearer tau_pub_…` publishable\nkey, or else the `<slug>.tilldev.app` host. When the header and a publishable key are both sent they must name\nthe same app. Routes that take an end-user access token occupy the Authorization header, so they need the\nheader or the hosted host.\n\n## Realm strictness\nAccount-management routes verify `claims.app === target app` — a token from one realm is a plain 401\nagainst another. Tokens are minted by sign-in, magic consume, passkey/TOTP/push completion, refresh,\nand the OIDC token endpoint; all mint the same realm token.\n\n## Non-enumeration\nEmail-accepting unauthenticated routes (magic send, password reset request, verify resend, signup with a\ntaken email) answer success-shaped regardless of whether the account exists, and pad response timing.\nDo not build UI that expects \"no such account\" from them.\n\n## Step-up and the credential floor\nAdding or removing a factor on an account that holds one requires an `mfa: true` session (403\n`step_up_required` otherwise). Removals that would strand the account — its last way in, or its last\nsecond factor on an MFA-required app — are refused with 409 and a human-readable reason."},"servers":[{"url":"https://auth.tilldev.dev"}],"tags":[{"name":"Sign in","description":"Password sign-in and the second-factor completions it can demand."},{"name":"Sign up","description":"Self-serve account creation (apps can be invite-only)."},{"name":"Magic links","description":"Email link sign-in. The click-through always lands on the hosted pages; embed only the trigger."},{"name":"Password","description":"Set (first), change, and reset."},{"name":"Passkeys","description":"WebAuthn registration, sign-in, management, and counter-lock recovery."},{"name":"TOTP","description":"Authenticator-app second factor + backup codes."},{"name":"Sessions","description":"Token refresh, sign-out, and per-device session management."},{"name":"Account","description":"The signed-in user and their factor inventory."},{"name":"Email verification","description":"Verify-email consume and resend."},{"name":"SSO session","description":"The identity provider's own session. The hosted pages keep it in an HttpOnly cookie on their own origin and trade it for a one-minute code that the browser redeems, so a returning user signs in to another client without a password. It ends with the sign-in it came from, after the idle limit or at the absolute limit."},{"name":"Access","description":"Identity-aware access for internal apps. Your reverse proxy asks `/v1/access/check` about every request and forwards `/.tilldev/access/*` on each protected host to `/v1/access/flow/*`. Every call carries the access app key in `X-TillDev-Access-Key` and the protected host in `X-Forwarded-Host`."}],"components":{"securitySchemes":{"accessKey":{"type":"apiKey","in":"header","name":"X-TillDev-Access-Key","description":"The access app key (`tdak_…`). Only your proxy holds it; browsers never see it."},"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"The end-user access token minted by any sign-in completion on this realm. An app with its own signing key signs it under the issuer `/realms/{app id}` (keys at `/realms/{app id or public id}/.well-known/jwks.json`); otherwise the shared key signs it (`/.well-known/jwks.json`). Claims include `app` (app id) and `pid` (public id)."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string","description":"Human-readable message, written to be shown to the user."},"code":{"type":"string","description":"Stable machine code, present on some refusals (e.g. `account_deactivated`, `email_unverified`)."},"hint":{"type":"string","description":"Optional next-action hint (e.g. the password-less account hint on sign-in)."}}},"StepUpError":{"type":"object","required":["error","error_description"],"properties":{"error":{"type":"string","enum":["step_up_required"]},"error_description":{"type":"string","description":"Human-readable instruction (what to re-authenticate with)."}}},"TokenBundle":{"type":"object","required":["access_token","refresh_token","refresh_expires_at"],"properties":{"access_token":{"type":"string","description":"Short-lived JWT. Realm-bound (`app` claim); `mfa: true` when this session proved a second factor."},"refresh_token":{"type":"string","description":"Rotates on every /v1/refresh; reuse of a rotated token revokes the family."},"refresh_expires_at":{"type":"string","format":"date-time"}}},"PublicUser":{"type":"object","required":["id","email","email_verified"],"properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"display_name":{"type":["string","null"]},"email_verified":{"type":"boolean"}}},"SessionBundle":{"type":"object","description":"A full session: the user plus a token pair.","required":["ok","user","access_token","refresh_token","refresh_expires_at"],"properties":{"ok":{"type":"boolean","enum":[true]},"user":{"$ref":"#/components/schemas/PublicUser"},"access_token":{"type":"string","description":"Short-lived JWT. Realm-bound (`app` claim); `mfa: true` when this session proved a second factor."},"refresh_token":{"type":"string","description":"Rotates on every /v1/refresh; reuse of a rotated token revokes the family."},"refresh_expires_at":{"type":"string","format":"date-time"}}},"MfaChallenge":{"type":"object","description":"Second factor demanded. Trade the challenge token at /v1/totp/verify-signin (TOTP or backup code), poll /v1/push/signin/poll when `method` is \"push\", or send the emailed code to /v1/signin/email-code when `method` is \"email\" (an unusual sign-in by someone with no second factor).","required":["mfa_required","challenge_token"],"properties":{"mfa_required":{"type":"boolean","enum":[true]},"challenge_token":{"type":"string"},"method":{"type":"string","enum":["push","email"],"description":"Absent for TOTP."},"email_hint":{"type":"string","description":"The masked address the code went to (method: \"email\" only)."},"expires_at":{"type":"string","format":"date-time","description":"When the emailed code stops working (method: \"email\" only)."},"push":{"type":"object","description":"Push approval details (method: \"push\" only).","properties":{"challenge_id":{"type":"string","format":"uuid"},"match_number":{"type":"integer","description":"Number the user must match on their Authenticator."},"expires_at":{"type":"string","format":"date-time"}}}}},"Factors":{"type":"object","description":"Counts and booleans only — never secrets, hashes or credential ids.","required":["totp","backup_codes","passkeys","passkeys_locked","push_devices"],"properties":{"totp":{"type":"boolean"},"backup_codes":{"type":"integer","description":"Remaining single-use backup codes."},"passkeys":{"type":"integer","description":"Live (unlocked) passkeys."},"passkeys_locked":{"type":"integer","description":"Passkeys locked by counter regression — unlockable via /v1/passkeys/{id}/unlock."},"push_devices":{"type":"integer","description":"Enrolled Till Authenticator devices."}}},"Me":{"type":"object","required":["user"],"properties":{"user":{"type":"object","required":["id","email","email_verified","mfa_enrolled","factors","has_password","groups"],"properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string"},"email_verified":{"type":"boolean"},"display_name":{"type":["string","null"]},"mfa_enrolled":{"type":"boolean","description":"True when the account holds ANY live second factor (TOTP, live passkey, or push device)."},"factors":{"$ref":"#/components/schemas/Factors"},"has_password":{"type":"boolean"},"groups":{"type":"array","items":{"type":"string"},"description":"Every group the user is in, by name, sorted. Empty when none."}}}}},"Passkey":{"type":"object","required":["id","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"label":{"type":["string","null"]},"aaguid":{"type":["string","null"]},"last_used_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"locked_at":{"type":["string","null"],"format":"date-time","description":"Non-null when locked by a security-counter regression."},"locked_reason":{"type":["string","null"]}}},"SessionSummary":{"type":"object","required":["sid","current","started_at","last_seen_at","expires_at","status"],"properties":{"sid":{"type":"string","format":"uuid"},"current":{"type":"boolean"},"device_id":{"type":["string","null"]},"ip":{"type":["string","null"]},"country":{"type":["string","null"],"description":"ISO 3166-1 alpha-2 country the newest token was issued in, when known."},"user_agent":{"type":["string","null"]},"started_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time"},"expires_at":{"type":"string","format":"date-time"},"status":{"type":"string","enum":["active","revoked","expired"]}}}}},"paths":{"/v1/access/check":{"get":{"tags":["Access"],"summary":"Decide one proxied request","description":"Called by the reverse proxy before it forwards a request. Any method is accepted, and a path appended after `/v1/access/check` is read as the request path when `X-Forwarded-Uri` is absent. The session cookie, revocation, the account, the user’s role and the request’s risk are re-read every time.","security":[{"accessKey":[]}],"parameters":[{"name":"X-Forwarded-Host","in":"header","required":true,"schema":{"type":"string"},"description":"The protected host. Must be one of the access app’s hosts."},{"name":"X-Forwarded-Uri","in":"header","schema":{"type":"string"},"description":"The original path and query. `X-Original-URI` is read too."},{"name":"X-Forwarded-Method","in":"header","schema":{"type":"string"},"description":"The original method. `X-Original-Method` is read too."},{"name":"X-TillDev-Client-Ip","in":"header","schema":{"type":"string"},"description":"The visitor’s address as your proxy saw it. Without it, network and country rules refuse the request and the network risk signal is off."},{"name":"X-TillDev-Client-Country","in":"header","schema":{"type":"string","pattern":"^[A-Z]{2}$"},"description":"The visitor’s country, when your edge knows it. Country rules refuse requests without one."},{"name":"X-TillDev-Access-Mode","in":"header","schema":{"type":"string","enum":["status"]},"description":"`status` answers 401 instead of a redirect, for proxies that redirect themselves."}],"responses":{"200":{"description":"Forward the request. On an allowed request the identity headers carry the signed-in user; on a public path they are empty unless someone is signed in.","headers":{"X-TillDev-User-Id":{"schema":{"type":"string"}},"X-TillDev-Email":{"schema":{"type":"string"}},"X-TillDev-Role":{"schema":{"type":"string"}},"X-TillDev-Mfa":{"schema":{"type":"string","enum":["true","false",""]}},"X-TillDev-Assertion":{"schema":{"type":"string"},"description":"An EdDSA JWT (`typ` `tilldev-access+jwt`, audience = the access app’s client id, five minutes) your app can verify against the published keys."}}},"302":{"description":"A page view that needs a sign-in: `Location` is `/.tilldev/access/start` on the same host."},"401":{"description":"A sign-in is needed and this is not a page view, or the status mode was asked for.","content":{"application/json":{"schema":{"type":"object","required":["error","reason","signin"],"properties":{"error":{"type":"string","enum":["signin_required"]},"reason":{"type":"string","enum":["no_session","mfa","fresh","risk"]},"signin":{"type":"string"}}}}}},"403":{"description":"Refused by the policy, or the key or host is wrong (`X-TillDev-Access-Error: key|host`)."}}}},"/v1/access/flow/start":{"get":{"tags":["Access"],"summary":"Start a sign-in","description":"Reached as `/.tilldev/access/start?rd=<path>` on the protected host. Sends the browser to the hosted sign-in with PKCE, and asks for a second factor or a recent sign-in when the rule for `rd` needs one.","security":[{"accessKey":[]}],"parameters":[{"name":"rd","in":"query","schema":{"type":"string"},"description":"Where to return: a path on the same host, or a full URL on it."}],"responses":{"302":{"description":"To the hosted sign-in, or straight back when the policy already allows `rd`."},"403":{"description":"The policy refuses `rd` whoever signs in."},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/access/flow/callback":{"get":{"tags":["Access"],"summary":"Finish a sign-in","description":"Reached as `/.tilldev/access/callback`. Redeems the one-time code with the verifier from the sign-in cookie, starts a session and sets a host-only, HTTP-only cookie.","security":[{"accessKey":[]}],"parameters":[{"name":"code","in":"query","schema":{"type":"string"}},{"name":"state","in":"query","schema":{"type":"string"}}],"responses":{"302":{"description":"Back to the page asked for."},"400":{"description":"The sign-in expired, was started in another browser, or the code was already used."},"403":{"description":"Signed in, but the policy still refuses the page."}}}},"/v1/access/flow/signout":{"get":{"tags":["Access"],"summary":"Sign out of the access app","description":"Reached as `/.tilldev/access/signout`. Ends the session at once and clears the cookie.","security":[{"accessKey":[]}],"responses":{"200":{"description":"Signed out."}}},"post":{"tags":["Access"],"summary":"Sign out of the access app","security":[{"accessKey":[]}],"responses":{"200":{"description":"Signed out."}}}},"/v1/signin":{"post":{"tags":["Sign in"],"summary":"Password sign-in","description":"Success is one of three shapes: a full session, a TOTP challenge, or a push challenge. Failures are deliberately uniform (`Invalid credentials`) to prevent account enumeration; password-less accounts get an unconditional hint pointing at their real method. 8 failed attempts lock the account for 15 minutes (423).","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string","minLength":3,"maxLength":254},"password":{"type":"string","minLength":1,"maxLength":512}}}}}},"responses":{"200":{"description":"Session, TOTP challenge, or push challenge.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/SessionBundle"},{"$ref":"#/components/schemas/MfaChallenge"}]}}}},"401":{"description":"Invalid credentials (uniform for unknown account / wrong password; may carry a `hint`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Method disabled for this app, email not permitted, account deactivated (`code: account_deactivated`), email unverified (`code: email_unverified`), or the sign-in crossed the app's risk block threshold (`code: signin_blocked`; the user is emailed).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"423":{"description":"Account temporarily locked after repeated failures.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/signup":{"post":{"tags":["Sign up"],"summary":"Create an account","description":"Refused with 403 when the app is invite-only (`self_signup` off). A taken email returns a bare `{ ok: true }` with no session — success-shaped, non-enumerating: this route is unauthenticated, so a distinguishable answer would let anyone test which addresses are registered. The account's real owner is emailed \"you already have an account\" instead. A NEW account always returns the full bundle including `user.id`, even when email verification is pending (`email_verified: false`) — so the only response without an id is the taken-email case. Server-side provisioning that needs a guaranteed id for every address should not use this route: use the authenticated admin surface (`POST /admin/apps/{public_id}/users/import`, or SCIM `POST /Users`), which returns the id and reports an existing account explicitly as `conflict`.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string","minLength":3,"maxLength":254},"password":{"type":"string","minLength":8,"maxLength":512},"display_name":{"type":"string","maxLength":120}}}}}},"responses":{"200":{"description":"Session (or bare `{ok:true}` for a taken email).","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/SessionBundle"},{"type":"object","properties":{"ok":{"type":"boolean"}}}]}}}},"400":{"description":"Invalid input / invalid email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Invite-only app, or email not permitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/magic/send":{"post":{"tags":["Magic links"],"summary":"Send a sign-in link","description":"The embedable trigger. The emailed link ALWAYS lands on the hosted pages (the token is verified on an origin TillAuth controls), then hands off via the app's validated redirect. Success-shaped for unknown accounts and per-email rate limits — non-enumerating.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","minLength":3,"maxLength":254},"redirect_uri":{"type":"string","format":"uri","maxLength":512,"description":"Where to land after the click-through; must be in the app's allow-list."}}}}}},"responses":{"200":{"description":"Accepted (whether or not the account exists).","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"redirect_uri is not in the allow-list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Method disabled for this app, or email not permitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/magic/consume":{"post":{"tags":["Magic links"],"summary":"Consume a link token (hosted pages call this)","description":"Single-use, network-checked, and re-checks the allow-list. When the account has TOTP enrolled the response is an MFA challenge instead of a session. Embedded UIs normally never call this — the hosted landing does.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string","minLength":16,"maxLength":256}}}}}},"responses":{"200":{"description":"Session (plus `redirect_uri` echo) or an MFA challenge.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/SessionBundle"},{"$ref":"#/components/schemas/MfaChallenge"}]}}}},"400":{"description":"Invalid or expired link / link already used.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Link opened from a different network, or email no longer permitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/password/set":{"post":{"tags":["Password"],"summary":"Set a first password (password-less accounts)","description":"For accounts born via magic link, OAuth, or invite. Session-bound — no mailbox round-trip. Step-up-gated when the account already holds a second factor. 409 when a password already exists.","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["new_password"],"properties":{"new_password":{"type":"string","minLength":8,"maxLength":512}}}}}},"responses":{"200":{"description":"Password set. Existing sessions stay valid (the account gained a credential).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"401":{"description":"Missing or invalid token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Step-up required: the account holds a second factor but this session has not proven one. Send the user back through sign-in (completing their factor mints an `mfa: true` token), then retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpError"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Account already has a password — change it instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/password/change":{"post":{"tags":["Password"],"summary":"Change the password","description":"Proves the current password. On success EVERY session (including this one) is revoked — the client must sign in again.","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["current_password","new_password"],"properties":{"current_password":{"type":"string","minLength":1,"maxLength":512},"new_password":{"type":"string","minLength":8,"maxLength":512}}}}}},"responses":{"200":{"description":"Changed; all sessions revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"sessions_revoked":{"type":"boolean"}}}}}},"400":{"description":"Invalid input, or new password equals the current one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing/invalid token, or current password is incorrect.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"This account has no password to change (use /v1/password/set).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/password/reset-request":{"post":{"tags":["Password"],"summary":"Request a reset link","description":"Non-enumerating: always `{ ok: true }` whether or not the account exists. The emailed link lands on the hosted reset page.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","minLength":3,"maxLength":254}}}}}},"responses":{"200":{"description":"Accepted.","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/password/reset":{"post":{"tags":["Password"],"summary":"Complete a reset (hosted pages call this)","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token","new_password"],"properties":{"token":{"type":"string","minLength":16,"maxLength":256},"new_password":{"type":"string","minLength":8,"maxLength":512}}}}}},"responses":{"200":{"description":"Password reset.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"Invalid or expired link / link already used.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/passkeys/register/start":{"post":{"tags":["Passkeys"],"summary":"Begin registering a passkey","description":"Returns WebAuthn creation options. Step-up-gated when the account already holds a second factor.","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["origin"],"properties":{"origin":{"type":"string","minLength":1,"maxLength":512,"description":"The calling page's origin; must be registered for the app."},"label":{"type":"string","maxLength":80}}}}}},"responses":{"200":{"description":"WebAuthn options to pass to navigator.credentials.create().","content":{"application/json":{"schema":{"type":"object","required":["options","challenge_token"],"properties":{"options":{"type":"object","description":"PublicKeyCredentialCreationOptions (JSON encoding)."},"challenge_token":{"type":"string"},"label":{"type":["string","null"]}}}}}},"400":{"description":"Invalid body / unsupported origin.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Sign in required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Step-up required: the account holds a second factor but this session has not proven one. Send the user back through sign-in (completing their factor mints an `mfa: true` token), then retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpError"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/passkeys/register/finish":{"post":{"tags":["Passkeys"],"summary":"Complete registering a passkey","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["origin","challenge_token","response"],"properties":{"origin":{"type":"string","minLength":1,"maxLength":512},"challenge_token":{"type":"string"},"response":{"type":"object","description":"RegistrationResponseJSON from navigator.credentials.create()."},"label":{"type":["string","null"],"maxLength":80}}}}}},"responses":{"200":{"description":"Passkey registered.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"Invalid body, unsupported origin, expired challenge, or attestation failure.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Sign in required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Step-up required: the account holds a second factor but this session has not proven one. Send the user back through sign-in (completing their factor mints an `mfa: true` token), then retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpError"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/passkeys/signin/start":{"post":{"tags":["Passkeys"],"summary":"Begin passkey sign-in","description":"Returns WebAuthn request options. A challenge is issued even for unknown emails — non-enumerating.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["origin"],"properties":{"origin":{"type":"string","minLength":1,"maxLength":512},"email":{"type":"string","description":"Optional — narrows allowCredentials for non-discoverable flows."}}}}}},"responses":{"200":{"description":"WebAuthn options to pass to navigator.credentials.get().","content":{"application/json":{"schema":{"type":"object","required":["options","challenge_token"],"properties":{"options":{"type":"object","description":"PublicKeyCredentialRequestOptions (JSON encoding)."},"challenge_token":{"type":"string"}}}}}},"400":{"description":"Invalid body / unsupported origin.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/passkeys/signin/finish":{"post":{"tags":["Passkeys"],"summary":"Complete passkey sign-in","description":"The assertion IS the authentication — a successful passkey sign-in mints an `mfa: true` session. A security-counter regression locks the credential (401) rather than accepting a possible clone; the owner recovers via /v1/passkeys/{id}/unlock from a signed-in session.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["origin","challenge_token","response"],"properties":{"origin":{"type":"string","minLength":1,"maxLength":512},"challenge_token":{"type":"string"},"response":{"type":"object","description":"AuthenticationResponseJSON from navigator.credentials.get()."}}}}}},"responses":{"200":{"description":"Session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionBundle"}}}},"400":{"description":"Invalid body, unsupported origin, or expired challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unknown or locked passkey, signature failure, or counter regression (credential locked).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Email not permitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/passkeys":{"get":{"tags":["Passkeys"],"summary":"List the user's passkeys","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"responses":{"200":{"description":"Passkeys, including locked ones.","content":{"application/json":{"schema":{"type":"object","required":["passkeys"],"properties":{"passkeys":{"type":"array","items":{"$ref":"#/components/schemas/Passkey"}}}}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/passkeys/{id}":{"delete":{"tags":["Passkeys"],"summary":"Remove a passkey","description":"Refused with 409 (reason stated) when this live passkey is the account's last way in, or its last second factor on an MFA-required app. Locked passkeys are always deletable.","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Removed.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app, or passkey not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Floor refusal — removal would strand the account; the reason says what to do first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/passkeys/{id}/unlock":{"post":{"tags":["Passkeys"],"summary":"Unlock a counter-locked passkey","description":"For benign counter regressions (device restore/migration). Resets the stored counter so the next assertion re-syncs. Step-up-gated when the account holds another live factor. If the lock was NOT a restore you recognise, delete the passkey instead.","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Unlocked; counter re-syncs on next use.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"401":{"description":"Unauthorized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Step-up required: the account holds a second factor but this session has not proven one. Send the user back through sign-in (completing their factor mints an `mfa: true` token), then retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpError"}}}},"404":{"description":"Unknown app, or passkey not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"This passkey is not locked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/totp/setup":{"post":{"tags":["TOTP"],"summary":"Begin TOTP enrolment","description":"Proof ladder: accounts WITH a password confirm it; password-less accounts proceed on their session alone when factor-less, or must present an `mfa: true` session when a factor exists (403 step-up).","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"current_password":{"type":"string","minLength":1,"maxLength":512,"description":"Required only when the account has a password."}}}}}},"responses":{"200":{"description":"Pending enrolment (not yet enabled).","content":{"application/json":{"schema":{"type":"object","required":["ok","otpauth_uri","secret","qr_png"],"properties":{"ok":{"type":"boolean"},"otpauth_uri":{"type":"string"},"secret":{"type":"string","description":"Shown once for manual entry."},"qr_png":{"type":"string","description":"data: URL of the QR code."}}}}}},"400":{"description":"Password confirmation required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing/invalid token, or current password incorrect.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Step-up required: the account holds a second factor but this session has not proven one. Send the user back through sign-in (completing their factor mints an `mfa: true` token), then retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpError"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"TOTP is already enabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/totp/verify-setup":{"post":{"tags":["TOTP"],"summary":"Prove the first code and enable TOTP","description":"On success returns the 10 single-use backup codes (shown exactly once) AND a fresh `mfa: true` token pair — store it, replacing the old one.","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","minLength":6,"maxLength":6},"current_password":{"type":"string","minLength":1,"maxLength":512}}}}}},"responses":{"200":{"description":"Enabled. Backup codes + re-minted mfa-true session.","content":{"application/json":{"schema":{"type":"object","required":["ok","backup_codes","access_token","refresh_token","refresh_expires_at"],"properties":{"ok":{"type":"boolean"},"backup_codes":{"type":"array","items":{"type":"string"}},"message":{"type":"string"},"access_token":{"type":"string","description":"Short-lived JWT. Realm-bound (`app` claim); `mfa: true` when this session proved a second factor."},"refresh_token":{"type":"string","description":"Rotates on every /v1/refresh; reuse of a rotated token revokes the family."},"refresh_expires_at":{"type":"string","format":"date-time"}}}}}},"400":{"description":"Code missing, password confirmation required, or setup not started.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing/invalid token, or current password incorrect.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Step-up required: the account holds a second factor but this session has not proven one. Send the user back through sign-in (completing their factor mints an `mfa: true` token), then retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpError"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"TOTP is already enabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Invalid code — try again with the latest one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/totp/verify-signin":{"post":{"tags":["TOTP"],"summary":"Complete a sign-in MFA challenge","description":"Trades the `challenge_token` from sign-in or magic consume, plus a 6-digit TOTP code OR an XXXXX-XXXXX backup code (consumed on use), for a full `mfa: true` session.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["challenge_token","code"],"properties":{"challenge_token":{"type":"string","minLength":16},"code":{"type":"string","pattern":"^[0-9A-Z\\-]{6,12}$"}}}}}},"responses":{"200":{"description":"Session.","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"ok":{"type":"boolean"}}},{"$ref":"#/components/schemas/TokenBundle"}]}}}},"400":{"description":"Invalid input, or TOTP not enabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Invalid challenge.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Invalid code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/signin/email-code":{"post":{"tags":["Sign in"],"summary":"Complete an unusual sign-in with the emailed code","description":"When the app scores sessions in enforce mode and a sign-in crosses its sign-in-again threshold, a user with no second factor is emailed a 6-digit code. Send it with the `challenge_token` to get the session. Codes last 10 minutes, work once, and only the newest works.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["challenge_token","code"],"properties":{"challenge_token":{"type":"string","minLength":16},"code":{"type":"string","pattern":"^\\d{6}$"}}}}}},"responses":{"200":{"description":"Session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionBundle"}}}},"400":{"description":"Invalid input.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Challenge expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Email no longer permitted, or account deactivated (`code: account_deactivated`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Wrong, spent or expired code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/totp/disable":{"post":{"tags":["TOTP"],"summary":"Turn TOTP off","description":"Same proof ladder as setup. Refused with 409 (reason stated) when TOTP is the last second factor on an app that requires MFA. Destroys the secret and remaining backup codes.","security":[{"bearerAuth":[]}],"parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"current_password":{"type":"string","minLength":1,"maxLength":512}}}}}},"responses":{"200":{"description":"Disabled.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"Password confirmation required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing/invalid token, or current password incorrect.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Step-up required: the account holds a second factor but this session has not proven one. Send the user back through sign-in (completing their factor mints an `mfa: true` token), then retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StepUpError"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Floor refusal — disabling would strand the account on an MFA-required app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/push/signin/poll":{"post":{"tags":["Sign in"],"summary":"Poll a push approval challenge","description":"The initiating browser polls after /v1/signin answered `method: \"push\"`. The Till Authenticator device approves out-of-band (its own signed protocol, not part of this spec).","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["challenge_token","challenge_id"],"properties":{"challenge_token":{"type":"string","minLength":16},"challenge_id":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"Still pending, or the full session once approved.","content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["status"],"properties":{"status":{"type":"string","enum":["pending"]}}},{"$ref":"#/components/schemas/SessionBundle"}]}}}},"401":{"description":"Denied on the device (`{status:\"denied\"}`), or invalid challenge.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"error":{"type":"string"}}}}}},"404":{"description":"Request not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Challenge already consumed.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["consumed"]}}}}}},"410":{"description":"Challenge expired.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["expired"]}}}}}},"429":{"description":"Rate limited (Retry-After header).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/me":{"post":{"tags":["Account"],"summary":"The signed-in user and their factor inventory","description":"Resolves the user from the token's own realm — the one route that needs no X-TillAuth-App. `mfa_enrolled` means \"holds any live second factor\"; `factors` breaks it down. This is the read model an embedded settings screen renders.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Me"}}}},"401":{"description":"Invalid or expired token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/refresh":{"post":{"tags":["Sessions"],"summary":"Rotate the token pair","description":"Refresh tokens are single-use; presenting a rotated one revokes the whole family (theft detection). Each refresh is also scored against the session’s history under the app’s session-risk policy.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refresh_token"],"properties":{"refresh_token":{"type":"string","minLength":1,"maxLength":4096}}}}}},"responses":{"200":{"description":"Fresh pair.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenBundle"}}}},"400":{"description":"refresh_token is required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Session over; sign in again. `code` is `invalid`, `expired`, `theft_detected`, or from the session-risk policy `reauth_required` (sign in again, with a second factor if enrolled) or `risk_revoked` (every session of the user ended).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/signout":{"post":{"tags":["Sessions"],"summary":"Sign out","description":"Revokes the refresh-token family. Idempotent — unknown tokens still answer ok.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refresh_token"],"properties":{"refresh_token":{"type":"string","minLength":1,"maxLength":4096}}}}}},"responses":{"200":{"description":"Signed out.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"refresh_token is required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/sso/session":{"post":{"tags":["SSO session"],"summary":"Start an SSO session from a sign-in","description":"Proves the sign-in with its current refresh token and returns the SSO secret to keep in an HttpOnly cookie. The absolute limit counts from the sign-in, so starting again never extends it. `replaces` ends the browser's previous SSO session.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refresh_token"],"properties":{"refresh_token":{"type":"string","minLength":1,"maxLength":4096},"replaces":{"type":"string","minLength":16,"maxLength":256}}}}}},"responses":{"201":{"description":"Started.","content":{"application/json":{"schema":{"type":"object","required":["secret","expires_at","idle_expires_at"],"properties":{"secret":{"type":"string"},"expires_at":{"type":"string","format":"date-time"},"idle_expires_at":{"type":"string","format":"date-time"}}}}}},"400":{"description":"refresh_token is required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"The refresh token is not live, or the sign-in is past the absolute limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"SSO sessions are off for this app (`code: sso_disabled`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/sso/grant":{"post":{"tags":["SSO session"],"summary":"Trade the SSO secret for a one-time code","description":"Called by the server that holds the cookie. The code lasts 60 seconds, works once, and replaces any earlier code.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["secret"],"properties":{"secret":{"type":"string","minLength":16,"maxLength":256}}}}}},"responses":{"200":{"description":"Code.","content":{"application/json":{"schema":{"type":"object","required":["code","expires_in"],"properties":{"code":{"type":"string"},"expires_in":{"type":"integer"}}}}}},"400":{"description":"secret is required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No live SSO session (`state`: unknown, revoked, idle, expired, family_ended or disabled).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/sso/token":{"post":{"tags":["SSO session"],"summary":"Redeem the code for a session","description":"Called from the browser, so the new session is scored against the network it is used from. In enforce mode a risky sign-in is refused with `reason: risk` and the user signs in normally; at the block threshold the answer is 403 `signin_blocked`.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","minLength":16,"maxLength":256}}}}}},"responses":{"200":{"description":"Session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionBundle"}}}},"400":{"description":"code is required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"`login_required` with `reason`: invalid (wrong, spent or expired code, or the SSO session ended) or risk.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Email no longer permitted, account deactivated, or the sign-in was blocked (`code: signin_blocked`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/sso/end":{"post":{"tags":["SSO session"],"summary":"End the SSO session","description":"Ends the SSO session, the sign-in it came from and every session started from it. With `expect_sub`, nothing ends unless the session is that user's (`mismatch: true`). Idempotent.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["secret"],"properties":{"secret":{"type":"string","minLength":16,"maxLength":256},"expect_sub":{"type":"string","format":"uuid"},"reason":{"type":"string","enum":["signout","oidc_logout"]}}}}}},"responses":{"200":{"description":"Done.","content":{"application/json":{"schema":{"type":"object","required":["ok","ended"],"properties":{"ok":{"type":"boolean"},"ended":{"type":"boolean"},"mismatch":{"type":"boolean"}}}}}},"400":{"description":"secret is required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown app — the target realm could not be resolved (see app resolution in the description).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/sessions":{"get":{"tags":["Sessions"],"summary":"List the user's sessions","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Sessions, current first.","content":{"application/json":{"schema":{"type":"object","required":["sessions"],"properties":{"sessions":{"type":"array","items":{"$ref":"#/components/schemas/SessionSummary"}}}}}}},"401":{"description":"Invalid or expired token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/sessions/revoke":{"post":{"tags":["Sessions"],"summary":"Revoke one session","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sid"],"properties":{"sid":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"Revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"revoked":{"type":"integer"}}}}}},"401":{"description":"Invalid or expired token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"You can only revoke your own sessions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Session not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/sessions/revoke-others":{"post":{"tags":["Sessions"],"summary":"Revoke every session but this one","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"revoked":{"type":"integer"}}}}}},"400":{"description":"Session predates session ids — sign in again first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Invalid or expired token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/verify-email":{"post":{"tags":["Email verification"],"summary":"Consume a verification token (hosted pages call this)","description":"Deliberately uniform: every failure is a 200 `{ ok: false }` — no oracle for token probing.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string","minLength":16,"maxLength":256},"app_id":{"type":"string","maxLength":128,"description":"Fallback app resolution for headless clients."}}}}}},"responses":{"200":{"description":"`ok: true` verified; `ok: false` for any failure.","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/verify-email/resend":{"post":{"tags":["Email verification"],"summary":"Resend the verification email","description":"Success-shaped regardless of account state — non-enumerating.","parameters":[{"name":"X-TillAuth-App","in":"header","required":false,"schema":{"type":"string"},"description":"Target app (realm) public id. One of three resolution mechanisms — see \"App resolution\" in the API description. On routes that also take a Bearer access token this header (or a hosted host) is effectively required, because the Authorization header is occupied by the access token."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","minLength":3,"maxLength":254}}}}}},"responses":{"200":{"description":"Accepted.","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}}}},"429":{"description":"Rate limited. Carries Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until the window resets."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}