Smart Agent Teams

Authentication

Sessions with access and refresh tokens, personal API keys, and how to send and renew them.

Every SAT API call except sign-in, registration, token refresh, password reset and /health needs a bearer credential. There are two kinds. A session starts with an email and password and gives you a short-lived access token plus a refresh token; the web app and an interactive sat use sessions. A personal API key is a long-lived credential for scripts, CI and runners. Both authenticate as a person, and company endpoints then check that this person is a member of the company.

Send the credential

Authorization: Bearer <access token or sat_live_ API key>

The API tells the two apart by the sat_live_ (or sat_test_) prefix. Everything else is treated as an access token. The live event stream uses the same header; see Live events.

ProblemAnswer
No Authorization header, or not Bearer401 AUTH_REQUIRED, message Not authenticated
Expired, malformed or revoked credential401 INVALID_TOKEN, message Could not validate credentials
Valid credential, but not a member of the company404 NOT_FOUND (Company not found)

Sessions

Register

curl -sS -X POST "$SAT_API_URL/api/register" -H "Content-Type: application/json" \
  -d '{"email": "ada@noteflow.example", "password": "a-long-passphrase", "name": "Ada Lovelace"}'

email must be a valid address, password 8 to 128 characters (the same rule as a password reset and the web app's forms), name 1 to 255 characters; company is optional. The answer is 201 with user, access_token and refresh_token, so the new user is signed in. An email already registered answers 409 USER_EXISTS. There is no email verification.

Sign in

curl -sS -X POST "$SAT_API_URL/api/login" -H "Content-Type: application/json" \
  -d '{"email": "ada@noteflow.example", "password": "a-long-passphrase"}'
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": {"id": "0f8e...", "email": "ada@noteflow.example", "name": "Ada Lovelace", "created_at": "2026-09-01T08:00:00+00:00", "avatar_url": null, "preferences": {}}
}
ErrorStatusWhen
INVALID_CREDENTIALS401Unknown email or wrong password
ACCOUNT_LOCKED400The account is locked after failed attempts (see below)
ACCOUNT_INACTIVE400The account is deactivated

Sign in with Google

GET /api/auth/config returns {"google_client_id": "<id>" | null}; no credential needed. When it is not null, get a Google ID token with Google Identity Services for that client ID and exchange it:

curl -s -X POST "$SAT_API_URL/api/login/google" \
  -H 'Content-Type: application/json' \
  -d '{"credential": "<Google ID token>"}'

The answer has the same shape as POST /api/login (access_token, refresh_token, user). The token must be signed by Google, issued for the configured client ID, unexpired, and carry a verified email; the account with that email is signed in, or created. Errors: 401 GOOGLE_SIGN_IN_FAILED (token rejected), 404 GOOGLE_DISABLED (no client ID configured), 429 RATE_LIMITED (10 a minute per IP). See Sign in with Google.

Refresh

curl -sS -X POST "$SAT_API_URL/api/refresh" -H "Content-Type: application/json" \
  -d "{\"refresh_token\": \"$REFRESH_TOKEN\"}"

The answer is {"access_token": "..."}. The refresh token is not rotated: keep using the same one until it expires, 7 days after sign-in. Then the user must sign in again. A refresh token that is expired, revoked by sign-out or a password reset, or malformed answers 401 INVALID_REFRESH_TOKEN.

Sign out

POST /api/logout needs a session access token in the Authorization header. An API key is refused with 403 SESSION_REQUIRED, so an unattended key holder such as an agent cannot end its owner's sessions.

BodyEffect
{"refresh_token": "..."}Ends only that session: one browser or one CLI profile. This is what the web app and sat logout send
No bodyEnds every session of the user, browsers included. This is sat logout --everywhere

Signing out revokes refresh tokens. Access tokens are not revocable: one already issued stays valid until it expires, up to 30 minutes later.

The current user

