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.

OpenAPI 3.1.0 openGym v1.4.5 177 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 from the 21st on, for 30 s, doubling up to 15 min. Wrong passwords also pause the account they were tried against from the 6th on, 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 (their failures reach the audit log at most ten a minute per address). 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, from the 21st on, 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","code":"origin-mismatch","expected":ORIGIN} on any non-GET route, the passkey routes included. Exempt: pair/redeem, which the mobile app calls from its own origin (the code in the body is the credential, and the token comes back in the body, never as a cookie), and any request a Bearer token authenticates by itself: a valid session token, with no session cookie beside it, on any route but the sign-in ones — the four passkey routes, the password and reset-code routes and the two device-link routes, which act on the credential in the body, never on a Bearer. A cross-origin request whose Bearer does not buy the exemption is refused before any handler runs: 401 {"error":"not signed in"} when that Bearer does not verify and is the only credential (a paired app whose token was revoked or expired), the 403 otherwise. The passkey routes were exempt before, on the grounds that the challenge id is a credential; but it is bound to nothing else, so a hostile page could complete the attacker's own passkey sign-in in the victim's browser (login CSRF) and the answer set the victim's session to the attacker's account. The password routes are not exempt for the same reason: a hostile page knows a valid name and password of its own.

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. One admin is the owner: the record with "owner": true, else the first ADMIN_UIDS entry with a profile, else the oldest admin. Only the owner appoints and removes admins, deletes an admin, hands the role on and clears the audit log.
  • FIRST_USER_ADMIN (default off; 1 turns it on): the first profile registered on an instance with no profiles gets that "admin": true flag and is the owner. Decided at registration only, never applied to an instance that already has profiles.
  • 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.
  • OIDC_ISSUER + OIDC_CLIENT_ID (and usually OIDC_CLIENT_SECRET): adds the oidc routes and oidc in GET /api/config. OIDC_ONLY=1 refuses passkey and password sign-ups, and passkey and password sign-ins for everyone but admins (403 oidc-only).
  • 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).
  • INTEGRATIONS (unset by default, e.g. webhook): the providers of the integrations routes, and the integrations block of GET /api/config. INTEGRATIONS_ALLOW_LAN=1 lets a webhook reach the home network (http too), never loopback or link-local.
  • 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 (100), MEDIA_VIDEO_MAX_SEC (60), MEDIA_AUDIO_MAX_MB (2), 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"}.
  • Every route that reads a request body requires Content-Type: application/json (a charset parameter is fine) and answers any other type with 415 {"error":"content-type must be application/json"} — the types a form can send are the ones that skip the CORS preflight, which is how a cross-site form reached these routes. A request with no body needs no 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, so 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.

The check writes: it creates and removes one empty file under DATA_DIR, because a server whose data directory is full or read-only has every write path broken while the port still answers. It is a 503 when that fails — the container healthcheck (wget --spider) reads the status and nothing else.

The probe runs at most once every five seconds and the answer in between is the last one, so a poll can be that stale. The route is unauthenticated, and one disk write per request is not something to leave open to anyone.

Public: no authentication required.

Responses

200 The server is up and its data directory is writable.{ ok: always true, users: integer, writable: always true }
503 The data directory could not be written — a full disk, or a mount that came back read-only. Nothing that stores anything will work until it is fixed; the reason is in the container log.
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, oidc: object, default_lang: string, media: MediaConfig, integrations: object }

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.

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.

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. 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).

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: boolean, provider_logout: string (uri) } · 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"}
POST /api/account/delete Delete your own account and everything of yours

The signed-in account removes itself for good, with the same clean-up as /api/admin/user/delete: 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. The body carries the account's name, typed back by its owner, and the same proof as setting a password (see OwnerProof): a passkey assertion made for this request, or the current password while PASSWORD_LOGIN is on. A paired phone (Bearer token) cannot hold a passkey for the server, so for a profile with no password that counts the pairing itself is the proof (audited as device; the app asks the phone's own screen lock first); a profile with one sends the current password from the phone too. Cookie sessions always need the proof. The name and the last-admin rule are checked before the proof is asked. The last admin is refused while any other profile remains (the instance would be left with nobody to run it); the only profile on an instance may delete itself. Clears the caller's cookie; every other session and Bearer token of the account stops working with it. Audited as auth.account.delete, with the name and the proof it was made with (passkey, password, device); a refused proof as auth.proof.fail with act: account-delete.

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

Request body (JSON)

{
  "name": "Ada",
  "current": "correct horse battery staple"
}

Responses

200 The account and everything of theirs is gone.Ok · sets Set-Cookie
400 confirm-name: the name is missing or is not this account's.{"error":"type the account name to confirm"}
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, because 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 last-admin: the only admin, with other profiles still on the instance.{"error":"the last admin cannot leave while other profiles remain"}
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"}

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. One live code per profile: minting a new one retires the previous one. Same proof as a device link (a passkey assertion made for this request, or the current password while password sign-in is on), since the phone token a code turns into is renewed for as long as it is used. Audited as auth.pair.create.

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

Request body

OwnerProof

Responses

200 A fresh pairing code.{ code: string }
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, because 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"}
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"}

OIDC

Sign in with an identity provider (OpenID Connect), next to passkeys. Every route here is a 404 unless the server runs with OIDC_ISSUER and OIDC_CLIENT_ID (docs/SELF_HOSTING.md, "Sign in with your identity provider").

GET /api/oidc/startpublic Leave for the identity provider

A browser navigation, not a fetch. Answers 302 to the provider's authorization endpoint with the authorization code flow, PKCE (S256), a fresh state and nonce, and sets a 10-minute gymoidc cookie (__Host-gymoidc over https) that ties the callback to this browser. mode: web (default), app (the phone app, see below), link (Settings, with the ticket from POST /api/oidc/link, in the browser signed in as that profile), or reauth (sign in at the provider again, prompt=login, so the next change to a way in may use provider: true as its proof).

For mode=app, return must be exactly opengym://oidc, challenge the base64url SHA-256 of a verifier only the app holds (43 characters), and astate the app's own state, which comes back unchanged. Anything else is a 400, never a redirect.

Public: no authentication required.

Query parameters

mode "web" | "app" | "link" | "reauth"
ticket string
return "opengym://oidc"
challenge string
astate string

Responses

302 To the provider; or, when the start itself fails, back to the app with ?oidc-error=<code> (throttled, provider, link-expired).
400 An unknown mode, or app parameters that are not allowed.
GET /api/oidc/callbackpublic Come back from the identity provider

The redirect URI to register at the provider: <ORIGIN>/api/oidc/callback (or OIDC_REDIRECT_URI). Checks the state and the flow cookie, trades the code at the token endpoint with the PKCE verifier, and verifies the ID token: the signature against the provider's JWKS (RS*, PS*, ES*, EdDSA; never none or HS*), iss, aud/azp, exp, iat, nbf and the nonce. Groups and the e-mail are read from userinfo when the ID token has none.

Then: a profile linked to this identity signs in; otherwise, with OIDC_EMAIL_LINKING=1, a profile whose sign-in e-mail matches a email_verified: true address is linked; otherwise a new profile is made, if the groups let this person in (OIDC_ALLOWED_GROUPS, or an access= rule in OIDC_ROLE_MAP) and FIRST_USER_ADMIN applies as for any sign-up. Answers 302 to the app with ?oidc=signed-in|linked|confirmed and a session cookie, or ?oidc-error=<code>: state, token, provider, cancelled, group, link-needed, invite, disabled, taken, already-linked, wrong-account, link-expired, throttled. In app mode, 302 to opengym://oidc?code=…&astate=… (or error=) and no cookie. Rate-limited per address; wrong states and tokens count toward the address pause.

