Getting started

Authentication

A short-lived bearer token, refreshed against a rotating credential.

Contents

Every endpoint outside /auth/* and /status/* requires an Authorization header. There are no API keys and no query-string credentials.

Header
Authorization: Bearer <token>
CredentialLifetimeBehaviour
token30 minutesAccess token. Sent on every request. Cannot be revoked individually.
refresh_token30 daysExchanged for a new access token. Rotated on every use: the one you present is revoked and a new one issued.

Endpoints#

POST/auth/registerno auth
POST/auth/loginno auth
POST/auth/refreshno auth
POST/auth/logout
GET/auth/me

Registration also creates the organization and makes the caller its owner. Everything else in the API is scoped to that organization; there is no cross-organization read of any kind.

Refreshing#

Refresh when a call returns 401, not on a timer. Thirty minutes is the current access-token lifetime and it is configuration, not contract.

curl
curl -X POST "$BASE/auth/refresh" \
  -H 'Content-Type: application/json' \
  -d '{"refresh_token":"9f2c…"}'

# → {"token": "…", "refresh_token": "…"}   ← store BOTH
client.ts
async function apiWithRetry(path: string, init: RequestInit = {}) {
  let res = await raw(path, init)
  if (res.status === 401) {
    const r = await fetch(`${BASE}/auth/refresh`, {
      method:  'POST',
      headers: { 'Content-Type': 'application/json' },
      body:    JSON.stringify({ refresh_token: refreshToken }),
    })
    if (!r.ok) throw new Error('session ended, sign in again')

    // Both values are replaced. Keeping the old refresh token
    // guarantees a 401 on the next refresh: it is now revoked.
    ;({ token, refresh_token: refreshToken } = await r.json())
    res = await raw(path, init)
  }
  return res
}
client.py
def refresh():
    global token, refresh_token
    r = requests.post(f"{BASE}/auth/refresh",
                      json={"refresh_token": refresh_token},
                      timeout=30)
    if r.status_code != 200:
        raise RuntimeError("session ended, sign in again")

    # Both values are replaced. Keeping the old refresh token
    # guarantees a 401 next time: it is now revoked.
    data          = r.json()
    token         = data["token"]
    refresh_token = data["refresh_token"]
    s.headers["Authorization"] = f"Bearer {token}"
Rotation is how theft becomes visible

Because the presented refresh token is revoked as it is used, a stolen token that the attacker redeems will make the legitimate client's next refresh fail. Store one refresh token, replace it atomically, and never retry a refresh with a value that has already been sent.

Why a request returns 401#

detailMeaning
Invalid email or passwordCredentials rejected at login. Deliberately does not distinguish an unknown account from a wrong password.
User not foundToken decoded, but the account is gone or has been deactivated.
Session ended. Please sign in again.The account ended all sessions after this token was issued. Refreshing will not help, re-authenticate.
Invalid or expired refresh tokenThe refresh token was already used, revoked, or is older than 30 days.

A 403 is a different situation entirely: the session is valid and re-authenticating changes nothing. Those carry a machine-readable code. See error codes.