CallPurpose
GET /api/meid, email, name, created_at, avatar_url, preferences
PATCH /api/meChange name or avatar_url (an https:// URL; null clears it)
PATCH /api/me/preferencesMerge a partial preferences update: objects merge key by key, lists replace, null removes a setting

Tokens

Access and refresh tokens are JWTs signed with HS256 using the API's JWT_SECRET_KEY. Without that variable the API falls back to a built-in development secret only when APP_ENV (or, if unset, ENVIRONMENT) is development, dev, local or test, ignoring case; with any other value it refuses to start. See Configuration.

Access tokenRefresh token
Lifetime30 minutes7 days
Claimssub (user id), type: "access", iat, expsub, type: "refresh", iat, exp, jti (unique id)
Stored by the serverNoYes, as a SHA-256 hash, so it can be revoked
Accepted as a bearer credentialYesNo

Treat tokens as opaque. Read the user from GET /api/me rather than from the token.

Account lockout

Five wrong passwords in a row lock the account for 30 minutes. The fifth attempt still answers 401 INVALID_CREDENTIALS; from then on, every sign-in answers 400 ACCOUNT_LOCKED, even with the correct password, until the lock expires.

  • A successful sign-in resets the failure count.
  • When a lock has expired, the next sign-in attempt starts the count again from zero, so it takes another five wrong passwords to lock the account again.
  • A password reset clears the lock and the count.

Sign-in has no per-IP rate limit; lockout is the only throttle.

Password reset

POST /api/password/forgot with {"email": "..."} always answers 202 with the same message, whether or not the email has an account. If it does, the user gets an email with a link that works once and expires after 30 minutes. Limited to 5 requests per minute per IP address.

Set the new password

POST /api/password/reset with {"token": "...", "password": "..."}. The new password must be 8 to 128 characters. An invalid, used or expired token answers 400 INVALID_RESET_TOKEN. Limited to 10 requests per minute per IP address.

A reset ends every credential issued before it: it revokes all refresh tokens, access tokens issued before the change stop working, and API keys created before the change answer 401 INVALID_TOKEN from then on. Create new keys after the reset for your runners and scripts. The reset also revokes those keys, so they leave the key list and stop counting toward the 20-key limit and the name check.

The supported API has no change-password endpoint for a signed-in user yet. Use the reset flow.

API keys

A personal API key authenticates exactly like an access token, as the member who created it, but it does not expire unless you ask. Use one for anything unattended: runners, CI jobs, scripts.

PropertyValue
Formatsat_live_ followed by 32 URL-safe characters, 41 characters in all
StoredOnly the SHA-256 hash. The key is shown once, in the create response
prefixThe first 13 characters, for example sat_live_Krwo, to recognize a key in lists
ExpiryNone by default. expires_in_days from 1 to 3650 sets one
last_usedUpdated at most once a minute
Limit20 active keys per user; names unique among active keys. Active means not revoked and not expired
ScopeEverything the member can do, in every company they belong to
sat apikey create ci-runner --expires-in-days 90   # printed once
sat apikey create grace-laptop-runner --store      # also store it for this profile
sat apikey list
sat apikey revoke ci-runner

The create response is 201:

{
  "id": "5e1c...",
  "name": "ci-runner",
  "prefix": "sat_live_Krwo",
  "created_at": "2026-10-05T10:00:00+00:00",
  "last_used": null,
  "expires_at": "2027-01-03T10:00:00+00:00",
  "key": "sat_live_Krwo..."
}

List returns the same fields without key, newest first, active keys only: revoked and expired keys are left out.

ErrorStatusWhen
SESSION_REQUIRED403Creating or revoking with an API key. Sign in with a password to manage keys. The legacy /api/users/{username}/api-keys routes refuse API keys the same way
API_KEY_EXISTS409An active key already has this name. A revoked or expired key's name can be reused
API_KEY_LIMIT400The user already has 20 active keys. Revoked and expired keys do not count
NOT_FOUND404Revoking a key that does not exist, is already revoked or expired, or belongs to someone else

Revocation takes effect on the next request. A key also stops working when it expires, when the user is deactivated, or when the user completes a password reset after the key was created.

Refresh on 401

Access tokens last 30 minutes, so a long-lived client will see 401s. Handle them the way @sat/api-client does:

  1. Send the request with the current access token.
  2. On 401, refresh. Share one refresh among all requests that failed at the same time, so a burst of 401s causes one POST /api/refresh.
  3. If the refresh succeeded, store the new access token and retry the request once.
  4. If the refresh failed, or the retry is still 401, drop the tokens and send the user to sign in.
authed-fetch.ts
let refreshing: Promise<boolean> | null = null;

async function refresh(): Promise<boolean> {
  const refreshToken = store.getRefreshToken();
  if (!refreshToken) return false;
  refreshing ??= fetch(`${baseUrl}/api/refresh`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refresh_token: refreshToken }),
  })
    .then(async (res) => {
      if (!res.ok) return false;
      const body = await res.json();
      store.setTokens(body.access_token, body.refresh_token); // refresh_token is not returned today
      return true;
    })
    .catch(() => false)
    .finally(() => {
      refreshing = null;
    });
  return refreshing;
}

export async function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
  const send = () => {
    const headers = new Headers(init.headers);
    const token = store.getAccessToken();
    if (token) headers.set('Authorization', `Bearer ${token}`);
    return fetch(`${baseUrl}${path}`, { ...init, headers });
  };
  let res = await send();
  if (res.status === 401 && (await refresh())) res = await send();
  if (res.status === 401) {
    store.clear();
    onUnauthorized(); // for example, redirect to /login
  }
  return res;
}

In TypeScript, createApiClient from @sat/api-client already does this; see Typed client. With an API key there is nothing to refresh: a 401 means the key was revoked, expired, or created before the owner's last password reset.

Security notes

  • Keep refresh tokens and API keys secret. Either one gives full access as the user until it is revoked or expires.
  • Sign-out does not cut off an access token already issued; it expires within 30 minutes.
  • A password reset ends API keys too. Keys created before the reset stop working; create new ones afterwards.
  • API keys are not scoped. A key can do anything its owner can, in every company. Give each machine its own key and use expires_in_days.
  • Some actions need a session. An API key gets 403 SESSION_REQUIRED when it tries to create or revoke keys (through /api/me/api-keys or the legacy /api/users/{username}/api-keys routes) or to sign out (POST /api/logout). Everything else accepts a key, including PATCH /api/me and PATCH /api/me/preferences. See Runner security.

On this page