Get access token
#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.