> ## Documentation Index
> Fetch the complete documentation index at: https://docs.clickbase.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> The authenticated data endpoints use Passport OAuth bearer tokens (the auth:api guard).

The authenticated data endpoints use **Passport OAuth bearer tokens** (the `auth:api` guard). Send your token on every request:

```http theme={null}
Authorization: Bearer {token}
```

The same tokens authenticate both this REST API and the [MCP connectors](/ai/overview) — one token, two surfaces.

## Creating a token

Create a personal access token either way — both call the same Action:

* In the app, on the **Developers** settings page.
* Over the API, with [`POST /api/tokens`](/api/tokens).

The plaintext token is shown **once**, at creation time, and is never stored or shown again. Copy it immediately.

<CodeGroup>
  ```bash title="curl" theme={null}
  curl -X POST https://clickbase.so/api/tokens \
    -H "Authorization: Bearer {token}" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json" \
    -d '{"name": "Reporting script"}'
  ```

  ```javascript title="JavaScript" theme={null}
  await fetch('https://clickbase.so/api/tokens', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({ name: 'Reporting script' }),
  });
  ```

  ```python title="Python" theme={null}
  import requests

  requests.post(
      'https://clickbase.so/api/tokens',
      json={'name': 'Reporting script'},
      headers={
          'Authorization': f'Bearer {token}',
          'Content-Type': 'application/json',
          'Accept': 'application/json',
      },
  )
  ```
</CodeGroup>

## Team binding

A token is scoped to a single team. Tenancy is resolved by the `LoadTeamFromToken` middleware on every authenticated request:

* **Personal access tokens** (created on the Developers page or via `POST /api/tokens`) are **pinned to the team** they were minted for. That binding is stable — it never follows the creating user's current-team switches. A token created for Team A always acts on Team A.
* **`last_used_at`** is stamped on the token on every authenticated request.
* **Fail-closed on lost membership:** the team is resolved *through* your team memberships. If you no longer belong to the token's team, the request is rejected with `403 Forbidden` (`"This token is bound to a team you no longer belong to."`).

Because the resolved team is written onto the acting user's `currentTeam`, every endpoint scopes its reads and writes through `$team->sites()`. A token can never reach another team's data by passing a foreign id or domain.

## Subscription requirement

The authenticated management and reporting endpoints require the token's team to have an **active subscription**. After team binding, the `EnsureTokenTeamHasActiveSubscription` middleware (`team.subscription.token` on the group in `routes/api.php`) re-checks `Team::subscriptionAccess()->allowsProductAccess()` on every request — the same rule the web app uses.

A team whose trial has expired or that has no subscription is cut off from the whole dashboard, reads and writes alike, with a plain **`402 Payment Required`**:

```json theme={null}
{
    "message": "An active subscription is required to access this team."
}
```

The public tracking ingestion beacons (`/api/collect`, `/api/event`, `/api/identify`, `/api/engage`, `/api/session-replay/record`, `/api/tracking-config/{trackingKey}`) and the Stripe webhook are **exempt** — they authenticate per-site (or not at all) and never pass through this middleware, so data keeps flowing even while billing lapses. The Payments API is **not** exempt: it requires an active subscription like every other authenticated endpoint.

## Site allowlist

When you create a personal access token — on the **Developers** settings page or via [`POST /api/tokens`](/api/tokens) — you can optionally limit it to specific sites with `site_ids` (an array of site UUIDs). Omit the field, pass `null`, or pass `["*"]` to allow all sites in the workspace. A token with a non-null allowlist can only reach those sites; a foreign site returns `404 Not Found`, same as a site outside your workspace. See [API tokens](/api/tokens) for the create payload.

## Failure modes

| Condition                                            | Status                     | Detail                                                                                                  |
| ---------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------- |
| Missing / invalid token                              | `401 Unauthorized`         | The `auth:api` guard rejects the request.                                                               |
| Token bound to a team you left                       | `403 Forbidden`            | Membership is re-checked on every request (`"This token is bound to a team you no longer belong to."`). |
| Team without an active subscription                  | `402 Payment Required`     | `"An active subscription is required to access this team."`                                             |
| Your account has no active team                      | `422 Unprocessable Entity` | `"Your account has no active team. Create a team before using the API."`                                |
| Site not in your team (foreign or unknown id/domain) | `404 Not Found`            | Sites are looked up via `$team->sites()`.                                                               |

## Rate limiting

The whole authenticated data group is throttled at **`60` requests per minute** (`throttle:60,1`) per token. Exceeding it returns `429 Too Many Requests`. A few reads carry an additional dedicated bucket on top of the group limit (search-console keyword lookups isolate Google's quota). The tracking ingestion and Stripe webhook endpoints have their own, separate limits.

## Example

<CodeGroup>
  ```bash title="curl" theme={null}
  curl https://clickbase.so/api/sites \
    -H "Authorization: Bearer {token}" \
    -H "Accept: application/json"
  ```

  ```javascript title="JavaScript" theme={null}
  await fetch('https://clickbase.so/api/sites', {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/json',
    },
  });
  ```

  ```python title="Python" theme={null}
  import requests

  requests.get(
      'https://clickbase.so/api/sites',
      headers={'Authorization': f'Bearer {token}', 'Accept': 'application/json'},
  )
  ```
</CodeGroup>