Public: no authentication required.

Query parameters

state string
code string
error string

Responses

302 Back to the app (see above).
POST /api/oidc/app/redeempublic Trade the phone app's one-time code for a Bearer token

The code from opengym://oidc?code=… and the verifier whose SHA-256 the app sent as challenge. One try per code, 2 minutes. Returns the same token POST /api/pair/redeem does. CSRF-exempt, like pair/redeem: the credential is in the body, and nothing comes back as a cookie.

Public: no authentication required.

Request body (JSON)

coderequired string
verifierrequired string (≤ 128 chars)

Responses

200 Signed in.{ token: string, user: SessionUser }
400 Unknown, used or expired code, or the wrong verifier (oidc-invalid).
429 Too many tries from this address.

Watch relay

The watch relay for Zepp OS watches (docs/ZEPP_OS.md)

POST /api/watch/pair A pairing code for a Zepp OS watch

A signed-in device asks for a code that the Zepp app's settings page for openGym redeems (POST /api/watch/redeem). 12 characters typed as XXXX-XXXX-XXXX, one per profile (a new one replaces the last), good for ten minutes, held only as a hash.

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

Responses

200 The code and when it expires (ms since the epoch).{ code: string, exp: integer }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/watch/redeempublic Redeem a watch pairing code for a watch token

Called by the openGym watch app's Side Service inside the Zepp phone app. The code is the credential (CSRF-exempt, throttled per address like a device link). The answer is a watch token (ogw_…) that opens GET/POST /api/watch/box and nothing else; it ends with "sign out everywhere", a new password, a disabled account, an unlink, or 90 days without use.

Public: no authentication required.

Request body (JSON)

coderequired string
name string The watch's name for Settings (40 characters).
{
  "code": "K7WQ-2MZP-8RTA",
  "name": "Amazfit Balance"
}

Responses

200 Paired.{ token: string, id: string, name: string }
400 Code unknown, used or expired.
429 Too many wrong codes from this address.
PUT /api/watch/snapshot The page's snapshot for the profile's watches

The JSON text the page sends any watch (frontend/src/lib/watch-sync.js buildSnapshot), 64 KB at most. 409 no-watch while no watch is paired.

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

Request body (JSON)

jsonrequired string

Responses

200 Stored.
400 Not a snapshot.
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
409 No watch paired.
GET /api/watch/events What the watches sent, oldest first

The events exactly as a watch sent them (watch-sync.js applyEvents), until acknowledged (POST /api/watch/ack), for two weeks at most. paired counts the watches, seen is when one last checked in.

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

Responses

200 The queue.{ events: object[], paired: integer, seen: integer }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/watch/ack Events applied, out of the queue

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

Request body (JSON)

ids string[]

Responses

200 Done.
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
GET /api/watch/boxpublic The watch reads the page's last snapshot

With the watch token as Authorization Bearer. json is null before the page sent one.

Public: no authentication required.

Responses

200 The snapshot text and when it came (ms).{ json: string, at: integer }
401 No live watch token (watch-unpaired).
POST /api/watch/boxpublic The watch sends its events

With the watch token. At most 50 events a request, 4 KB each, of the types the page applies; a queue holds 500. accepted names the ids the watch may forget (queued now or before, or refused for good); the rest it sends again. The answer carries the snapshot too, so one request does both.

Public: no authentication required.

Request body (JSON)

events object[]

Responses

200 Accepted ids, and the snapshot.{ accepted: string[], json: string, at: integer }
401 No live watch token (watch-unpaired).

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 are free and the sixth pauses 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. Both are 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 with 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 passkey is 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, because 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": "[email protected]",
  "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, because 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, because 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, plus 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, because 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, because 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, recovery: boolean }
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 (a recovery code as auth.recovery.ok), and every refusal as auth.link.fail. A recovery code also works on a profile at the passkey limit: the passkey used longest ago is removed to make room. 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.

The records of removals (deleted) and of edits (edited) never shrink: the stored ones are joined into the write (a reset keeps its own). A writer that does not stamp its own changes (no stamped: true; an API script, an older app) and sends the current baseRev gets them stamped by the server, at the server's time: every entry it removed (a workout, routine, custom exercise, weigh-in, card, profile or favourite), every setting and plan day it changed (queue, week.<day>, …), and every entry it edited (_ts, per field _f). That keeps a planner's new week, or a routine it removed, from being undone by a phone that reconnects later with an older change. A setting, plan day or entry field whose stored stamp (edited, _f) is later than the one the writer sends back keeps the stored value: the writer never saw that change, so send back the edited and _f you read. A writer that sends no edited at all is not held to it. An entry the document holds while a removal on record says it was deleted after its last edit is kept and marked as added back.

Such a writer also cannot delete what it does not know: a field of the stored document, of a stored routine, workout, custom exercise, card or profile, of a routine's exercise, or of a logged exercise or set that its copy leaves out comes back from the stored copy (an older app rebuilds an exercise from the fields it knows, which dropped pyramid sets made on an updated device). Only the fields v1.3.9 itself removes (a superset link, a note, a cleared set value, …) can be removed this way. Send stamped: true to remove anything else.

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.
baseWid string The write id (_wid) of the document this client last read or wrote. With baseRev, a write over a different document at the same revision (the data directory went back in time and the number was reached again) answers 409.
stamped boolean True when the client stamps its own changes in deleted, edited and each entry's _ts/_f, as the app does. Omit it to have the server stamp what changed against the stored document.

Responses

200 Saved.{ ok: always true, ts: number | null, rev: integer, wid: string }
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, wid: string }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
GET /api/nudge-check Whether a day was trained or excused (the phone's background check)

Asked by the phone app in the background before a missed-day nudge or a workout-day reminder it scheduled on itself fires, so a day trained on another device is not nudged. A few bytes instead of the whole document: done is true when a workout is dated that day or the day carries a note; nudge is whether the server's copy of the nudge rule would still nudge that day (false once it is done, or when the plan has no session for it); rev is the profile's revision. Nothing of the workouts themselves.

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

Query parameters

daterequired string (date) The day, YYYY-MM-DD, in the phone's own calendar.

Responses

200 How that day stands.{ date: string (date), done: boolean, nudge: boolean, rev: integer }
400 date-invalid: not a YYYY-MM-DD date.{"error":"date must be YYYY-MM-DD"}
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. An endpoint stored for another account moves to the caller only when the request carries that subscription's own keys (the same browser, signed into another account); otherwise it is a 409 and the row stays where it was.

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, and a re-send of an endpoint without one keeps the id its row already has.

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"}
409 The endpoint is stored for another account and the request does not carry that subscription's keys.{"error":"this subscription belongs to another account"}
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. A stored subscription also names the deviceId it is stored with, and the client re-registers when that is not its own: a row stored without one (from before device ids) sends that device's rest-timer alerts to every subscription of the account.

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, deviceId: string | null }
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, keyed by 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.
next string (≤ 120 chars) The set waiting after the rest, in the person's language ("Bench press, 80 kg × 8"). It becomes the push's body, one line of plain text cut at 120 characters. Optional; without it the body is the general "Time for your next set."
{
  "seconds": 90,
  "deviceId": "3f9c1d2e7b0a4c6d9e8f1a2b3c4d5e6f",
  "next": "Bench press, 80 kg × 8"
}

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} is the "left" signal: with reason navigated or closed, or with no reason, it drops the caller's entry immediately. With reason: hidden (the page went to the background) it marks the entry away instead, and the entry stops being listed 45 s after that mark unless a heartbeat arrives first; a heartbeat clears the mark. The 70 s expiry applies either way.

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 (see reason); true (with the fields below) updates it.
reason "navigated" | "closed" | "hidden" Why the client sent active: false: navigated (an in-app route change), closed (the page is going away) or hidden (the page went to the background). navigated and closed drop the entry at once, as no reason does; hidden marks it away rather than dropping it. Ignored with active: true.
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 eight 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.

