API

Every route the openGym backend serves — passkey auth, state sync, push, pairing, admin — as one hand-written OpenAPI spec. The same file lives in the repository at api/openapi.yaml, next to the one server file it documents.

Download openapi.yaml

OpenAPI 3.1.0 openGym v1.3.9 69 endpoints AGPL-3.0-or-later

Overview

Passkey (WebAuthn) auth + per-user state storage for openGym. One Node file, no framework, JSON-file storage, HMAC-signed session cookies.

/Same origin as the openGym web app (the normal deployment)
https://opengym.example.comYour self-hosted instance

Authentication

Sign-in is by passkey (WebAuthn, via @simplewebauthn/server). A successful register/verify or login/verify sets the session cookie; every authenticated route then works with that cookie. The paired mobile app has no shared cookie jar, so it redeems a short pairing code (/api/pair/redeem) for the same signed token and sends it as Authorization: Bearer <token> instead.

An instance started with PASSWORD_LOGIN=1 also offers name-and-password sign-in (tag password): opt-in per profile, scrypt-hashed, throttled, with an admin-issued one-time reset code instead of e-mail. A profile may also add an e-mail address to sign in with instead of its name (/api/account/email); no mail is ever sent to it. Its routes set exactly the same cookie. With the flag off (the default) every one of them answers 404.

Throttling

The password routes are counted per client address: at most 60 requests a minute across them, and wrong answers (a password, a reset code, an invite code on password signup, an e-mail address already in use) pause the address after 20 for 30 s, doubling up to 15 min. Wrong passwords also pause the account they were tried against after 5, for 1 min doubling up to 1 h — the same pause whether the account was named by its name or its e-mail — and an identifier that names no account is paused the same way, as typed. A password check counts from the moment it starts, so checks sent at once get no more tries than checks sent one by one. A paused caller gets 429 {"error": …, "code": "locked", "retryAfter": <s>} with a Retry-After header. Passkey sign-up and sign-in and the pairing routes are not throttled. The two routes that redeem a one-time device link (tag passkeys) share the per-address budget, and wrong codes pause the address for link redemption only, after 20, the way wrong reset codes do. Adding or removing a passkey in Settings (POST /api/account/passkeys/options, DELETE /api/account/passkeys) and making a device code (POST /api/account/device-link) spend the per-address budget too, and a current password given there as proof counts like one given at sign-in. The address is the socket's, or with TRUST_PROXY=1 the one a trusted proxy put in CF-Connecting-IP / the last X-Forwarded-For entry / X-Real-IP; IPv6 clients are counted by /64. Counters live in memory and reset on restart.

Sessions

The cookie/bearer value is <payload>.<hmac> where the payload is <uid>:<expiry-ms>:<session-version>, signed with a per-instance secret (HMAC-SHA256). Sessions last SESSION_DAYS (default 90). POST /api/logout/all bumps the user's session version, which invalidates every token ever issued for the account.

CSRF

State-changing browser requests must come from the app's own origin. The server checks Sec-Fetch-Site (falling back to Origin vs the configured ORIGIN); a mismatch is refused with 403 {"error":"cross-origin request refused"} on any non-GET route. Exempt: passkey sign-up and sign-in (register/options, register/verify, login/options, login/verify) and pair/redeem, each of which carries its own one-shot credential in the body, and any request authenticated with a Bearer token. The password routes are not exempt: a hostile page knows a valid name and password of its own, and could otherwise sign a visitor into it (login CSRF). Neither are the device-link routes: a link is redeemed on the app's own origin, the only one a passkey can be created for.

