Skip to content
Developers
OpenAPI
POST /api/v1/oauth/tokenscope: none (anonymous — the credentials themselves are the proof)

Get access token

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

#Get access token

Exchanges a client's client_id/client_secret for a short-lived bearer access token. The first call of every integration, and the only endpoint that follows RFC 6749 instead of problem+json for its errors.

#Headers

Header Required Value
Content-Type yes application/x-www-form-urlencoded
Authorization one of these two Basic base64(client_id + ":" + client_secret) — see Authentication for why no percent-encoding step is needed

#Body (form fields)

Field Required Value
grant_type yes client_credentials
client_id + client_secret only if not using Authorization: Basic sending both the header and the body fields is 400 invalid_request
scope no space-separated subset of the client's scopes; omitted = every scope the client holds

#Request

curl -s -X POST https://sail-club-server.cloud.run/api/v1/oauth/token \
  -u "scc_3f9a1c0d2b7e4a6f8c1d2e3f:scs_q0vXexampleSecretDoNotUseThisOne43chars" \
  -d grant_type=client_credentials \
  -d "scope=events:read results:read"
const creds = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const res = await fetch('https://sail-club-server.cloud.run/api/v1/oauth/token', {
  method: 'POST',
  headers: {
    Authorization: `Basic ${creds}`,
    'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: new URLSearchParams({ grant_type: 'client_credentials', scope: 'events:read results:read' }),
});
if (!res.ok) throw new Error(`token request failed: ${res.status}`);
const token = await res.json();
import requests
from requests.auth import HTTPBasicAuth

res = requests.post(
    "https://sail-club-server.cloud.run/api/v1/oauth/token",
    auth=HTTPBasicAuth(client_id, client_secret),
    data={"grant_type": "client_credentials", "scope": "events:read results:read"},
    timeout=10,
)
res.raise_for_status()
token = res.json()
using var client = new HttpClient { BaseAddress = new Uri("https://sail-club-server.cloud.run") };
var authHeader = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authHeader);
var form = new FormUrlEncodedContent(new Dictionary<string, string> {
    ["grant_type"] = "client_credentials",
    ["scope"] = "events:read results:read",
});
var res = await client.PostAsync("/api/v1/oauth/token", form);
res.EnsureSuccessStatusCode();
var token = await res.Content.ReadFromJsonAsync<TokenResponse>();
<?php
$ch = curl_init('https://sail-club-server.cloud.run/api/v1/oauth/token');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_USERPWD => "$clientId:$clientSecret",
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'grant_type' => 'client_credentials',
        'scope' => 'events:read results:read',
    ]),
]);
$token = json_decode(curl_exec($ch), true);

#Response

{
  "access_token": "sca_5f2e1a9c8d7b6f4e3a2c1d0e9f8a7b6c5d4e3f2a1b0c",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "events:read results:read"
}

Cache-Control: no-store — never cache this response. The token is opaque (do not attempt to decode it); treat it as a random string valid for expires_in seconds.

#Response fields

Field Type Note
access_token string pass as Authorization: Bearer <access_token> on data calls
token_type string always "Bearer"
expires_in int seconds from now; always 3600 today, but read the field
scope string the token's actual (possibly narrowed) scopes, space-separated

#Errors

RFC 6749 style — an error field, not type/status; no error_description. Our own machine code is included alongside for exact branching:

HTTP error code
400 invalid_request API_TOKEN_REQUEST_INVALID
400 unsupported_grant_type API_GRANT_TYPE_UNSUPPORTED
401 invalid_client API_CLIENT_CREDENTIALS_INVALID
400 unauthorized_client API_CLIENT_DISABLED / API_CLIENT_EXPIRED / API_IP_NOT_ALLOWED
400 invalid_scope API_SCOPE_INVALID (+ params.scope)
429 rate_limited RATE_LIMIT_EXCEEDED (+ Retry-After: 60)

A 401 with invalid_client also carries WWW-Authenticate: Basic realm="sailclub-api" when Basic auth was used (or when no credentials were sent at all).

#Caching

Never cache a token response (Cache-Control: no-store). Cache the token itself, in your process's memory, for its expires_in — see Authentication.

Prompt for AI agents ready to paste into an agent
Implement a getAccessToken() function for the Sail Club API (OAuth2 client credentials, RFC 6749 §4.4).

POST https://sail-club-server.cloud.run/api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id + ":" + client_secret)   (read client_id/client_secret from env vars)
Body: grant_type=client_credentials

Success (200, Cache-Control: no-store):
  { "access_token": string, "token_type": "Bearer", "expires_in": number, "scope": string }
Treat access_token as opaque — do not decode or parse it.

Failure: RFC 6749 error body { "error": "invalid_request"|"unsupported_grant_type"|"invalid_client"|
  "unauthorized_client"|"invalid_scope"|"rate_limited", ... } plus a "code" field with our machine code
  (API_TOKEN_REQUEST_INVALID, API_GRANT_TYPE_UNSUPPORTED, API_CLIENT_CREDENTIALS_INVALID,
  API_CLIENT_DISABLED, API_CLIENT_EXPIRED, API_IP_NOT_ALLOWED, API_SCOPE_INVALID, RATE_LIMIT_EXCEEDED).
  On 429, read Retry-After (seconds) and wait that long before retrying. On any other failure, do not retry
  in a loop — surface it, since it usually means the credential or its scopes need a club-admin fix.

Requirements:
  - Cache the token in memory: { access_token, expiresAt: now + expires_in * 1000 - 60000 } (60s safety
    margin). Reuse the cached token while expiresAt is in the future; fetch a new one otherwise.
  - Never fetch a new token per outgoing API call.
  - A minimal test: two calls to getAccessToken() within the same expiry window make exactly one HTTP
    request to the token endpoint.

Adapt to your actual stack (Node/TypeScript, Python, C#/.NET, PHP, ...) — the endpoint, headers, body,
caching rule and error codes above are language-neutral.