Optional header X-OpenGym-Account: the account id the upload is meant for. When it is not the session's account (the browser signed in as someone else meanwhile) the upload is refused with 409 account-mismatch and nothing is stored.

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), or a sound (a 16-bit PCM WAV, audio/wav): 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, MEDIA_AUDIO_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.

Takes the same optional X-OpenGym-Account header as the upload: another account than the session's answers 409 account-mismatch.

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, host: 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. code is off, the class a job refused at enqueue for the same reason carries; the app shows its own line for it.{"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. code is off, the class a job refused at enqueue for the same reason carries; the app shows its own line for it.{"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. code is off, the class a job refused at enqueue for the same reason carries; the app shows its own line for it.{"error":"the Coach is not set up on this instance"}
POST /api/coach/ask Ask the Coach a question

Queues an ask job: a question from the chat, answered in plain text. It changes nothing; the answer arrives as a pending proposal of kind ask with an answer string, which the client writes into the chat and resolves. The running session, which the server never holds, may ride along as today; the payload builder bounds it like a logged workout.

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

Request body (JSON)

textrequired string The question. Cut at the instance's message length.
today object The session running on the device (entries with their sets), or absent.
lang string The language the app is showing, which the Coach answers in.

Responses

202 Queued. The job runs in the background; its outcome arrives through GET /api/coach/status, which the client polls.{ job: object }
400 No question in the body (code is invalid).
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. code is off, the class a job refused at enqueue for the same reason carries; the app shows its own line for it.{"error":"the Coach is not set up on this instance"}
POST /api/coach/adapt Ask the Coach about an exercise mid-workout

Queues an adapt job: one exercise of the workout running on the device, and why it needs a rethink (pain, a busy rack, short on time, feeling great, tired). The answer is a pending proposal of kind adapt with up to six changes to the unfinished part of that session (swap, sets, reps, weight, add, skip, move, rest), each naming the exercise by its index in today. Logged sets are never changed. The client applies the ticked ones to its own running session and resolves the proposal; the server never holds the session.

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

Request body (JSON)

todayrequired object The session running on the device (entries with their sets).
entryrequired integer (0–) The index in today.entries of the exercise asked about.
reason "pain" | "busy" | "short" | "great" | "tired" | "other"
text string What the lifter typed, optional. Cut at the instance's message length.
activeId string The running session's id, handed back on the proposal.
lang string The language the app is showing, which the Coach answers in.

Responses

202 Queued. The job runs in the background; its outcome arrives through GET /api/coach/status, which the client polls.{ job: object }
400 No running workout, or no exercise of it named (code is invalid).
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. code is off, the class a job refused at enqueue for the same reason carries; the app shows its own line for it.{"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. code is off, the class a job refused at enqueue for the same reason carries; the app shows its own line for it.{"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. code is off, the class a job refused at enqueue for the same reason carries; the app shows its own line for it.{"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. code is off, the class a job refused at enqueue for the same reason carries; the app shows its own line for it.{"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. code is off, the class a job refused at enqueue for the same reason carries; the app shows its own line for it.{"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"}

Integrations

Settings → Integrations: outbound webhooks now, more providers later. Off unless the server runs with INTEGRATIONS (e.g. INTEGRATIONS=webhook); then a profile can add its own connections. Secrets are sealed at rest and shown once. See docs/INTEGRATIONS.md.

GET /api/integrations The integrations this server offers, and the caller's connections

providers is what the admin switched on with INTEGRATIONS (empty when it is unset, the default), connections the caller's own. A connection never carries a secret or its full URL: info.url is masked. status is connected, paused or attention (the last deliveries failed for good, or its sealed settings no longer open because ./data/secret changed); lastError says why in plain words.

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

Responses

200 Providers and connections.{ providers: object[], connections: IntegrationConnection[] }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/integrations/connect Add a connection (a webhook)

For provider: webhook: url (https, or http to a home-network address when INTEGRATIONS_ALLOW_LAN=1; never loopback, link-local or, by default, a private address), an optional label (60 characters) and events (at least one; default workout.finished). At most 10 webhooks a profile. The answer's reveal.secret is the signing secret, shown this once: the server keeps it sealed and never sends it again. Payloads and the signature: docs/INTEGRATIONS.md.

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

Request body (JSON)

providerrequired string
urlrequired string (≤ 2048 chars)
label string (≤ 60 chars)
events IntegrationEvent[]

Responses

200 Connected.{ connection: IntegrationConnection, reveal: object }
400 The URL or the events were refused (code: url, events).
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 code off: the server runs without INTEGRATIONS (the default). code missing: no such connection on this profile (another profile's id is the same answer).{"error":"integrations are off on this server"}
409 Too many connections of this kind (code: too-many).
429 Connect, change or test tapped too often (20 a minute, tests 6). Try again after Retry-After.{"error":"slow down a little"}
POST /api/integrations/update Rename a connection, change its events, pause or resume it

A new URL is a new webhook (and a new secret): disconnect and connect again. paused: false also clears a "Needs attention".

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

Request body (JSON)

idrequired string
label string (≤ 60 chars)
events IntegrationEvent[]
paused boolean

Responses

200 The connection as it is now.{ connection: IntegrationConnection }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 code off: the server runs without INTEGRATIONS (the default). code missing: no such connection on this profile (another profile's id is the same answer).{"error":"integrations are off on this server"}
429 Connect, change or test tapped too often (20 a minute, tests 6). Try again after Retry-After.{"error":"slow down a little"}
POST /api/integrations/disconnect Remove a connection

Forgets the connection, its sealed secrets, its log and anything still waiting to be sent to it. Nothing is called on the far side.

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

Request body (JSON)

idrequired string

Responses

200 Gone.Ok
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 code off: the server runs without INTEGRATIONS (the default). code missing: no such connection on this profile (another profile's id is the same answer).{"error":"integrations are off on this server"}
POST /api/integrations/test Send a test event now

Delivers a made-up event of type (default: the connection's first event) straight away, marked "test": true, and answers how it went. Six a minute per profile.

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

Request body (JSON)

idrequired string

Responses

200 The receiver's answer (ok false with msg when it failed).{ ok: boolean, status: integer | null, ms: integer | null, msg: string | null, connection: IntegrationConnection }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 code off: the server runs without INTEGRATIONS (the default). code missing: no such connection on this profile (another profile's id is the same answer).{"error":"integrations are off on this server"}
429 Connect, change or test tapped too often (20 a minute, tests 6). Try again after Retry-After.{"error":"slow down a little"}
POST /api/integrations/retry Send the failed deliveries again

Deliveries the server gave up on (after eight attempts over about an hour, or at once for a refusal like a 404) go back into the queue from attempt one and are sent now.

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

Request body (JSON)

idrequired string

Responses

200 How many went back, and the connection afterwards.{ requeued: integer, connection: IntegrationConnection }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 code off: the server runs without INTEGRATIONS (the default). code missing: no such connection on this profile (another profile's id is the same answer).{"error":"integrations are off on this server"}
429 Connect, change or test tapped too often (20 a minute, tests 6). Try again after Retry-After.{"error":"slow down a little"}
GET /api/integrations/oauth/start Start a sign-in with an OAuth service (Strava)

A browser navigation, not a fetch. Redirects to the service's consent page with a state bound to the signed-in profile (valid ten minutes, once), or back to Settings → Integrations with error when that is not possible.

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

Query parameters

providerrequired "strava"

Responses

302 To the service
GET /api/integrations/oauth/callback Where the OAuth service sends the person back

Checks the state (its profile, its time, used once; a browser that sends its session cookie has to be that profile), exchanges the code for tokens (sealed, never shown) and stores the connection, replacing an earlier one of the same service. Redirects to Settings → Integrations with intg and connected=1, or error.

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

Query parameters

state string
code string
scope string
error string

Responses

302 Back to the app.
GET /api/integrations/log A connection's activity log

The last 50 deliveries and tests, newest first, plus what is queued or failed.

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

Query parameters

idrequired string

Responses

200 The log.{ log: object[], queued: integer, failed: integer }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 code off: the server runs without INTEGRATIONS (the default). code missing: no such connection on this profile (another profile's id is the same answer).{"error":"integrations are off on this server"}

Scoped tokens

Scoped tokens (docs/API_TOKENS.md): a key a profile makes in Settings for one program, an AI client over MCP, a planner, a script, holding per category off, view or apply. Only its SHA-256 is stored. A token reaches the /api/token/* routes and nothing else, never more than its profile, and ends with "sign out everywhere". API_TOKENS=off makes every route here a 404.

GET /api/tokens The profile's scoped tokens

Never the token itself, only a few characters of it (hint) to tell them apart.

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

Responses

200 The tokens, what a token can hold, and whether /mcp is served here.{ tokens: ApiToken[], categories: object[], days: integer[], defaultDays: integer, mcp: boolean }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/tokens Make a scoped token

The answer is the only time the token is shown. At most 10 per profile; a token that may see nothing is refused. days is one of 7, 30, 90, 365 or 0 (until revoked).

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

Request body (JSON)

name string (≤ 40 chars)
scopes TokenScopes
days integer

Responses

200 The new token (once), its id and the updated list.{ token: string, id: string, tokens: ApiToken[] }
400 Nothing granted (nothing-granted) or bad days (bad-days).
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
409 Ten tokens already (too-many).
POST /api/tokens/revoke Revoke one token, or every token

Without an id, every token of the profile. It stops working on the next request.

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

Request body (JSON)

id string

Responses

200 How many went, and the list.{ ok: boolean, revoked: integer, tokens: ApiToken[] }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
GET /api/calendar The profile's calendar feed, if it has one

The private ICS address (docs/CALENDAR.md), whether it shows finished workouts, and when a calendar last fetched it.

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

Responses

200 The feed, or on false.CalendarFeed
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/calendar Turn the calendar feed on, change what it shows, or make a new address

With a feed already on, done changes what it shows and the address stays. renew (or no feed yet) makes a new address and ends the old one.

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

Request body (JSON)

done boolean Also show finished workouts
renew boolean

Responses

200 The feed.CalendarFeed
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
409 The profile has as many tokens as it may hold.
POST /api/calendar/revoke Turn the calendar feed off

Its address stops working at once.

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

Responses

200 Off.CalendarFeed
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
GET /api/calendar/{token}.icspublic The calendar feed (iCalendar)

For a calendar app to subscribe to. The token in the path is the feed's own; no other token, cookie or header is read. Planned sessions as all-day events from a week back to eight weeks ahead, and with done the workouts of the last 90 days.

Public: no authentication required.

Query parameters

token (path)required string

Responses

200 The feed.
404 No such feed (never was
429 Fetched too often.
GET /api/token/me Whose token this is and what it may do

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

Responses

200 The profile (id, name) and the token (id, name, scopes, expiry).{ user: object, token: object }
401 No token, an unknown, expired or revoked one, or its profile signed out everywhere or is disabled.{"error":"token not accepted"}
429 Too many reads with this token this minute (TOKEN_READS_PER_MIN
GET /api/token/data The part of the profile the token may read

The document's keys of every category the token holds at view or more, plus the profile's settings (units, week start, progression defaults). Never photos, the Coach's conversation, the live workout or linked services. Each category read is noted once a day in the profile's sharing log.

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

Responses

200 The scoped document (null before the profile first synced) and its revision.{ state: object, rev: integer, wid: string }
401 No token, an unknown, expired or revoked one, or its profile signed out everywhere or is disabled.{"error":"token not accepted"}
429 Too many reads with this token this minute.
PUT /api/token/data Change what the token may change

Over the exact revision read (baseRev, required: no blind writes). Every key sent that the token holds at apply is written; a key it may only see has to come back unchanged and is ignored; any other key is a 403 scope. The rest of the profile stays as stored, and the change is stamped like any API client's (sync-stamps.js), so every device merges it. A changed revision is a 409 with the current scoped document. Writes are counted per token (TOKEN_WRITES_PER_HOUR, 60).

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

Request body (JSON)

staterequired object
baseRevrequired integer
baseWid string

Responses

200 Saved (or unchanged), with the new revision.{ ok: boolean, unchanged: boolean, rev: integer, wid: string }
400 No state, no baseRev (base-rev), or an invalid document.
401 No token, an unknown, expired or revoked one, or its profile signed out everywhere or is disabled.{"error":"token not accepted"}
403 A key outside what the token may change (scope).
409 The revision moved (body has state and rev), or the profile never synced (not-synced).
429 Too many writes with this token this hour.
GET /api/token/data/rev Just the revision, to read the document only when it moved

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

Responses

200 The revision and write id.{ rev: integer, wid: string }
401 No token, an unknown, expired or revoked one, or its profile signed out everywhere or is disabled.{"error":"token not accepted"}

MCP connector

The MCP connector's OAuth sign-in (docs/MCP_REMOTE.md), for Claude, ChatGPT and other assistants that add openGym by URL. Only with MCP=on, otherwise 404. What it issues is an ordinary scoped API token, made when the person approves it on the consent screen.

GET /.well-known/oauth-authorization-server MCP connector: authorization server metadata (RFC 8414)

Only with MCP=on. Under BASE_PATH also at /.well-known/oauth-authorization-server<BASE_PATH> (path insertion). Advertises DCR, Client ID Metadata Documents, refresh tokens (offline_access), revocation and the iss response parameter (RFC 9207).

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

Responses

200 Issuer, endpoints, S256, public clients, the scopes.
GET /.well-known/openid-configuration MCP connector: the same metadata under its OpenID Connect discovery name

The MCP spec's clients look here after RFC 8414; under BASE_PATH this is the address below the prefix (<BASE_PATH>/.well-known/openid-configuration). openGym issues no ID tokens.

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

Responses

200 As /.well-known/oauth-authorization-server.
GET /.well-known/oauth-protected-resource MCP connector: protected resource metadata (RFC 9728)

The document the mcp container's 401 points at. resource is <ORIGIN>/mcp.

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

Responses

200 resource, authorization_servers, scopes_supported.
GET /.well-known/oauth-protected-resource/mcp MCP connector: the same document at the path-suffixed address

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

Responses

200 As above.
POST /api/oauth/register MCP connector: register an OAuth client (RFC 7591)

Only with MCP=on. Public clients (no secret), redirect URIs https or loopback only. Used by Claude and ChatGPT when they add openGym as a custom connector (docs/MCP_REMOTE.md).

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

Responses

201 The client: client_id, redirect_uris, token_endpoint_auth_method: none.
400 Bad redirect URIs.
GET /api/oauth/authorize MCP connector: start the sign-in

Checks the client, the redirect URI and the PKCE S256 challenge, then redirects to the app's consent page. An error goes back to the client's redirect URI when it is a known one.

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

Responses

302 To the consent page, or back to the client with an error.
400 Unknown client or redirect URI.
GET /api/oauth/request MCP connector: what the consent page shows

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

Responses

200 The assistant, where it sends you back, the scopes asked for.
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Expired.
POST /api/oauth/approve MCP connector: approve, making a scoped token

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

Responses

200 redirect: back to the assistant with a one-time code.
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
POST /api/oauth/deny MCP connector: decline

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

Responses

200 redirect: back to the assistant with access_denied.
POST /api/oauth/token MCP connector: trade the code for the token

application/x-www-form-urlencoded, grant_type=authorization_code, the code, the code_verifier, client_id and redirect_uri. The code works once, for ten minutes. A client that asked for offline_access (or registered the refresh_token grant) also gets a refresh_token; its access token then lives a day. grant_type=refresh_token rotates both; a refresh token used again after the grace minute ends the connection.

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

Responses

200 access_token (an ogt_ token), token_type: Bearer, scope, expires_in, refresh_token when asked for.
400 invalid_grant and friends.
POST /api/oauth/revoke MCP connector: end a connection (RFC 7009)

token= an access or refresh token of a connector. The token row goes, as if revoked in Settings → API tokens. Always 200.

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

Responses

200 Done, or nothing to do.

Trainers

Trainers, gym leads and studios (docs/TRAINERS.md). Off unless the server runs with TRAINERS=on; then every route here answers, otherwise 404 trainers-off. A member connects to a trainer with the trainer's code and a consent, decides per category what is shared, and can change or end it any time, effective on the next request. Every route spends a per-account budget (429 rate-limited, Retry-After).

GET /api/sharing The member: my connections and gyms

Every trainer connection (pending or active) with its scopes, whether it is paused (left-gym, gym-off, not-trainer) and its gym lead row; the gyms joined; the current consent text with version and hash; the lead mode (row, mirror, none).

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

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/sharing/code The member: read a trainer code

Who the code belongs to, which gym (name, data protection contact, leads), the default scopes for the consent screen and the lead row, and the consent text. Creates nothing. Wrong codes count per address and account (10 free, then a pause that doubles).

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

Request body (JSON)

code string TR-XXXXX-XXXXX, any case, spaces and dashes optional

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/sharing/connect The member: say yes to a trainer

Creates a pending grant (the trainer confirms it), a consent record per row, and with a studio trainer the studio membership and the gym lead row (never more than view). Levels per category are off, view, suggest, apply; each category has its own ceiling.

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

Request body (JSON)

code string The trainer code
scopes object { category: level }
leadScopes object The lead row; only narrows the pre-fill
consent object { v, hash } of the consent text shown (GET /api/sharing). A stale one is 409 consent-outdated with the current text.
lang string Language the consent was read in

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/sharing/decline The member: not now

Nothing is created and the code stays usable; the member's own log notes it.

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

Request body (JSON)

code string The trainer code

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/sharing/update The member: change what one connection shares

New scopes for a trainer row (its lead row follows down, never up) or for a lead row (only narrower than the trainer's). A new consent record replaces the old one.

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

Request body (JSON)

id string Grant id
scopes object { category: level }
consent object { v, hash } of the consent text shown (GET /api/sharing). A stale one is 409 consent-outdated with the current text.
lang string Language

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/sharing/disconnect The member: end a connection

Ends the grant (and a trainer grant's lead row) at once. The trainer loses access on their next request.

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

Request body (JSON)

id string Grant id

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/sharing/leave The member: leave a gym

Ends the membership, the lead row and every studio trainer grant of that gym. Private trainers stay.

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

Request body (JSON)

gymId string Gym id

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
GET /api/sharing/log The member: my sharing log

Newest first: connections, changes, ends, gym joins, admin looks (ADMIN_DATA_VIEW) and trainer looks folded per day and category. Only the member reads it.

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

Query parameters

before number Page: entries older than this at
limit integer 1 to 200, default 100

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
GET /api/sharing/export The member: everything about my sharing

Memberships, grants, consent records (version, hash, language, scopes) and the full log, as an attachment. Account deletion removes all of it.

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

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
GET /api/trainer The trainer: roles, codes, requests, members

The gyms the caller trains in, whether they may invite as a free trainer, their live codes, pending requests (member name and the scopes offered) and connected members with the level each category grants right now. Never any member data.

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

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/trainer/codes The trainer: new invite code

A code (shown once, only its hash is stored) and a link for the QR. Studio codes need a trainer role in that gym, others free-trainer status. 20 a day.

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

Request body (JSON)

gymId string Optional gym id
maxUses integer 1 to 200, default 1
days integer 1 to 30, default 7

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/trainer/codes/revoke The trainer: revoke a code

The code stops working; connections made with it stay.

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

Request body (JSON)

id string Code id

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/trainer/requests/answer The trainer: confirm or decline a request

Confirming activates the grant and its lead row; declining ends both.

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

Request body (JSON)

id string Grant id
accept boolean true to confirm

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/trainer/disconnect The trainer: end a connection

Same as the member ending it.

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

Request body (JSON)

id string Grant id

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
GET /api/trainer/members The trainer: the member list

Every member connected to this trainer, with what each shares and, where key numbers are shared, the last workout, the streak (weeks), this week's sessions against the plan and flags: missed (planned days without a workout in the last 7), pr (records in the last 7 days), inactive (no workout for inactive days, default 7), weight (a change of 1 or more over two weeks, needs body weight), pending (suggestions not answered yet). A flag only appears when its category is shared. Each listed member's sharing log notes the look.

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

Query parameters

today string Client date (YYYY-MM-DD), used when within a day of the server
inactive string Days without a workout before the inactive flag (2 to 60)

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
GET /api/trainer/member The trainer or gym lead: one member

The projection of one member: only the categories they share with this person (shared), never the document. Workouts come without media and, unless notes are shared, without notes; paged newest first by before. Plus this person's own comments, suggestions and changes. 404 for a member this person was never connected to, 403 not-shared once a connection ended.

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

Query parameters

id string Member id
before string Workouts before this date (YYYY-MM-DD)
limit string Workouts per page (1 to 100, default 20)
today string Client date (YYYY-MM-DD)

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
GET /api/trainer/member/photo The trainer or gym lead: one progress photo of a member

The bytes of one progress photo (a still filed under a day or on a workout, photos.items of the member projection), only while the member shares photos with this person; the grant is checked for every file. Sent with the same headers as GET /api/media/{hash}. Form videos, custom exercise pictures, sounds and the profile picture are never served here. 403 level with photos not shared, 404 for a stranger or a file outside the category. Budget trainer.photo.

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

Query parameters

id string Member id
hash string The photo (lowercase sha256)

Responses

200 The file.
400 Not a sha256 (bad-request).
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Photos not shared with this person (level), or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
POST /api/trainer/ops The trainer: change a member's data

Whitelisted ops (routine.put, routine.remove, week.set, bodyweight.put, measurement.put, workout.add, workout.edit), each checked against its category: applied at once where the member allows changes (written through the same sync stamps as PUT /api/data, every entry labelled _by, undoable by the member for 30 days), sent as one suggestion to the member's inbox where they allow suggestions, refused otherwise.

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

Request body (JSON)

memberId string
ops object[]
message string (≤ 300 chars)

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
POST /api/trainer/assign The trainer: assign one of their routines

Copies one of the trainer's own routines (with the custom exercises it needs, without media) to up to 50 members, optionally onto a weekday (day, 0 is Sunday). Per member the outcome is applied, suggested or skipped with a reason. The copy carries origin (kind trainer, sourceId); assigning it again replaces that copy.

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

Request body (JSON)

memberIds string[]
routineId string
day integer (0–6)
message string (≤ 300 chars)

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
POST /api/trainer/comment The trainer: comment on a workout

Needs workouts at See. The comment goes to the member's inbox (and a push), never into their training data.

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

Request body (JSON)

memberId string
workoutId string
text string (≤ 1000 chars)

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
POST /api/trainer/leave-gym The trainer: stop working at a gym

Ends the trainer role at that gym. The trainer's members there are paused and get the offer to stay connected privately for 30 days.

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

Request body (JSON)

gymId string

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
GET /api/gym The gym lead: the gym

The gym's trainers (with member counts) and its members, each with their trainers and, where the member's own lead row shares key numbers, the summary. Never more than view.

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

Query parameters

id string Gym id
today string Client date (YYYY-MM-DD)

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
POST /api/gym/code The gym lead: a join code

A GY-… code (default 50 uses, 30 days) and the link that opens it. Members who use it join the gym and set their lead row; there is no trainer in it.

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

Request body (JSON)

gymId string
maxUses integer (1–200)
days integer (1–30)

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
POST /api/gym/staff The gym lead: add or remove a trainer

By account id. Removing one pauses their gym members and sends each the private offer.

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

Request body (JSON)

gymId string
user string Account id
on boolean

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
GET /api/sharing/inbox The member: inbox and trainer changes

Suggestions, comments, notices (a new connection, changed sharing, a gym joined, a trainer's change) and offers to stay with a trainer privately, newest first; plus every change a trainer made, with canUndo.

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

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
POST /api/sharing/inbox/answer The member: answer an inbox item

A suggestion: accept applies it (labelled with the trainer, undoable), otherwise it is declined; 410 when the connection ended. An offer: accept with the current consent makes a private connection, otherwise the paused one ends. A notice or comment: dismissed.

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

Request body (JSON)

id string
accept boolean
consent object
lang string

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
POST /api/sharing/inbox/seen The member: mark inbox items seen

Without ids, all of them.

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

Request body (JSON)

ids string[]

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
POST /api/sharing/undo The member: undo a trainer's change

Puts the entries back as they were, as the member's own newest change, so it wins on every device. 409 changed-since when the member changed them after the trainer did (send again with force), 409 already-undone, 410 after 30 days.

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

Request body (JSON)

id string
force boolean

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not allowed at this level, or the connection ended (not-shared).
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).
GET /api/sharing/preview The member: see what a connection sees

The same projection the trainer or gym lead of that grant gets, so the preview cannot drift from the truth.

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

Query parameters

grant string Grant id

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited).

Community

The community library (LIBRARY=on, off by default): things people chose to share with everyone on this server, each after its own consent, always shown with who shared it and when. Importing copies the item into your own profile. Reports, admin review (SHARE_REVIEW=on), removal and bans are all logged. The built-in exercise media can never be shared (blocked by SHA-256). Every route spends a per-account budget.

GET /api/shared Browse the community library of this server

Items people shared with everyone on this server (LIBRARY=on), newest or most imported first, 50 a page. Live items the caller may see, plus the caller's own in any status. Every item carries by (the uploader's current name and, when they shared one, their profile picture's hash) and at (when it was shared). A plan comes with a summary instead of its payload; GET /api/shared/{id} has the whole thing. Budget library.list (600 an hour).

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

Query parameters

type "sound" | "exercise" | "plan" | "equipment" | "tip" | "look" | "measures" | "avatar" | "photo" | "formvideo" | "progress"
q string Words that must all appear (title, description, names).
lang string Comma-separated languages, e.g. de,en.
sort "new" | "popular"
ex string Tips about this exercise id only.
mine string 1: only the caller's own items, in every status.
gym string A gym id, or server for items open to the whole server.
cursor string

Responses

200 One page.{ items: SharedItem[], next: string, total: integer }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 The community library is off on this server (sharing-off, LIBRARY unset), or the item does not exist or is not one the caller may see (not-found).{"error":"the community library is off on this server"}
429 Over a per-account budget (rate-limited); Retry-After says when to try again.{"error":"slow down a little"}
POST /api/shared Share one of your own items with everyone on this server

Needs the current library consent with own: true ("It's mine to share"): without the tick 400 consent-own, with an older text 409 consent-outdated and the current one. Files named in the payload must be in the caller's own uploads (PUT /api/media/{hash}) first, else 409 media-missing with the list. Each file is checked again before it is copied into the shared folder: its type, the sharing caps (SHARE_VIDEO_MAX_MB 25, sounds 30 s, a profile picture 32 KB), no Exif, XMP, text chunks or MP4 metadata boxes (422 media-metadata), no WebM (422 media-webm), and never a file of the built-in exercise library or one an admin removed (422 media-blocked, by SHA-256). Starts pending with SHARE_REVIEW=on, and always for personal photos and videos. Budget library.publish (20 an hour). Logged as shared.publish.

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

Request body (JSON)

typerequired string
src string Which of your own things this is (sound:<id>); only ever sent back to you.
title string (≤ 60 chars)
desc string (≤ 500 chars)
lang string
gymId string
payloadrequired object Per type: sound {name, m}, exercise {ex, media?}, plan {bundle}, equipment {name, profile}, tip {exId, exName, text}, look {flame?, freeze?, accent?}, measures {fields}, avatar {m}, photo/progress/formvideo {media, caption, exId?}. Files are MediaRefs.
consentrequired object
consent.v integer
consent.hash string
consent.own boolean

Responses

201 Shared (or waiting for an admin).{ id: string, status: "live" | "pending", item: SharedItem }
400 consent-own, type-off, bad-payload.
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 banned or not-in-gym.
409 consent-outdated (with consent), media-missing (with missing), too-many.
413 quota or media-too-large.
422 media-metadata, media-webm, media-blocked, media-type.
429 Over a per-account budget (rate-limited); Retry-After says when to try again.{"error":"slow down a little"}
GET /api/shared/{id} One shared item with its whole payload

An item the caller may not see answers exactly like one that does not exist (404 not-found).

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

Responses

200 The item.{ item: SharedItem }
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 The community library is off on this server (sharing-off, LIBRARY unset), or the item does not exist or is not one the caller may see (not-found).{"error":"the community library is off on this server"}
PATCH /api/shared/{id} A new title, description or version of your item

If-Match: <rev> (or rev in the body) is the revision the editor saw; another one is 412 rev-mismatch. A new payload is a new version (version + 1) and goes through every check a first share does; on a reviewed server it waits for an admin again. People who imported an older version keep it and are told a new one exists. Budget library.edit (60 an hour).

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

Request body (JSON)

rev integer
title string
desc string
lang string
payload object

Responses

200 The new rev and version.{ rev: integer, version: integer, status: string, item: SharedItem }
403 not-owner or banned.
404 The community library is off on this server (sharing-off, LIBRARY unset), or the item does not exist or is not one the caller may see (not-found).{"error":"the community library is off on this server"}
412 rev-mismatch, with the current rev.
DELETE /api/shared/{id} Stop sharing your item

The item leaves the library for new imports (withdrawn); copies people already took stay theirs. Logged as shared.withdraw.

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

Responses

200 Withdrawn.{ status: "withdrawn" }
403 not-owner.
404 The community library is off on this server (sharing-off, LIBRARY unset), or the item does not exist or is not one the caller may see (not-found).{"error":"the community library is off on this server"}
POST /api/shared/{id}/import Copy a shared item into your own profile

Only a live item. Its files are copied into the caller's own upload folder, against the caller's media quota (413 media-quota), so the copy keeps working when the original is withdrawn. Answers the payload and an origin (kind: library, the item id, its version, by and at frozen now, this server's ORIGIN) for the app to merge into its state. Counted once per person. Budget library.import (120 an hour).

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

Request body (JSON)

Responses

200 What to merge.{ type: string, title: string, payload: object, media: string[], origin: object, item: SharedItem }
404 The community library is off on this server (sharing-off, LIBRARY unset), or the item does not exist or is not one the caller may see (not-found).{"error":"the community library is off on this server"}
413 media-quota.
POST /api/shared/{id}/report Report a shared item to the admins

Once per person and item, never your own (403 own-item, 409 already-reported). Reasons: spam, offensive, not-theirs, licensed, personal, other, plus an optional note of 300 characters. Reports from SHARE_AUTOHIDE_REPORTS (3) different people take the item out of sight until an admin decides, and a single licensed report does it at once. Budget library.report (10 an hour). Logged as shared.report.

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

Request body (JSON)

reasonrequired "spam" | "offensive" | "not-theirs" | "licensed" | "personal" | "other"
note string (≤ 300 chars)

Responses

200 Reported; status says whether it is out of sight now.{ ok: boolean, status: string }
400 bad-reason.
403 own-item.
409 already-reported.
429 Over a per-account budget (rate-limited); Retry-After says when to try again.{"error":"slow down a little"}
GET /api/shared/media/{hash} A file of a shared item, for the preview

Only a file a live item names that the caller may see (or one of the caller's own, or any for an admin), never a blocked hash. The same headers as GET /api/media/{hash} (nosniff, a sandbox CSP, private, no-store).

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

Responses

200 The file.
404 media-missing or sharing-off.
GET /api/admin/shared The moderation list

Every item the caller may moderate (admins; a gym's leads for that gym's items), most reported first, with the owner, the reports (who, why, when), the number of importers, the last moderation and the consent version. counts feeds the badge.

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

Query parameters

status "live" | "pending" | "hidden" | "withdrawn" | "removed"
reported "1"
type string
owner string

Responses

200 The list.{ items: SharedItem[], counts: object, review: boolean }
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
POST /api/admin/shared/{id} Approve, hide, restore or remove an item

Every action needs a short reason and is logged (shared.approve, shared.hide, shared.restore, shared.remove). Remove is final: the files leave the shared folder and the folder of every person who imported them, and their hashes are blocked from then on. Text copies (a routine, a tip) stay with the people who took them.

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

Request body (JSON)

actionrequired "approve" | "hide" | "restore" | "remove"
reasonrequired string (≤ 200 chars)

Responses

200 Done.{ status: string, copiesRemoved: integer }
400 bad-action or reason-needed.
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
409 removed or withdrawn.
POST /api/admin/users/{uid}/sharing Pause or resume sharing for one person

banned: true (with a reason) stops the person sharing or editing, and takes their live and waiting items out of sight; false brings back exactly the ones the ban hid. Logged as shared.ban / shared.unban.

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

Request body (JSON)

bannedrequired boolean
reason string (≤ 200 chars)

Responses

200 Done.{ banned: boolean, items: integer }
403 Signed in, but not an admin. The attempt is recorded in the audit log.{"error":"forbidden"}
404 not-found.

Admin

Admin dashboard. Requires an admin session (ADMIN_UIDS or admin:true)

POST /api/admin/user/recoveryadmin Make a one-time recovery code (#584)

For someone who lost every way in, on any instance (with or without PASSWORD_LOGIN). Returns a code (12 characters from the pairing alphabet, 60 bits), shown once, stored only as a SHA-256, valid for 24 h. The person redeems it like a device link (POST /api/device-link/options and /verify): the device they use gets a new passkey for the profile and is signed in. Making it bumps the profile's session version (every session, pending pairing code and device link ends); passkeys and the password stay. A newer code replaces an older one.

Any admin may make one for a profile that is not an admin; only the owner for an admin; nobody for the owner (403 owner: the owner uses the server command node recover.js <name>), for oneself (400 self) or for a disabled profile (400 disabled). Audited as admin.recovery.create; a refusal for an admin as admin.denied.

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 Oneself, or a disabled profile.{"error":"enable the account first"}
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
403 Not an admin; an admin who is not the owner asking for an admin; or the target is the owner.{"error":"the owner recovers with the server command (recover.js)"}
404 No user with that id.{"error":"no such user"}
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. Without ADMIN_DATA_VIEW there is no lastWorkout and live carries only startedAt (no routine, no progress); data_view says which.

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 for one user. Admin only. Since 1.4.5 it answers operational numbers only (counts, bytes, lastSync, hasPush, roles with TRAINERS on) and data_view: false. Only with ADMIN_DATA_VIEW=on does it add the full workout history (newest first), the body-weight log and the routines, with data_view: true; every such look is written to the activity log (admin.data-view) and to the member's own sharing log. 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, the last admin and the owner cannot be deleted, and only the owner deletes another admin. 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, the last admin, or the owner.{"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"}
POST /api/admin/user/roleadmin Owner: make someone an admin, or remove the role

Owner only (403 for any other admin). Not on the owner themselves, not on an admin named in ADMIN_UIDS (the server would restore them on restart), and a disabled account is enabled before it can be promoted. Removing the role keeps the account and its data.

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.
adminrequired boolean

Responses

200 Changed.{ ok: always true, id: string, admin: boolean }
400 The owner, an ADMIN_UIDS admin, a disabled account, or no 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"}
404 No user with that id.
POST /api/admin/owneradmin Owner: hand the owner role to another admin

Owner only. The target must already be an enabled admin; the previous owner stays an admin.

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 Handed over.{ ok: always true, id: string }
400 Yourself, or not an enabled 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.
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 Owner: clear the activity log

Owner only, so no other admin can erase a trace of what they did. Deletes the log file. The clear is itself the first event of the fresh log, and event ids are never reset, so 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/oidcadmin OIDC status

What the server was told (issuer, client id, scopes, groups claim, allowed groups, the number of role-map rules, OIDC_ONLY, e-mail linking, logout) and what it found (the discovery document reached, keys in the set, the signing algorithms accepted, the last error). The client secret is never in it.

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 status.OidcStatus
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/oidc/checkadmin Fetch the provider's discovery document and keys again

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 status after the check.OidcStatus
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. A runtime-backed provider is not run when the privilege drop cannot be performed, exactly as a job would not be: runtime then reads unavailable, with the same reason a job is refused with.

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. That is the whole path a job takes, on nobody's profile. A runtime-backed provider is refused, without spawning anything, when the privilege drop cannot be performed, exactly as a job would be.

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. An HTTPS provider gets the same retries as a job: a 429, 500, 502, 503, 504 or 529 is tried twice more, after pauses of 2 and 5 seconds, and only then reported. A runtime provider is asked once.

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/chatgpt/startadmin Coach: start Sign in with ChatGPT for plan usage (preview)

Only with COACH_CHATGPT_PLAN=on (404 otherwise). Returns the auth.openai.com address to open in the admin's own browser; the sign-in returns to a loopback address on that computer (redirect), whose full address is then posted to /api/admin/coach/chatgpt/finish. Plan usage for open-source apps per developers.openai.com/siwc (docs/AI_COACH.md).

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 url to open, redirect it comes back to.
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 The preview is not switched on.
POST /api/admin/coach/chatgpt/finishadmin Coach: finish Sign in with ChatGPT with the address the browser ended on

{ callback }: the loopback address with code and state. The server exchanges the code (PKCE), verifies the ID token against OpenAI's keys, requires the plan-usage scope and files the session, encrypted, as the Coach's instance credential (a personal one: it binds to the first profile that uses it).

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 ok, account (the ChatGPT email), usage (where to manage limits).
400 Expired, another sign-in's address, or plan use not allowed.
502 OpenAI did not complete the exchange.
POST /api/admin/coach/discoveradmin Find a model server on this network

Asks a fixed list of nearby addresses (the ollama, lmstudio and llama compose services, the Docker host, this machine) on the Ollama, LM Studio and llama.cpp ports for their model list. The list never comes from the request.

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 What answered.{ ok: boolean, found: object[] }
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": "[email protected]"
}

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"}
GET /api/admin/gymsadmin Gyms and roles

Every gym with its staff (names and roles) and counts, the free trainers, the number of active grants. Never member data. TRAINERS on 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 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/admin/gyms/newadmin Create a gym

A gym with a name (up to 60) and a data protection contact (up to 200) shown on the consent screen.

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)

name string Gym name
contact string Who is responsible, how to reach them

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/admin/gyms/updateadmin Change or disable a gym

A disabled gym pauses every grant through it, lead and trainers alike.

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)

id string Gym id
name string New name
contact string New contact
disabled boolean Off or on

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/admin/gyms/staffadmin Give or take a gym role

Lead or trainer. Taking the trainer role away pauses that trainer's studio grants at once; giving it back resumes 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)

gymId string Gym id
userId string Profile id
role string lead or trainer
on boolean false to take it away

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).
POST /api/admin/user/traineradmin Allow a free trainer

A trainer without a gym (a personal trainer). Taking it away pauses their private members at once.

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)

id string Profile id
free boolean true to allow

Responses

200 Done.object
401 No session, expired/revoked session, or a disabled account.{"error":"not signed in"}
404 Not found, or trainers-off.
429 Over the account budget (rate-limited) or too many wrong codes (code-paused).

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.

IntegrationEvent workout.finished: a workout reached the server (from any device).

workout.pr: that workout set personal records. weighin.logged: a new or changed weigh-in. Only events from the last seven days that happened after the connection was made are sent.

Free-form object.

IntegrationConnection
id string
provider string
label string (≤ 60 chars)
events IntegrationEvent[]
paused boolean
status "connected" | "paused" | "attention"
info object
info.url string Masked.
createdAt integer Epoch ms.
lastOk integer | null Epoch ms of the last delivery that landed.
lastError object | null
failures integer Failed attempts since the last success.
queued integer
failed integer Deliveries given up on; Retry sends them again.
SharedItem One item of the community library, as a client sees it (docs/dev/SHARED-LIBRARY-API.md).
id string
type string
gymId string
title string
desc string
lang string
payload object
summary object A plan's routines and exercise ids
media string[]
version integer
rev integer
status "live" | "pending" | "hidden" | "withdrawn" | "removed"
createdAt integer
updatedAt integer
stats object
stats.imports integer
by object The uploader, read fresh on every answer. Always there.
by.id string
by.name string
by.avatar string
at integer When it was shared (ms).
mine boolean
src string Only on your own items.
TokenScopes Per category off, view or apply (notes at most view).

Missing is off.

workouts "off" | "view" | "apply"
plans "off" | "view" | "apply"
bodyweight "off" | "view" | "apply"
measurements "off" | "view" | "apply"
notes "off" | "view"
CalendarFeed
on boolean
id string
url string The address to subscribe to.
done boolean Finished workouts are in it too.
created integer
used integer
ApiToken
id string
name string
hint string The six characters after ogt_.
scopes TokenScopes
created integer
exp integer
used integer Last use
kind "calendar" Set on a calendar feed's token
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).
deleteBlocked "last-admin" Present when deleting this profile (POST /api/account/delete) would be refused before any proof is checked: last-admin, the only admin while other profiles remain. The delete sheet says so before asking for a passkey or the password; the delete itself still checks.
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
OidcStatus
enabled boolean
issuer string
clientId string
confidential boolean Whether a client secret is set (never the secret).
scopes string[]
label string
only boolean
redirectUri string
groupsClaim string
allowedGroups string[]
roleRules integer
linkByEmail boolean
logout boolean OIDC_LOGOUT is on and the provider has an end_session_endpoint.
logoutAsked boolean
reachable boolean
keys integer
algs string[]
lastOk integer | null
lastError string | null
lastErrorAt integer | null
linked integer Profiles linked to the provider.
plainHttp boolean
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
owner boolean Present (true) only for the instance owner.
admin boolean
oidc boolean Present (true) when the profile is linked to the configured identity provider.
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.
_wid string Server-set id of this write (16 hex characters), new on every accepted PUT.
_wids string[] The write ids of the documents this one descends from, oldest first (up to 50).
deleted object When entries were removed, per list and entry key ({ workouts: { <id>: ms } }, weigh-ins by day, favourites by exercise id). A negative stamp is an add-back after a removal. Never shrinks on the server; stamped for writers that do not stamp (see PUT /api/data).
edited object When each setting and plan day was last changed, ms ({ queue: ms, 'week.3': ms, … }). Never shrinks on the server; stamped for writers that do not stamp (see PUT /api/data).
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[]
queue object A planner's floating week {ids, since, startsOn, label}. The week's sessions as an ordered queue, the first undone one is today's. Written by an API client or by the app's own rotation feature, read by the reminder tick.
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" | "audio" gif means animated; a one-frame GIF is an image. audio is a sound of the user's own (sounds); its width and height are 1.
hashrequired string SHA-256 of the stored file.
mimerequired "image/webp" | "image/jpeg" | "image/png" | "image/gif" | "video/mp4" | "video/quicktime" | "video/webm" | "audio/wav"
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
audioMB number A sound of the user's own (a WAV of at most 30 s).
quotaMB number Per profile; 0 = no cap.
sounds always true This instance keeps the sounds of the user's own for the rest timer (sounds, v1.4.5). Absent on an instance from before; the app then keeps them on the device.
avatar always true This instance keeps the user's profile picture (avatar.m, a 256 px square, v1.4.5). It is served like every other file, from the caller's own folder only. Absent on an instance from before; the app then keeps the picture on the device that set it.
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.
days always true This instance also keeps the files of progress photos filed under a day (dayPhotos, a photo for a day without a workout). Absent on an instance from before; the app then only adds photos to logged workouts there.
sets always true This instance also keeps the files of a form video filmed for one set (workouts[].entries[].sets[].media, v1.4.4). Absent on an instance from before; the app then keeps a set's video on the phone that filmed it and never uploads it.
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 One day of weigh-ins.

w is the day's one value, the one the profile's bwDaily setting picks from ins (first, the default; avg; last), so a reader that knows nothing of ins gets the same number the app charts. A day from before v1.4.4 has no ins: one weigh-in without a time. A client that writes w and t of a day with ins (an older app correcting the day) has that taken as a correction of the weigh-in w came from.

d string (date)
w number The day's weight in unit (see the description).
t number When the day was last edited (ms)
src string Set when every weigh-in of the day was read from a health app or a file.
ins object[] The day's weigh-ins, by time (#859).
ins[].id string Unique within the day. '0' is the weigh-in a day from before held.
ins[].at string | null Local time of day, HH:MM; null when not known.
ins[].w number Weight in unit.
ins[].t number When this weigh-in was last edited (ms).
ins[].src string Read from a health app or a file.
ins[].sid string The health app's own id of the sample.
gone object Weigh-ins removed from a day that keeps others, id → when (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
owner boolean The one admin who manages the others.
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.owner boolean The one admin who manages the others.
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.
user.recoveryUntil number | null Expiry (ms) of an unused recovery code (POST /api/admin/user/recovery, recover.js).
unit "kg" | "lb"
lastSync number | null
data_view boolean true only with ADMIN_DATA_VIEW on: then routines, bodyweight and workouts are present.
hasPush boolean
bytes integer Size of the state file on disk.
counts object
counts.workouts integer
counts.bodyweight integer
counts.measurements integer
counts.routines integer
roles object TRAINERS on only. Studio roles, free-trainer status, gyms joined.
grants integer TRAINERS on only. Active sharing grants of this member.
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.account.delete, 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.
trigger "scheduled" Present when an automatic review made it, which nobody was watching for.
summary string (≤ 4000 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