# 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](/docs/authentication#a-note-on-encoding) 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

```bash
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"
```

```javascript
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();
```

```python
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()
```

```csharp
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
<?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

```json
{
  "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`](/docs/errors#API_TOKEN_REQUEST_INVALID) |
| 400 | `unsupported_grant_type` | [`API_GRANT_TYPE_UNSUPPORTED`](/docs/errors#API_GRANT_TYPE_UNSUPPORTED) |
| 401 | `invalid_client` | [`API_CLIENT_CREDENTIALS_INVALID`](/docs/errors#API_CLIENT_CREDENTIALS_INVALID) |
| 400 | `unauthorized_client` | [`API_CLIENT_DISABLED`](/docs/errors#API_CLIENT_DISABLED) / [`API_CLIENT_EXPIRED`](/docs/errors#API_CLIENT_EXPIRED) / [`API_IP_NOT_ALLOWED`](/docs/errors#API_IP_NOT_ALLOWED) |
| 400 | `invalid_scope` | [`API_SCOPE_INVALID`](/docs/errors#API_SCOPE_INVALID) (+ `params.scope`) |
| 429 | `rate_limited` | [`RATE_LIMIT_EXCEEDED`](/docs/errors#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](/docs/authentication#refresh-before-expiry-pattern).

```prompt
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.
```