Environment-dependent behavior

  • INVITE_ONLY=1 — registration requires a valid invite code (minted by an admin).
  • ALLOW_GUEST=0 — hides the client-side "continue without account" mode; the server merely reports the flag via GET /api/config (guest mode never talks to this API at all).
  • ADMIN_UIDS=<uid>,<uid> — user ids treated as admins (a "admin": true flag on the user record in db.json works too). Admins get the /api/admin/* routes.
  • AUDIT_LOG (default on), AUDIT_MAX (default 5000 events), AUDIT_DAYS (default 90), AUDIT_IP (off | net | full, default off) — shape the audit log served by GET /api/admin/audit.
  • PASSWORD_LOGIN=1 — adds the password routes and password_login: true in GET /api/config. Off by default.
  • DEFAULT_LANG (e.g. pt-BR; unset by default) — default_lang in GET /api/config, the language of the sign-in screen and of every profile that never picked one.
  • TRUST_PROXY=1 — the throttle reads the client address from proxy headers (set by the bundled docker-compose.yml, where only the web container can reach the API).
  • MEDIA_UPLOADS (default on; 0 removes the media routes and the media block of GET /api/config), MEDIA_QUOTA_MB (200 per profile, 0 = no cap), MEDIA_IMAGE_MAX_MB (2), MEDIA_GIF_MAX_MB (8), MEDIA_VIDEO_MAX_MB (40), MEDIA_VIDEO_MAX_SEC (60), MEDIA_GC_GRACE_DAYS (14), MEDIA_UPLOADS_PER_HOUR (600 per profile), MEDIA_MIN_FREE_MB (512, 0 = no floor) — photos and videos of custom exercises (tag media). Every MB here is 2^20 bytes.

Conventions

  • Every response body is JSON with Cache-Control: no-store. The one exception is GET /api/media/{hash}, which answers with the stored file itself (Cache-Control: private, no-store).
  • Errors are always {"error": "<human-readable message>"}. The password, throttle and media routes add a stable code for the client to word in its own language.
  • Unknown method+path pairs return 404 {"error":"not found"}; an unhandled exception returns 500 {"error":"server error"}.
  • Request bodies are parsed as JSON regardless of Content-Type. A body that does not parse, or parses to anything but an object (null, a string, an array), is 400 {"error":"invalid json"} on every route that reads one; an empty body counts as {}. A body over 5 MiB is 413 {"error":"body too large"}; the rest of the upload is discarded so the answer reaches the client.
  • CORS: the request's Origin is reflected in Access-Control-Allow-Origin without Allow-Credentials — cross-origin callers can only ever authenticate with a Bearer token, never with the cookie.

Meta

Health and public configuration

GET /api/healthpublic Health check

Always public. Also reports how many user accounts exist.

Public — no authentication required.

Responses

200 The server is up.{ ok: always true, users: integer }
GET /api/configpublic Instance configuration

The flags the login screen needs before anyone is signed in. Both reflect environment variables on the server (INVITE_ONLY, ALLOW_GUEST), and so does password_login, which is there only when PASSWORD_LOGIN=1, and default_lang, which is there only when DEFAULT_LANG is set.

media is there unless the instance runs with MEDIA_UPLOADS=0, for everybody: the caps are not a secret, and its absence is how the app knows this server does not store photos and videos at all.

A caller with a valid session also gets a coach key: the block when the instance has the AI Coach switched on and a provider connected, and null when it has not. A caller with no session gets no key at all — the block names the provider this instance talks to, which is the same fact GET /api/coach/disclosure will not hand out unauthenticated.

The key is always present for a session, null included, so a client that caches this answer can tell "no Coach on this instance" from "you were not signed in when you asked" and knows whether asking again would change it.

Public — no authentication required.

Responses

200 Instance flags, plus the Coach block for a signed-in caller.{ invite_only: boolean, allow_guest: boolean, coach: object | null, password_login: always true, default_lang: string, media: MediaConfig }

Auth

Passkey (WebAuthn) registration and login, sessions

GET /api/me Who am I?

Resolves the current session to a user. A paired phone (bearer token) whose token is past half of SESSION_DAYS also receives a fresh token to use from now on; it carries the account's current session version, so "sign out everywhere" revokes it like the old one. Cookie sessions never get one.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 A valid session.{ user: SessionUser, token: string }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/register/optionspublic Start passkey registration (WebAuthn ceremony, step 1)

Returns PublicKeyCredentialCreationOptions (from @simplewebauthn/server's generateRegistrationOptions) plus a challenge id cid. The client passes options to navigator.credentials.create() (or @simplewebauthn/browser's startRegistration) and sends the result to /api/register/verify together with the cid. Challenges are one-shot and expire after 5 minutes.

CSRF-exempt (the challenge is the credential). No session required.

Public — no authentication required.

Request body (JSON)

namerequired string (≤ 40 chars) Display name for the new profile (trimmed, max 40 chars).
code string Invite code — required (and validated) only when the instance runs with INVITE_ONLY. Compared case-insensitively.
{
  "name": "Ada",
  "code": "3F9C21A07B54D688"
}

Responses

200 Ceremony options.{ cid: string, options: WebAuthnRegistrationOptions }
400 name missing/empty.{"error":"name required"}
403 Instance is invite-only and the code is missing, used, or revoked.{"error":"a valid invite code is required"}
POST /api/register/verifypublic Finish passkey registration (WebAuthn ceremony, step 2)

Verifies the authenticator's attestation response against the stored challenge (verifyRegistrationResponse), creates the user + credential, and signs the caller in (sets the session cookie). On an invite-only instance the invite is re-checked and burned here. CSRF-exempt.

Public — no authentication required.

Request body (JSON)

cidrequired string The challenge id from /api/register/options.
credentialrequired WebAuthnRegistrationCredential

Responses

200 Registered and signed in. The session cookie is set.{ user: SessionUser } · sets Set-Cookie
400 Challenge expired/replayed, or the attestation did not verify (wrong origin/RP ID, malformed response…). The message is human-readable.{"error":"challenge expired — try again"}
403 Invite-only and the code became invalid since step 1.{"error":"invite code is no longer valid — ask for a new one"}
409 This credential id is already registered.{"error":"credential already registered"}
POST /api/login/optionspublic Start passkey login (WebAuthn ceremony, step 1)

Returns PublicKeyCredentialRequestOptions (generateAuthenticationOptions with an empty allowCredentials — discoverable credentials / resident keys are required at registration, so the browser offers the user their passkeys itself) plus a one-shot challenge id cid. CSRF-exempt. Takes no request body.

Public — no authentication required.

Responses

200 Ceremony options.{ cid: string, options: WebAuthnAuthenticationOptions }
POST /api/login/verifypublic Finish passkey login (WebAuthn ceremony, step 2)

Verifies the assertion (verifyAuthenticationResponse), updates the signature counter, and signs the caller in (sets the session cookie). CSRF-exempt.

Public — no authentication required.

Request body (JSON)

cidrequired string The challenge id from /api/login/options.

Responses

200 Signed in. The session cookie is set.{ user: SessionUser } · sets Set-Cookie
400 Challenge expired/replayed, or the assertion did not verify.{"error":"not verified"}
403 The account has been disabled by an admin.{"error":"this account has been disabled"}
404 The passkey's credential id is unknown to this instance.{"error":"unknown passkey — create a profile first"}
500 Credential exists but its user record is missing (corrupt db).{"error":"user missing"}
POST /api/logoutpublic Sign out (this device)

Clears the session cookie. Always succeeds — an invalid or missing session is a no-op. The token itself stays cryptographically valid until it expires; use /api/logout/all to revoke tokens.

Public — no authentication required.

Responses

200 Cookie cleared.Ok · sets Set-Cookie
POST /api/logout/all Sign out everywhere

Bumps the account's session version, which invalidates every cookie and Bearer token ever issued for it — on every device, including a copy someone walked off with. Passkeys are untouched; signing back in works immediately. Also clears the caller's own cookie. Unredeemed pairing codes for the account are voided too.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 All sessions revoked.Ok · sets Set-Cookie
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}

Pairing

Mobile-app pairing (code from a signed-in browser tab)

POST /api/pair/create Mint a pairing code for the mobile app

Called from an already signed-in browser tab (Settings → "Pair the mobile app"). Returns an 8-character code (alphabet without 0/O/1/I) the phone redeems within 5 minutes via /api/pair/redeem. One-shot.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 A fresh pairing code.{ code: string }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/pair/redeempublic Redeem a pairing code for a Bearer token

Called from the mobile app with the code shown in the browser. No session required — the code is the credential (one-shot, 5-minute TTL). Returns the same HMAC-signed session token the cookie would carry; the app sends it as Authorization: Bearer <token> from then on. CSRF-exempt.

Public — no authentication required.

Request body (JSON)

coderequired string The pairing code (case-insensitive).
{
  "code": "K7WQ2MZP"
}

Responses

200 Paired.{ token: string, user: SessionUser }
400 Code unknown, already used, expired — or the account behind it is gone or disabled (deliberately the same message for all of these).{"error":"invalid or expired code"}

Password

Optional name-and-password sign-in (PASSWORD_LOGIN=1). Every route here is a 404 while the flag is off.

POST /api/login/passwordpublic Sign in with a profile name or e-mail and a password

Only a profile that has set a password can sign in this way. The identifier is the profile's display name or the e-mail address it added (POST /api/account/email), compared trimmed, NFKC-normalised and case-insensitively. Send it as identifier (what the app sends), name (the original field, still accepted) or email. In identifier and name, a value with an @ is looked up as an e-mail first and then as a name; email is looked up only as an e-mail. A wrong password, an unknown name and a name whose profile has no password all get the same 401 after the same scrypt work, so neither the answer nor its timing says which names exist. So does a password that was right when its check started but was changed, reset or removed before the check finished. Five wrong passwords for an account pause it, whichever identifier they named it by (see Throttling); passkey sign-in is never paused by this. Sets the same session cookie as /api/login/verify. Not CSRF-exempt.

Public — no authentication required.

Request body (JSON)

identifier string A profile name or an e-mail address.
name string The original field: a profile name, or an e-mail address.
email string An e-mail address only.
passwordrequired string (≤ 256 chars)

Responses

200 Signed in. The session cookie is set.{ user: SessionUser } · sets Set-Cookie
400 Name or password missing.{"error":"name and password required"}
401 Wrong name or password — deliberately one answer for every cause.{"error":"wrong name or password"}
403 The right password for a disabled account.{"error":"this account has been disabled"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}
503 Every password check slot and the short queue behind them are taken.{"error":"the server is busy — try again in a moment"}
POST /api/register/passwordpublic Create a profile with a name and password

For browsers that cannot make a passkey (plain http on a LAN address, some Firefox setups). Creates the profile and signs it in. The same invite rules as passkey registration apply: on an invite-only instance the code is checked first, checked again after hashing, and burned. The password must be 10–256 characters and not one of the passwords guessing scripts try first; no two profiles with a password may share a name. An optional email is stored as the profile's sign-in e-mail (see POST /api/account/email); one that is not an address is refused with 400 email-invalid, one that is in use with 409 email-taken — asked only after the hash and the second invite check, so without a valid code it is never answered — which counts against the caller's address like a wrong invite code. Not CSRF-exempt.

Public — no authentication required.

Request body (JSON)

namerequired string (≤ 40 chars)
passwordrequired string (≤ 256 chars)
code string Invite code (INVITE_ONLY only).
email string (≤ 254 chars) Optional sign-in e-mail.
{
  "name": "Ada",
  "password": "correct horse battery staple",
  "code": "3F9C21A07B54D688"
}

Responses

200 Registered and signed in. The session cookie is set.{ user: SessionUser } · sets Set-Cookie
400 The password breaks the policy — too-short (under 10 characters), too-long (over 256) or too-common — a field is missing, or email-invalid: the email given is not an e-mail address.{"error":"this password is too easy to guess"} · {"error":"that is not an e-mail address"}
403 Invite-only and the code is missing, used or revoked.{"error":"a valid invite code is required"}
409 name-taken (see NameTaken) or email-taken — the e-mail is in use by another profile.{"error":"another profile already uses this e-mail address"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}
503 Every password check slot and the short queue behind them are taken.{"error":"the server is busy — try again in a moment"}
POST /api/login/password-resetpublic Set a new password with an admin's one-time reset code

Redeems the code from POST /api/admin/user/password-reset (valid 24 h, single use, stored only as a hash; dashes, spaces and case do not matter) and signs in. A new password that breaks the policy is refused without using up the code. Every other session of the account ends. Wrong codes count against the caller's address only (see Throttling), never against the name, so they cannot keep a real code from working. The profile is named by its name or its sign-in e-mail, in identifier, name or email as at sign-in. Not CSRF-exempt.

Public — no authentication required.

Request body (JSON)

identifier string The profile name or its sign-in e-mail.
name string The original field; an e-mail works here too.
email string
coderequired string
nextrequired string (≤ 256 chars)

Responses

200 Password set and signed in. The session cookie is set.{ user: SessionUser } · sets Set-Cookie
400 reset-invalid (wrong, used or expired code — one answer for all), missing, or a password-policy code.{"error":"that reset code is wrong or has expired"}
403 The account has been disabled.{"error":"this account has been disabled"}
409 Another profile already signs in with this name, or holds it with a reset code that has not been used or expired yet.{"error":"another profile already signs in with this name"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}
503 Every password check slot and the short queue behind them are taken.{"error":"the server is busy — try again in a moment"}
GET /api/account/password Whether this profile has a password

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 The profile's password state.{ set: boolean, setAt: string | null, passkeys: integer, name: string, nameTaken: boolean, email: string | null }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/account/password Set or change this profile's password

Proof first: current when the profile has a password, or a passkey assertion made for this request (cid from POST /api/login/options, credential signed by one of this profile's passkeys) — the only way to set a first password, and the way to replace a forgotten one. A session alone is never enough. Wrong current values count toward the same pause as wrong sign-ins. On success the session version is bumped, which ends every other session and pending pairing code of the account; the caller's own continues on a fresh cookie, or a fresh token for a Bearer caller.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

nextrequired string (≤ 256 chars)
current string
cid string

Responses

200 Saved. Other sessions have ended.{ ok: always true, token: string } · sets Set-Cookie
400 The new password breaks the policy — too-short (under 10 characters), too-long (over 256) or too-common — or a field is missing.{"error":"this password is too easy to guess"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 No acceptable proof: current-required, current-wrong, passkey-required (no password yet, so a passkey has to confirm), or passkey (the assertion did not verify or belongs to another profile).{"error":"your current password is not right"}
409 Another profile already signs in with this name, or holds it with a reset code that has not been used or expired yet.{"error":"another profile already signs in with this name"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}
503 Every password check slot and the short queue behind them are taken.{"error":"the server is busy — try again in a moment"}
DELETE /api/account/password Remove this profile's password

Refused while the profile has no passkey: the password would be its last way in. Otherwise the body carries the same proof setting one takes — current, the password itself, or a passkey assertion made for this request (cid from POST /api/login/options, credential signed by one of this profile's passkeys). A session alone is never enough: a stolen cookie must not take away the owner's way in where passkeys do not work. The last-way-in refusal comes before the proof is checked, and is checked again after it. Wrong current values count toward the same pause as wrong sign-ins. Existing sessions are left alone (POST /api/logout/all ends them). A profile without a password gets 200 as well, with no proof asked. Audited as auth.password.remove.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body

OwnerProof

Responses

200 Removed (or there was none).Ok
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 No acceptable proof that the owner is here: passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
409 The password is the profile's only way in.{"error":"the password is the only way into this profile"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}
503 Every password check slot and the short queue behind them are taken.{"error":"the server is busy — try again in a moment"}
POST /api/account/email Set, change or remove the e-mail this profile may sign in with

An e-mail address to type at POST /api/login/password instead of the profile name. No mail is ever sent to it — there is no verification and no reset mail; resets stay the admin's one-time code — so it is only an identifier. Stored trimmed, NFKC-normalised and lower-cased; at most 254 characters; unique across every profile, and never another password-holding profile's name. An empty or null email removes it (as DELETE does).

The body carries the same proof as setting a password (see OwnerProof): a session alone is never enough. Saving the address the profile already has answers 200 with no proof asked. An address in use by another profile is refused only after the proof, with 409 email-taken, and counts against the caller's address and account (20 free, then 30 s doubling to 15 min) — so the answer never costs less than a passkey prompt or a password check, and runs out after a few tries. It says an address is in use somewhere on this instance, never by whom. Audited as auth.email.set / auth.email.change with the address masked (a…@e…).

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

{
  "email": "ada@example.com",
  "current": "correct horse battery staple"
}

Responses

200 Saved (or removed).{ ok: always true, email: string | null }
400 Not an e-mail address.{"error":"that is not an e-mail address"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 No acceptable proof that the owner is here: passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
409 Another profile already uses this address.{"error":"another profile already uses this e-mail address"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}
503 Every password check slot and the short queue behind them are taken.{"error":"the server is busy — try again in a moment"}
DELETE /api/account/email Remove the e-mail this profile may sign in with

The profile then signs in by its name only. Takes the same proof as setting one (OwnerProof); a profile without an e-mail gets 200 with none asked. Audited as auth.email.remove.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body

OwnerProof

Responses

200 Removed (or there was none).{ ok: always true, email: null }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 No acceptable proof that the owner is here: passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}
503 Every password check slot and the short queue behind them are taken.{"error":"the server is busy — try again in a moment"}

Passkeys

More than one passkey on a profile: list, add, rename, remove — and one-time device links, which let another device of the same person add a passkey of its own.

GET /api/account/passkeys This profile's passkeys

Every passkey that signs this profile in, without key material. created and lastUsed are null for a passkey made before they were recorded.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 The list, and what it allows.PasskeyList
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
DELETE /api/account/passkeys Remove one passkey

Refused while it is the profile's last way in: its only passkey, and no password that can sign in (a password counts only while PASSWORD_LOGIN is on). Otherwise the body carries the same proof adding one takes (OwnerProof): a session alone is never enough, since a stolen cookie could otherwise choose which of the owner's passkeys is left. Any of the profile's passkeys may confirm, the one being removed included. The 404 and 409 refusals come before the proof is checked, and the last-way-in rule is checked again after it. Audited as auth.passkey.remove. An unused device code of the profile is dropped too, so one made with the removed passkey cannot add another afterwards.

Sessions are not tied to the passkey that opened them, so a session it already opened carries on; POST /api/logout/all ends those.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Query parameters

idrequired string The passkey's id from the list (its WebAuthn credential id).

Request body

OwnerProof

Responses

200 Removed. The list as it is now.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 No acceptable proof that the owner is here: passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
404 No passkey of this profile has that id.{"error":"passkey not found"}
409 It is the profile's last way in.{"error":"this passkey is the only way into this profile"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}
503 Every password check slot and the short queue behind them are taken.{"error":"the server is busy — try again in a moment"}
POST /api/account/passkeys/options Add a passkey (step 1): prove it is you, get creation options

A session alone is never enough to add a way in that outlives "sign out everywhere". The body carries the same proof POST /api/account/password takes: a passkey assertion made for this request (cid from POST /api/login/options, credential signed by one of this profile's passkeys), or current, the profile's password — only while PASSWORD_LOGIN is on. With it off, a password kept from before is not checked at all and the answer is passkey-required, so a password-only profile has to move onto a passkey while the flag is still on. Wrong passwords count toward the sign-in pause.

The options use the profile's own user handle and list its passkeys in excludeCredentials, so an authenticator that already holds one refuses. The challenge is good for 5 minutes, for this profile, and only while the session version is unchanged.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body

OwnerProof

Responses

200 Ceremony options.{ cid: string, options: WebAuthnRegistrationOptions }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 No acceptable proof that the owner is here: passkey-required (no password that counts — none is set, or PASSWORD_LOGIN is off — so a passkey has to confirm), passkey (the assertion did not verify, belongs to another profile, or was made for another challenge, one handed out for another ceremony or one already used), current-required or current-wrong.{"error":"confirm with your passkey first"}
409 The profile already has the most passkeys it can have (20).{"error":"a profile can have at most 20 passkeys"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}
503 Every password check slot and the short queue behind them are taken.{"error":"the server is busy — try again in a moment"}
POST /api/account/passkeys/verify Add a passkey (step 2): store the new one

Verifies the attestation against the challenge from step 1 and stores the passkey on this profile. Audited as auth.passkey.add, with the proof that allowed it.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

cidrequired string
credentialrequired WebAuthnRegistrationCredential
name string (≤ 40 chars) A label of the owner's choosing ("Work laptop").

Responses

200 Added. The list as it is now.object
400 Challenge expired, replayed or made for another profile, or the attestation did not verify.{"error":"challenge expired — try again"}
401 Not signed in — including a session that was ended ("sign out everywhere", a new password) after step 1.{"error":"not signed in"}
409 credential-exists (that passkey is already registered) or passkey-limit.{"error":"credential already registered"}
POST /api/account/passkeys/rename Name one passkey

An empty name clears it; the app then shows a numbered "Passkey n".

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

idrequired string
name string (≤ 40 chars)

Responses

200 Renamed. The list as it is now.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 No passkey of this profile has that id.{"error":"passkey not found"}
POST /api/device-link/optionspublic Redeem a device link (step 1): check the code, get creation options

Called on the other device, which has no session: the code is the credential. Returns creation options for the profile the code belongs to, and that profile's id and name. Does not use the code up, so a dismissed passkey prompt can be tried again. Wrong codes count toward a per-address pause of link redemption (see Throttling). Not CSRF-exempt.

Public — no authentication required.

Request body (JSON)

coderequired string Case, spaces and dashes are ignored.
{
  "code": "K7WQ-2MZP-4HXA"
}

Responses

200 Ceremony options.{ cid: string, options: WebAuthnRegistrationOptions, id: string, name: string }
400 The code is wrong, used, replaced or expired, or its profile is gone or disabled — one answer for all of them.{"error":"that code is wrong, used or expired"}
409 The profile already has the most passkeys it can have (20).{"error":"a profile can have at most 20 passkeys"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}
POST /api/device-link/verifypublic Redeem a device link (step 2): store the passkey and sign this device in

Verifies the attestation against the challenge from step 1 (which must have been made for this very code), stores the passkey on the code's profile, burns the code and sets the session cookie — the passkey just made is what signs the device in. Of two requests racing with one code, one wins. Audited as auth.link.ok, and every refusal as auth.link.fail. Not CSRF-exempt.

Public — no authentication required.

Request body (JSON)

coderequired string
cidrequired string
credentialrequired WebAuthnRegistrationCredential
name string (≤ 40 chars)

Responses

200 Added and signed in.{ user: SessionUser } · sets Set-Cookie
400 link-invalid (see below), a challenge that expired or was made for another code, or an attestation that did not verify.{"error":"that code is wrong, used or expired"}
409 credential-exists or passkey-limit.{"error":"credential already registered"}
429 Paused by the sign-in throttle (see Throttling in the description). The request was not looked at; try again after Retry-After seconds.{"error":"too many attempts — try again later"}

Data

Per-user state sync (the whole app state as one JSON blob)

GET /api/data Pull the account's app state

Returns the caller's entire app state as one JSON blob, or {"state": null} if nothing has been synced yet, together with rev, the server's revision of that document (0 when there is none). A client keeps the rev it last read or wrote and sends it back as baseRev on the next push, which lets the server refuse a write over a document the client never saw (see the 409 on PUT).

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 The stored state, or null, and its revision.{ state: State | null, rev: integer }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
PUT /api/data Push the account's app state

Replaces the stored state wholesale (atomic write; there is no merge on the server). The active field — an in-progress workout — is stripped before saving: a running session belongs to the device running it. Body limit 5 MiB.

With baseRev the write is conditional: it goes through only when baseRev equals the document's current revision, and answers 409 with the current document otherwise — the client merges the two and pushes again with the revision it was given. Without baseRev the write is unconditional (clients from before revisions, and deliberate replaces such as a backup import). The server stamps the new revision into the document as _rev; a _rev sent by the client is ignored.

Every accepted write also starts the grace period of each stored file (tag media) the new state no longer references, and stops it for each one it references again. That bookkeeping never changes the answer.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

staterequired State
baseRev integer | null The revision this client last read or wrote. Omit or null for an unconditional overwrite.

Responses

200 Saved.{ ok: always true, ts: number | null, rev: integer }
400 state missing or not an object, state an array or an object with nothing of the profile in it (_rev/_ts do not count — both would empty the profile), or workouts/routines set to anything other than an array or null. Entries inside those two arrays that are not objects (null, numbers, nested arrays) are dropped before the write rather than refused, so a client with a damaged copy still syncs.{"error":"state required"} · {"error":"invalid state"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
409 baseRev is not the current revision — another client wrote since this one last read. Carries the current document so the client can merge and retry.
GET /api/data/rev Current revision of the caller's profile state

The rev that GET /api/data would return, without the document. Signed-in clients poll this while open and on every return to the foreground and fetch the document only when the number changed.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 The current revision (0 when the profile has no state yet).{ rev: integer }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}

Push

Web Push (VAPID) subscriptions, rest-timer and test pushes

GET /api/push/public-keypublic VAPID public key

The instance's Web-Push application server key (generated once at first boot), for PushManager.subscribe({applicationServerKey}). Public.

Public — no authentication required.

Responses

200 The key.{ key: string }
POST /api/push/subscribe Register a Web-Push subscription

Stores the browser's PushSubscription for this account. Only endpoint and the two protocol keys are kept. The endpoint must be a public https:// URL — private/loopback/link-local addresses are refused (here and again at send time, against DNS rebinding). At most 20 subscriptions per user; the oldest are dropped first. Re-subscribing an existing endpoint replaces it and keeps its original created — the client re-sends its subscription on every signed-in boot, so a row the instance lost comes back on its own.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

subscriptionrequired PushSubscription
deviceId string A token the browser made up for itself (stored in its localStorage), so rest-timer pushes can be aimed at the device that started the rest. Optional; anything that is not a short token is ignored.

Responses

200 Stored.Ok
400 Malformed subscription, or an endpoint that isn't acceptable.{"error":"invalid subscription"} · {"error":"endpoint must not point at a private address"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
GET /api/push/status Does the server hold this subscription?

Whether the caller's account has a stored subscription with exactly this endpoint. The browser's PushManager.getSubscription() says nothing about the server's side — a row pruned after a dead send (404/410/403) leaves the browser subscribed to nowhere — so the client asks here before showing the notifications switch as on, and re-registers when the answer is no.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Query parameters

endpointrequired string (uri)

Responses

200 The answer.{ subscribed: boolean }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/push/unsubscribe Remove a Web-Push subscription

Removes the caller's subscription with the given endpoint. Idempotent.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

endpointrequired string (uri)

Responses

200 Removed (or was never there).Ok
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/push/test Send a test notification

Sends a test push to all of the caller's subscriptions (localized with the lang in their synced state). Returns 200 even if delivery to individual endpoints fails — dead endpoints (404/410, and 403 for a subscription made against a VAPID key the instance no longer has) are pruned as a side effect.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 Sends attempted.Ok
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/push/rest-timer Schedule a server-side rest-timer push

Schedules one push after seconds (a number or numeric string ≥ 1, clamped to 3600). One pending timer per device — deviceId, the same token the subscription was registered with; a new call from the same device replaces it, and the push goes only to that device's subscriptions. Without a deviceId there is one timer per account and the push goes to every subscription, as before. The client schedules this on rest start/extend and cancels on skip or on-screen completion — the push only actually fires when the tab was backgrounded/suspended and never got to cancel it. Timers live in memory: an API restart drops them.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

secondsrequired number (1–3600)
deviceId string The browser's own token, see POST /api/push/subscribe. Optional.
{
  "seconds": 90,
  "deviceId": "3f9c1d2e7b0a4c6d9e8f1a2b3c4d5e6f"
}

Responses

200 Timer scheduled.Ok
400 seconds missing, not numeric, or below 1. Nothing is scheduled.{"error":"seconds required"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/push/rest-timer/cancel Cancel the pending rest-timer push

Cancels the caller's scheduled rest-timer push, if any. With a deviceId only that device's timer; without one, every pending timer of the account. Idempotent.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON) (optional)

deviceId string

Responses

200 Cancelled (or nothing was pending).Ok
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}

Activity

Live-workout presence heartbeat

POST /api/activity Live-workout presence heartbeat

The client pings this every ~20 s while a workout is on screen; the admin dashboard reads who is training right now. Purely ephemeral, in-memory, never persisted; entries expire ~70 s after the last ping. {"active": false} drops the caller's entry immediately.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

active boolean false ends the presence; true (with the fields below) updates it.
name string (≤ 60 chars) Routine/workout name being trained.
exIdx integer Current exercise index (0-based).
exTotal integer Exercises in the workout.
setsDone integer
setsTotal integer
startedAt number Workout start, ms since epoch (defaults to now).
{
  "active": true,
  "name": "Push Day",
  "exIdx": 2,
  "exTotal": 5,
  "setsDone": 7,
  "setsTotal": 16,
  "startedAt": 1756500000000
}

Responses

200 Presence updated.Ok
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}

Media

The photo, GIF or video of a user's own custom exercise, and the photos and videos attached to a logged workout. Stored per profile (one quota for both) and named by the SHA-256 of its bytes; the state only carries a MediaRef. Every route here is a 404 when the instance runs with MEDIA_UPLOADS=0.

GET /api/media/{hash} Download one of your photos or videos

The stored file, whole, from the caller's own folder only: another profile's file answers exactly like one that was never uploaded (404). The body is the file, not JSON, with headers that keep a browser from ever treating it as a page: Content-Type from the sniffed type (never text/* or SVG), X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none'; sandbox, Cross-Origin-Resource-Policy: same-origin, Content-Disposition: inline, X-Robots-Tag: noindex and Cache-Control: private, no-store — the app keeps its own verified copy, and an HTTP-cache copy would outlive signing out.

No Range and no ETag: the app downloads a file once, checks it against its hash and keeps it. A paired phone fetches with its bearer token; <img src> cannot carry one. Not throttled.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 The file.sets Content-Length, Content-Disposition
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not in the caller's folder — never uploaded, removed by the sweep, or someone else's.{"error":"no such file"}
PUT /api/media/{hash} Upload one photo, GIF or video

The body is the raw file; Content-Type must be one of the seven types below and Content-Length is optional (chunked uploads are capped while they stream). The path names the SHA-256 of the bytes, and the file is stored only if they hash to it. Idempotent: uploading a file the server already has answers 200 with existed: true.

What is stored is decided by the file's first bytes, not by the declared type. The declared type only has to be in the same category — a still (JPEG, PNG, WebP, GIF) or a video (MP4, MOV, WebM): a JPEG declared as a GIF is stored as the JPEG it is, a video declared as an image is refused. The cap of the kind the bytes turned out to be applies (MEDIA_IMAGE_MAX_MB, MEDIA_GIF_MAX_MB, MEDIA_VIDEO_MAX_MB), and an MP4 or MOV must be a well-formed file no longer than MEDIA_VIDEO_MAX_SEC (+1 s), read from its headers. HEIC/AVIF, SVG, HTML and every other type are refused; the app converts HEIC photos before uploading. The server never decodes or alters a file: the app has already re-encoded photos and removed video metadata on the device.

Checked in this order: session (401), the hourly budget and at most two uploads at once per profile (429), the declared type (415), a declared length over the cap (413 before a byte is read), already stored (200), the quota (413 media-quota; files the profile already dropped get one hour instead of the grace to make room), free disk (507), then while and after streaming: size (413), hash (400), sniffed type (415), sniffed cap (413), video checks (415/413). No bytes for 60 s ends the upload with 408. A refused upload is read and discarded up to twice its cap, so the answer reaches the client.

A new file that the profile's state does not reference yet starts its grace period at once, so a state push that never comes does not keep it forever.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 The server already had these bytes (from this or another of your devices).MediaStored
201 Stored.MediaStored
400 The bytes do not hash to the name in the path (hash-mismatch).{"error":"the file does not match its name"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 A browser request from another origin (see CSRF).{"error":"cross-origin request refused"}
408 No bytes arrived for 60 seconds (timeout). The connection is closed.{"error":"the upload stalled"}
413 - media-too-large — over the cap of its kind; maxMB says the cap. - media-quota — the profile's space is full; usedMB and quotaMB say how full. - media-too-long — a video longer than maxSec seconds.{"error":"that file is too large"} · {"error":"your space for photos and videos is full"} · {"error":"that video is too long"}
415 media-type — the declared type is not one of the seven, the bytes are none of them, or they are a video declared as an image (or the reverse). media-invalid — an MP4 or MOV whose boxes do not add up.{"error":"that file type is not accepted"} · {"error":"that video could not be read"}
429 locked — more than MEDIA_UPLOADS_PER_HOUR uploads this hour; busy — two uploads of this profile are already in flight (retryAfter 5).{"error":"too many uploads — try again later"} · {"error":"too many uploads at once — try again in a moment"}
507 The server's disk has less than MEDIA_MIN_FREE_MB free (storage-full).{"error":"the server is running out of disk space"}
POST /api/media/missing Which of these files the server does not have

The app sends every hash its state references and uploads only what comes back, so the same call moves a guest's files into a new account, a phone's into its pairing, a backup's into the server, and re-sends a file the sweep removed while a device still had it. Answered from the caller's own folder only.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

hashesrequired string[]

Responses

200 The subset the server does not have, in the order sent, and the profile's usage.{ missing: string[], usage: MediaUsage }
400 hashes is missing, not a list, longer than 1000, or holds something that is not a lowercase SHA-256 (bad-request).
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 A browser request from another origin (see CSRF).{"error":"cross-origin request refused"}
POST /api/media/sweep Delete every file your current state does not reference

What "Reset everything" does after pushing the empty state: every stored file of the caller that the stored state does not reference goes now, without the grace period. A state that cannot be read removes nothing. At most 10 an hour per profile (429 locked). Recorded in the activity log as media.sweep.

Without this call nothing is lost either: the hourly sweep removes a file once the state has not referenced it for MEDIA_GC_GRACE_DAYS days (14), and only for profiles whose state reads — a missing or unreadable state, or a profile missing from db.json, never deletes anything.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

{}

Responses

200 What was removed, and what is left.{ removed: integer, freedBytes: integer, usage: MediaUsage }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 A browser request from another origin (see CSRF).{"error":"cross-origin request refused"}
429 More than 10 sweeps this hour (locked).{"error":"too many uploads — try again later"}

AI Coach

AI Coach — plans, reviews and debriefs. Every route here answers 503 unless the instance has the Coach switched on and a provider connected.

GET /api/coach/disclosure What the consent screen must say

The categories of data a Coach job would send, and the provider it would go to, read straight from the module that builds the payload — so the consent screen cannot drift from what actually leaves the box.

Needs a session but not a connected Coach: the screen that collects consent runs before there is anything to consent to.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 The disclosure.{ provider: string, providerLabel: string, categories: ("plan" | "training" | "bodyweight" | "profile" | "prefs")[], version: integer }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
GET /api/coach/status The running job, the waiting proposal and today's budget

What the Coach screen polls. Also the only place a proposal past its 14-day expiry is retired — a phone that stays closed never polls, so nothing else would notice.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 This profile's Coach state.CoachStatus
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
503 The Coach is not set up on this instance — switched off, or no provider connected. Every Coach route answers this rather than pretending, so an unconfigured instance renders exactly the app it was before the feature existed.{"error":"the Coach is not set up on this instance"}
POST /api/coach/plan Ask for a training plan

Queues a create job. With intake it builds a plan from the answers given on the intake screen; with refine it reworks the proposal already waiting, in the words of the person reading it. The answer is 202 and a job id — the result arrives through GET /api/coach/status.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON) (optional)

intake CoachIntake
refine string (≤ 4000 chars) A change asked for in plain words, applied to the proposal now waiting. Cut to the instance's maxMessageLen (1000 unless the admin changed it; 200–4000).
lang string The language the app is showing, which the Coach writes in. Absent or not a language tag, the profile's stored lang is used.
{
  "intake": {
    "goal": "muscle",
    "experience": "novice",
    "daysPerWeek": 3,
    "equipment": [
      "dumbbell",
      "barbell"
    ]
  }
}

Responses

202 Queued. The job runs in the background; its outcome arrives through GET /api/coach/status, which the client polls.{ job: object }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 The Coach will not take this job, and says which reason in code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
409 The Coach will not take this job, and says which reason in code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
429 The Coach will not take this job, and says which reason in code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
503 The Coach is not set up on this instance — switched off, or no provider connected. Every Coach route answers this rather than pretending, so an unconfigured instance renders exactly the app it was before the feature existed.{"error":"the Coach is not set up on this instance"}
POST /api/coach/review Ask for a review of the training since the last one

Queues a review job — the same job the weekly cadence queues on its own. The answer is either a change-set to accept or reject, or "nothing to change" with the reason, both delivered through GET /api/coach/status.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON) (optional)

note string (≤ 4000 chars) Anything the profile wants the Coach to know this time round. Cut to the instance's maxMessageLen (1000 unless the admin changed it; 200–4000).
lang string The language the app is showing, which the Coach writes in. Absent or not a language tag, the profile's stored lang is used.

Responses

202 Queued. The job runs in the background; its outcome arrives through GET /api/coach/status, which the client polls.{ job: object }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 The Coach will not take this job, and says which reason in code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
409 The Coach will not take this job, and says which reason in code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
429 The Coach will not take this job, and says which reason in code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
503 The Coach is not set up on this instance — switched off, or no provider connected. Every Coach route answers this rather than pretending, so an unconfigured instance renders exactly the app it was before the feature existed.{"error":"the Coach is not set up on this instance"}
POST /api/coach/debrief Ask for a read of one session

Queues a debrief job: one workout, read closely. It changes nothing — the card it produces is kept in the profile's log. With no workoutId the most recent session is read.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON) (optional)

workoutId string (≤ 40 chars) | null The workout to read; null or absent means the latest.
lang string The language the app is showing, which the Coach writes in. Absent or not a language tag, the profile's stored lang is used.

Responses

202 Queued. The job runs in the background; its outcome arrives through GET /api/coach/status, which the client polls.{ job: object }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 The Coach will not take this job, and says which reason in code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
409 The Coach will not take this job, and says which reason in code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
429 The Coach will not take this job, and says which reason in code: - busy (409) — a job for this profile is already running. - shared (409) — instance mode, and the credential belongs to another profile. - cap (429) — this profile's or the instance's daily limit is spent. - consent (403) — no consent recorded for this profile. - unprivileged (503) — the privilege drop cannot be performed, so no job runs. The message is the one to show; the raw provider detail never reaches the user.{"error":"the Coach is already thinking about your training"}
503 The Coach is not set up on this instance — switched off, or no provider connected. Every Coach route answers this rather than pretending, so an unconfigured instance renders exactly the app it was before the feature existed.{"error":"the Coach is not set up on this instance"}
POST /api/coach/pending/resolve Apply or discard the waiting proposal

The client applies the accepted changes to its own state and syncs them through PUT /api/data; this only records the decision and clears the proposal. Sending it when nothing is waiting is a no-op, not an error.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON) (optional)

accepted string[] Ids of the changes the person kept.
rejected string[] Ids of the changes they turned down.
dismissed boolean True when the whole proposal was thrown away unread.

Responses

200 Recorded; the proposal is cleared.Ok
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
503 The Coach is not set up on this instance — switched off, or no provider connected. Every Coach route answers this rather than pretending, so an unconfigured instance renders exactly the app it was before the feature existed.{"error":"the Coach is not set up on this instance"}
GET /api/coach/cohort How this profile sits against the others who opted in

Medians only, and only when at least three profiles on the instance share. Nothing at all for a profile that does not share itself, or on an instance where the admin has not switched the comparison on — each of those is a 200 saying which it is.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 The comparison, or the reason there isn't one.CoachCohort
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
503 The Coach is not set up on this instance — switched off, or no provider connected. Every Coach route answers this rather than pretending, so an unconfigured instance renders exactly the app it was before the feature existed.{"error":"the Coach is not set up on this instance"}
POST /api/coach/cohort/share Opt this profile in or out of the comparison

Held server-side rather than in the synced state: this flag decides whether the profile's numbers reach other people, and a stale device syncing an older copy must not be able to turn it back on.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Request body (JSON)

share boolean
{
  "share": true
}

Responses

200 The flag as it now stands.{ ok: always true, sharing: boolean }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
503 The Coach is not set up on this instance — switched off, or no provider connected. Every Coach route answers this rather than pretending, so an unconfigured instance renders exactly the app it was before the feature existed.{"error":"the Coach is not set up on this instance"}
GET /api/coach/account Whose provider account this profile would spend

Its own route because both the Coach screen and the admin card have to state it, and neither should be inferring it from settings. In instance mode a personal credential binds to the first profile that spends it; every other profile is refused outright rather than warned, and reason says so.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 The account this profile's jobs would run on.{ mode: "instance" | "profile", provider: string, providerLabel: string, account: string | null, connected: boolean, reason: string | null, message: string | null }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/coach/forget Drop everything the server holds for this profile

Consent withdrawn, or the Coach switched off for this profile: the job record, the waiting proposal, the history and the sharing flag go at once, without waiting for a sync to carry the news. A job already running is cancelled where the adapter allows it and writes nothing back either way.

Today's job count is the one thing that survives — it is the spending record the daily cap reads, and forgetting must not hand out a fresh cap.

Needs a session only: a profile must be able to be forgotten on an instance whose Coach has since been switched off.

Requires a session — the cookie set at sign-in, or Authorization: Bearer <token> from mobile-app pairing.

Responses

200 Forgotten.Ok
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}

Admin

Admin dashboard — requires an admin session (ADMIN_UIDS or admin:true)

POST /api/admin/user/password-resetadmin Issue a one-time password reset code

Returns a code (12 characters from the pairing alphabet, 60 bits) that is shown once, stored only as a SHA-256, valid for 24 h and good for one POST /api/login/password-reset. Issuing it removes the profile's current password and bumps its session version (every session and pending pairing code ends); passkeys keep working. A newer code replaces an older one. Admin accounts are refused — an admin sets their own password in Settings. Audited as admin.password.reset.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Request body (JSON)

idrequired string The user id.

Responses

200 The code, once.{ ok: always true, name: string, code: string, expires: number }
400 The target is an admin.{"error":"an admin sets their own password in Settings"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
404 No user with that id.{"error":"no such user"}
409 Another profile already signs in with this name, or holds it with a reset code that has not been used or expired yet.{"error":"another profile already signs in with this name"}
GET /api/admin/usersadmin List all users

One row per user with workout counts, last sync, push status and live presence. workouts counts only the entries the drill-down can show. Admin only.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Responses

200 All users.{ users: AdminUserRow[], invite_only: boolean, password_login: boolean, now: number }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
GET /api/admin/useradmin One user in detail

Drill-down — full workout history (newest first) and body-weight log for one user. Admin only. Anything in routines, bodyweight or workouts that is not an object is left out, and a list that is not an array reads as empty: PUT /api/data drops those entries now, but a state file written before it did still has them and this route has to answer for it. A workout's media (its photos and videos) is left out: they are the owner's own.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Query parameters

idrequired string The user id.

Responses

200 The user's detail.AdminUserDetail
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
404 No user with that id.{"error":"no such user"}
POST /api/admin/user/disableadmin Disable or re-enable a user

Sets the disabled flag. A disabled account is locked out everywhere at once (every existing session stops resolving) and dropped from live presence. Admins cannot be disabled. Audited.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Request body (JSON)

idrequired string The user id.
disabledrequired boolean

Responses

200 Flag updated.{ ok: always true, id: string, disabled: boolean }
400 Target is an admin.{"error":"cannot disable an admin"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
404 No user with that id.{"error":"no such user"}
POST /api/admin/user/deleteadmin Delete a user and everything of theirs

Removes the account for good: the user record, their passkeys, their push subscriptions, their live presence, their training history file, their uploaded photos and videos (DATA_DIR/uploads/<uid>/) and any Coach credential stored for them. Not reversible — /api/admin/user/disable is the reversible one. An admin cannot delete their own account, and the last admin cannot be deleted. Audited by name, since the id stops meaning anything.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Request body (JSON)

idrequired string The user id.

Responses

200 The account and everything of theirs is gone.{ ok: always true, id: string }
400 Your own account, or the last admin.{"error":"you cannot delete your own account"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
404 No user with that id.{"error":"no such user"}
GET /api/admin/invitesadmin List invite codes

All invite codes, with the redeeming user's name resolved for display.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Responses

200 All invites.{ invites: Invite[], invite_only: boolean }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
POST /api/admin/invites/newadmin Mint an invite code

Creates a fresh single-use invite code (16 hex chars = 64 bits — the code itself is the thing that isn't worth guessing; rate limiting is the reverse proxy's job). Audited.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Request body (JSON) (optional)

note string (≤ 60 chars) Free-form label ("for Alex").

Responses

200 The new invite.{ invite: Invite }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
POST /api/admin/invites/revokeadmin Revoke an unused invite code

Deletes an unused code. A code that was already redeemed cannot be revoked.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Request body (JSON)

coderequired string

Responses

200 Revoked.Ok
400 The code was already used.{"error":"already used — cannot revoke"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
404 No such code.{"error":"no such code"}
GET /api/admin/auditadmin Read the activity log

The audit log (sign-ins, failed attempts, admin actions), newest first, paged by event id (before cursor — offset paging would repeat rows as new events land). Retention (AUDIT_MAX events / AUDIT_DAYS days) is applied on read as well as on the hourly compaction. When AUDIT_LOG is off the route still answers, with enabled: false and whatever was logged before.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Query parameters

limit integer (1–200, default 100) Page size.
before integer Only events with id < before (cursor from nextBefore).
cat "auth" | "admin" | "fail" Filter — fail keeps only failed events; any other value keeps events whose name starts with <cat>. (in practice auth or admin).

Responses

200 One page of the log.{ events: AuditEvent[], total: integer, nextBefore: integer | null, enabled: boolean, ip_mode: "off" | "net" | "full", retention: object, now: number }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
POST /api/admin/audit/clearadmin Clear the activity log

Deletes the log file. The clear is itself the first event of the fresh log, and event ids are never reset — a wipe always leaves a visible id gap.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Responses

200 Cleared.Ok
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
GET /api/admin/coachadmin The Coach card

Everything the admin screen renders in one round trip, including a live check of the configured provider — for a runtime provider "is the runtime there", for an HTTPS one the model list its stored key can see.

Counts and outcomes only: no intake answers, no payloads, no proposals, and never a credential — only whether one is filed and what it is labelled.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Responses

200 The card.AdminCoach
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
POST /api/admin/coach/configadmin Change the Coach settings

A patch — every field is optional and only what is sent is changed. Model, endpoint and credential are all keyed by provider, so switching provider never drops any of them.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Request body (JSON)

enabled boolean
provider string Must be one of the ids GET /api/admin/coach lists.
model string (≤ 80 chars) For the provider being set (or the current one). Empty string clears it.
baseUrl string Only for a provider with a configurable endpoint; validated before it is stored. Empty means "back to the default", so it is refused for a provider that has none.
community boolean Offer the cohort comparison on this instance.
caps object
caps.perProfileDaily integer (0–200) 0 = no cap.
caps.instanceDaily integer (0–5000) 0 = no cap.
maxMessageLen integer (200–4000) How long a chat message, refinement or review note can be. Clamped into the range; not a number keeps the current value.
{
  "enabled": true,
  "provider": "compatible",
  "model": "qwen3.8-27b",
  "caps": {
    "perProfileDaily": 10,
    "instanceDaily": 0
  }
}

Responses

200 Saved.Ok
400 Unknown provider, an endpoint set on a provider that has a fixed one, a base URL that did not validate, or an empty one for a provider with no default endpoint to fall back to (compatible).{"error":"unknown provider"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
POST /api/admin/coach/testadmin Run one round trip against the provider

Checks the runtime, then asks the model for one fixed JSON object and validates the shape that comes back — the whole path a job takes, on nobody's profile.

Always 200: the outcome is in the body, because "the provider refused" is an answer to the question the button asks, not a failure of the request. It brings the two short upstream retry pauses rather than COACH_RETRY_DELAYS_MS, since an admin is watching it through a proxy with a timeout of its own.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Responses

200 The result of the round trip.{ ok: boolean, version: string, error: string }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
POST /api/admin/coach/modelsadmin List the models the endpoint serves

So the card can offer a list rather than a text field that goes stale with every model release. HTTPS providers only; a provider that cannot be asked answers ok: false with an empty list rather than an error status.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Responses

200 The models, or why there are none.{ ok: boolean, models: string[], error: string }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
POST /api/admin/coach/connectadmin File the provider credential

The token is accepted once and never read back: it is encrypted with the instance secret and leaves again only as an environment variable on a job's child process. type has to match a variable the provider actually declares, so a key for one provider cannot be filed under another and then silently go nowhere.

A key may be filed for a provider that is not the active one, so the chips can be prepared before switching.

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Request body (JSON)

provider string Defaults to the active provider.
typerequired "cli-token" | "oauth" | "apikey"
tokenrequired string Never returned by any route, ever.
account string (≤ 120 chars) A label for whose account this is — shown on the card.
{
  "type": "apikey",
  "token": "sk-…",
  "account": "team@example.com"
}

Responses

200 Filed.Ok
400 Unknown provider, a credential type that provider does not take, or no token.{"error":"no token supplied"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
POST /api/admin/coach/disconnectadmin Remove the provider credential

Admin only — a signed-in session whose user is an admin (ADMIN_UIDS or admin:true), as the session cookie or a Bearer token.

Request body (JSON) (optional)

provider string Defaults to the active provider.

Responses

200 Removed.Ok
400 Unknown provider.{"error":"unknown provider"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}

Schemas

The named shapes the endpoints above link to. The State blob is representative, not enforced — the server stores it opaquely and the app grows it over time.

Error
errorrequired string Human-readable message. This is the only error shape the API has.
code string A stable reason on the password, throttle and media routes, for a client to word in its own language (bad-credentials, locked, too-short, media-quota, …). Media refusals add the numbers to word it with (maxMB, maxSec, usedMB, quotaMB).
retryAfter integer 429 only — the same number of seconds as the Retry-After header.
Ok
ok always true
PasskeyList
passkeys object[]
passkeys[].id string WebAuthn credential id (base64url).
passkeys[].name string | null
passkeys[].created string | null ISO timestamp.
passkeys[].lastUsed string | null ISO timestamp of the last sign-in or confirmation with it.
passkeys[].transports string[]
password boolean The profile has a password and PASSWORD_LOGIN is on, so current can prove an addition or a removal.
lastWayIn boolean Removing any one passkey would be refused (409 last-way-in).
OwnerProof Either a passkey assertion made for this request (cid + credential), or the profile's current password (current) — which counts only while PASSWORD_LOGIN is on.

Each assertion is good for one request: its challenge is spent by it.

cid string From POST /api/login/options.
current string
SessionUser The caller's identity, as returned by /api/me and the sign-in routes.
id string Stable user id (16 base64url chars).
name string
admin boolean
WebAuthnRegistrationOptions PublicKeyCredentialCreationOptionsJSON — pass to startRegistration() (@simplewebauthn/browser) or decode for navigator.credentials.create().

Resident keys are required; attestation is none.

challenge string base64url
rp object
rp.name string
rp.id string The RP_ID the instance is configured with.
user object
user.id string base64url user handle
user.name string
user.displayName string
pubKeyCredParams object[]
authenticatorSelection object
authenticatorSelection.residentKey always "required"
authenticatorSelection.userVerification always "preferred"
attestation always "none"
WebAuthnRegistrationCredential RegistrationResponseJSON — the value startRegistration() resolves with (the created credential plus the authenticator's attestation response), sent back verbatim.
id string base64url credential id
rawId string
type always "public-key"
response object
response.clientDataJSON string base64url
response.attestationObject string base64url
response.transports string[] e.g. ["internal", "hybrid"] — stored for later logins.
WebAuthnAuthenticationOptions PublicKeyCredentialRequestOptionsJSON — pass to startAuthentication().

allowCredentials is empty: the browser offers the user's discoverable passkeys itself.

challenge string base64url
rpId string
userVerification always "preferred"
allowCredentials object[] Always empty.
WebAuthnAuthenticationCredential AuthenticationResponseJSON — the value startAuthentication() resolves with, sent back verbatim.
id string base64url credential id
rawId string
type always "public-key"
response object
response.clientDataJSON string
response.authenticatorData string
response.signature string
response.userHandle string
State The whole app state as one blob.

The server treats it as opaque (it only strips active on save and reads a few fields for reminders and the admin dashboard) — the schema below is representative, not enforced (except workouts and routines, which must be arrays (or null) when present), and grows with the app. Concurrent writes are caught by the server's revision (_rev, see PUT /api/data); the client merges the two documents and decides by _ts which side's settings win.

_ts number Client-set last-write timestamp (ms since epoch); drives sync.
_rev integer Server-set revision, incremented on every accepted PUT. Ignored when sent by a client.
unit "kg" | "lb"
lang string UI language code ("en", "de", "pt", …) — also localizes pushes.
theme string
accent string
workouts Workout[] Completed workouts, chronological.
routines Routine[]
week object Weekly plan: weekday (0=Sunday … 6) → routine id.
dayPlan object Per-date overrides: ISO date → routine id, or "rest". Wins over week.
bodyweight BodyweightEntry[]
customEx CustomExercise[] User-created exercises (id, name, muscles…).
exWeights object Per-exercise last/best weight memory: exercise id → {w, d}.
reminder object Daily "workout planned today" push. The server reads this: at time (HH:MM, in tz) it sends one push if a routine is planned and nothing was logged yet.
reminder.on boolean
reminder.time string
reminder.tz string | null IANA zone ("Europe/Zurich"); the user's own clock.
restSec number Default rest-timer seconds.
effort "none" | "rir" | "rpe" | null
equipProfiles object[]
settings object Catch-all note: the remaining top-level keys (sound, keepAwake, gifSize, targetW, autoBackup, …) are flat settings values like the ones above.
CustomExercise One exercise the user made (representative shape).

The server stores it as sent and reads only media from it, to know which uploaded files are still referenced.

id string
n string Name.
bp string Body part.
custom always true
media MediaRef
url string (uri, ≤ 2048 chars) One link — a video or a guide. http(s) only, no credentials. Neither the app nor the server ever fetches it: the app opens it in a new browsing context on a tap.
_ts number When this entry was last edited, ms — for merging two devices' copies.
MediaRef What the state knows of an uploaded file.

The bytes are behind GET /api/media/{hash}; the server keeps a file as long as some customEx[].media.hash, workouts[].media[].hash or .poster.hash of either in the profile's state names it, and for MEDIA_GC_GRACE_DAYS after.

kindrequired "image" | "gif" | "video" gif means animated; a one-frame GIF is an image.
hashrequired string SHA-256 of the stored file.
mimerequired "image/webp" | "image/jpeg" | "image/png" | "image/gif" | "video/mp4" | "video/quicktime" | "video/webm"
sizerequired integer (1–)
widthrequired integer (1–16384)
heightrequired integer (1–16384)
dur number (0–3600) Seconds, one decimal; videos and animated GIFs, absent when unknown.
codec "avc1" | "hvc1" | "av01" | "vp09" | "vp8" | "vp9" | "other" Videos only.
poster object A still of at most 480 px — always for images and GIFs, when possible for videos.
poster.hash string
poster.mime "image/webp" | "image/jpeg"
poster.size integer
poster.width integer
poster.height integer
atrequired number When it was attached
MediaConfig The instance's media caps (MEDIA_*), in MB of 2^20 bytes.

Absent when the instance runs with MEDIA_UPLOADS=0.

imageMB number A photo after the app re-encoded it, or a poster.
gifMB number
videoMB number
videoSec number
quotaMB number Per profile; 0 = no cap.
workouts always true This instance keeps the files a logged workout's media list names too, not only a custom exercise's. Absent on an instance from before; the app then offers photos and videos on custom exercises only.
MediaUsage
bytes integer What the profile's stored files take.
count integer
quotaBytes integer The cap; 0 = no cap.
MediaStored
ok always true
hash string
mime string What the bytes are — not necessarily what was declared.
size integer
existed boolean The server already had this file.
Workout One completed workout (representative shape).
id string
d string (date) ISO day, e.g. "2026-08-30".
start number ms since epoch
end number
routineId string
name string
bw number Body weight that day, in unit.
vol number Total volume (Σ weight × reps).
prs string[] Exercise ids that hit a personal record.
entries object[]
entries[].id string Exercise id.
entries[].topW number | null
entries[].sets object[]
entries[].sets[].w number Weight.
entries[].sets[].r number Reps.
entries[].sets[].done boolean
entries[].sets[].rir number
entries[].sets[].rpe number
media MediaRef[] Photos and videos attached to this workout (a progress photo, a form-check clip) — at most six in the app. Refs only; the bytes are behind /api/media/{hash}, and the server keeps every file any entry here names (the GC walks this list as it walks each custom exercise's media).
_ts number When this workout was last edited after it was logged, ms — for merging two devices' copies.
Routine A workout template (representative shape).
id string
name string
emoji string
ex object[] Exercises with set/rep targets.
BodyweightEntry
d string (date)
w number Weight in unit.
t number Timestamp (ms).
PushSubscription The browser's PushSubscription.toJSON() — only these fields are stored.
endpointrequired string (uri) Public https:// push-service URL.
keysrequired object
keys.p256dhrequired string
keys.authrequired string
AdminUserRow
id string
name string
created string | null ISO timestamp.
disabled boolean
admin boolean
invitedBy string | null The invite code this account was created with.
workouts integer Total logged workouts.
lastWorkout string | null ISO day of the newest workout.
lastSync number | null The state's _ts (ms).
hasPush boolean Has at least one push subscription.
live Presence | null Currently training, or null.
password boolean PASSWORD_LOGIN instances only — the profile has a password.
email string | null PASSWORD_LOGIN instances only — the e-mail the profile signs in with, if any.
Presence Live-workout snapshot (from /api/activity heartbeats).
name string
exIdx integer
exTotal integer
setsDone integer
setsTotal integer
startedAt number
updatedAt number
AdminUserDetail
user object
user.id string
user.name string
user.created string | null
user.disabled boolean
user.admin boolean
user.invitedBy string | null
user.password boolean PASSWORD_LOGIN instances only.
user.email string | null PASSWORD_LOGIN instances only — the sign-in e-mail, if any.
user.resetUntil number | null PASSWORD_LOGIN instances only — expiry (ms) of an unused reset code.
unit "kg" | "lb"
lastSync number | null
routines object[]
routines[].id string
routines[].name string
routines[].emoji string
routines[].count integer Number of exercises.
bodyweight BodyweightEntry[]
workouts Workout[] Full history, newest first.
Invite
code string 16 hex chars.
note string
createdBy string Admin user id.
created string ISO timestamp.
usedBy string | null User id that redeemed the code (absent/null while unused).
usedAt string
usedByName string | null Resolved display name (only in GET /api/admin/invites).
AuditEvent One line of the audit log.
idrequired integer Monotonic, never reset — a cleared log leaves a visible id gap.
tsrequired number ms since epoch.
evrequired string Event name. Currently one of: auth.register.ok|fail|denied, auth.login.ok|fail, auth.logout, auth.logout.all, auth.pair.create|ok|fail, admin.denied, admin.user.disable|enable, admin.invite.create|revoke, admin.audit.clear.
okrequired boolean false for failed/denied attempts.
uid string Acting (or affected) user id.
name string Acting user's name (max 40 chars).
tgt string Target user id (admin actions).
tname string Target user's name.
msg string Short detail — a failure reason code, or the invite code involved. Rejected invite guesses and unknown credential ids are deliberately never recorded.
ip string Only when AUDIT_IP is on — the full address (full) or just the network, /24 or /48 (net).
CoachCap What this profile has spent of its daily allowance.
used integer
limit integer 0 means no per-profile cap.
CoachStatus
job object | null The job running now, or null. One at a time per profile.
pending CoachProposal | null
cap CoachCap
maxMessageLen integer (200–4000) The longest note or refinement this instance keeps, in characters. It rides on the poll so a chat that is already open follows an admin's change.
last object | null
CoachProposal The answer waiting for a decision, from whichever kind of job produced it.

It expires 14 days after it was made; the first status poll after that retires it. changes comes from a review, bundle from a plan, score/highlights from a debrief — one of the three, never more.

id string The job's id.
kind "create" | "review" | "debrief"
createdAt integer Epoch ms.
expiresAt integer Epoch ms.
planHash string The plan this was written against — a later edit makes it stale.
iteration integer 1, or higher after a refine.
summary string (≤ 1200 chars)
changes object[] A review's change-set, each one applied or rejected on its own.
changes[].id string
changes[].type "add-exercise" | "remove-exercise" | "swap-exercise" | "sets" | "reps" | "repsMin" | "repsMax" | "sec" | "cardio" | "reorder" | "superset" | "routine-prog" | "exercise-prog" | "inc" | "add-routine" | "remove-routine" | "rename-routine" | "week"
changes[].why string
changes[].before object What the plan says now — the client checks it before applying.
changes[].after object What it would say.
evidence object The window the review read.
evidence.from string | null
evidence.to string | null
evidence.sessions integer | null
notes string[]
bundle object A whole plan, from a create job — routines, week and any custom exercises.
bundle.opengym_plan always 1
bundle.name string (≤ 40 chars)
bundle.summary string
bundle.basedOn string
bundle.week object
bundle.routines object[]
bundle.customEx object[]
score integer (1–10) A debrief's score for the session.
highlights string[]
watch string[]
nextTime string[]
workout object Which session a debrief read.
CoachIntake The intake answers, as the plan screen collects them.

Stored in the profile's own state as well; sent here so a plan can be asked for with answers that have not been synced yet.

goal string e.g. muscle, strength, fat-loss.
experience string
daysPerWeek integer
preferredDays integer[] 0 = Sunday.
sessionMin integer Minutes per session.
equipment string[]
limitations string Injuries and anything to work around.
CoachCohort Medians across the profiles that opted in, and where this one stands among them.

ok is false whenever there is nothing to show, and the other flags say why: the admin has the comparison switched off (enabled: false), this profile does not share (sharing: false), or there are not yet three who do.

ok boolean
enabled boolean
sharing boolean
people integer
minPeople integer Three. Below it nothing is computed at all.
unit "kg" | "lb" The reader's own unit — every number below is in it.
sessionsPerWeek object
sessionsPerWeek.median number
sessionsPerWeek.you number
exercises object[]
exercises[].id string
exercises[].name string
exercises[].people integer
exercises[].median number | null
exercises[].you number | null
rankPct integer | null Share of the others at or below this profile, averaged over the listed exercises.
AdminCoach The whole admin card in one answer.

Never a credential — only whether one is filed.

disabledByEnv boolean COACH_DISABLED is set, so nothing the admin toggles matters.
enabled boolean
provider string
providers object[] Every provider this build knows, and what each one needs.
providers[].id string
providers[].label string
providers[].runtime string The runtime it spawns, when it spawns one.
providers[].setupToken boolean
providers[].deviceLogin boolean
providers[].apiKey boolean
providers[].http boolean
providers[].baseUrl boolean Whether its endpoint is configurable.
providers[].keyOptional boolean
providers[].keyPlaceholder string | null
providers[].defaultModel string | null
providers[].connected boolean Whether a credential is already filed for it.
model string The model in force for the active provider.
models object Model per provider — switching provider keeps them all.
baseUrl string | null
knownModels string[] | null From the live check, when the provider could be asked.
caps object
caps.perProfileDaily integer
caps.instanceDaily integer
maxMessageLen integer How long a chat message, refinement or review note can be (200–4000, default 1000).
community boolean
runtime object The live check performed while answering this request.
runtime.ok boolean
runtime.version string | null
runtime.error string | null
runtime.needsKey boolean
authMode "instance" | "profile"
boundUid string | null The profile an instance credential bound itself to, once one has spent it.
auth object Whether a credential is filed, and whose. unreadable is its own state because it has a specific cause and a specific fix — ./data restored without its secret.
auth.state "not-required" | "none" | "optional" | "connected" | "unreadable"
auth.type string | null
auth.account string | null
auth.connectedAt string (date-time) | null
unprivileged object Whether the privilege drop can be performed. It fails closed: if ok is false no job runs at all, and the admin needs to hear that here rather than from a user.
unprivileged.ok boolean
unprivileged.dropped boolean
unprivileged.why string
jobsToday integer
lastSuccess string (date-time) | null
lastError object The last failure the instance log recorded, or null.
recent object[] The last 20 jobs, newest first — counts and outcomes only, never contents.
recent[].at string (date-time)
recent[].kind string
recent[].trigger "manual" | "scheduled"
recent[].outcome string
recent[].errorClass string | null
recent[].ms integer