Skip to content
Developers
OpenAPI

Authentication

API v1 Last updated 2026-09-30 View as Markdown

#Authentication

The API uses OAuth 2.0 Client Credentials (RFC 6749 §4.4) — the standard machine-to-machine grant every HTTP library and OAuth SDK already supports. There is no user login, no redirect flow, no refresh token.

#The two credentials

Credential Looks like Lifetime Who has it
client_id scc_3f9a1c0d2b7e4a6f8c1d2e3f until the client is deleted public — identifies the client, not a secret itself
client_secret scs_q0vX… (43 chars) until rotated secret — shown once at creation/rotation, stored at our end only as a salted hash
access token sca_… (43 chars) 1 hour your server, in memory — never persisted, never logged

Losing the client_secret? It cannot be retrieved — ask the club admin to rotate it (Settings → API access → the client → Rotate secret). Rotation immediately invalidates the old secret and every access token issued from it.

#A note on encoding

client_id/client_secret only ever use the base64url alphabet (letters, digits, -, _) plus their fixed scc_/scs_ prefix — they never contain a : or any character that needs percent-encoding. Every sample on this site sends them as-is, client_id:client_secret, base64-encoded for the Authorization: Basic header; you do not need to URL-encode either value first.

#Getting a token

POST /api/v1/oauth/token — see the full reference: Get access token. Two equivalent ways to send the credentials: HTTP Basic (recommended — never appears in a request body log), or as client_id/client_secret form fields. Sending both at once is a 400 API_TOKEN_REQUEST_INVALID.

curl -s -X POST https://sail-club-server.cloud.run/api/v1/oauth/token \
  -u "scc_3f9a…:scs_q0vX…" \
  -d grant_type=client_credentials

The response carries expires_in (always 3600, but read the field — don't hard-code it) and scope (the token's actual scopes, which may be a client asked for a narrower scope= than it holds).

#Refresh-before-expiry pattern

if no cached token OR cached token expires within the next ~60s:
    fetch a new token, cache { access_token, expires_at = now + expires_in }
use the cached access_token on the call
if the call returns 401:
    fetch exactly one new token and retry the call once
    if it still 401s, stop and alert a human — the credential itself is the problem

Don't fetch a token per API call — the token endpoint has its own, tighter rate limit (20/min per client and per caller IP — see Rate limits) and every extra round-trip is latency you don't need, since one token covers an hour of calls.

#Secret handling

#Rotation

The club admin can rotate a secret at any time from the panel. Effect: the old secret stops working for new token requests immediately, and every access token already issued from it stops working within 60 seconds (client state is cached at our end for up to a minute). Build your token refresh path to treat a sudden run of 401s as "credential changed, alert a human, don't just retry forever."

#What a Firebase (panel) token cannot do

The panel's own users authenticate with Firebase ID tokens. Those are a completely separate scheme from this API: a Firebase token is rejected on every /api/v1/* route (401 API_TOKEN_INVALID), and an API access token is rejected everywhere else. If you're building against this API, you should never see or handle a Firebase token at all.