# Restricted Keys (Scopes)

> Limit an API key to specific resource:action scopes

Source: https://docs.graph8.com/developers/key-scopes/

Restrict an API key to a subset of the API with **scopes**. A key with **no scopes** keeps working on normal read and write routes, so every existing key keeps working and scopes are opt-in. It is **not** unrestricted everywhere, though: some billable, destructive and external routes already need an **explicit** scope, so an unscoped organization key gets a 403 there, and once strict scope enforcement is switched on (`enforcement.scopes_strict` in `/v1/me`) all of them do. An unscoped personal key is judged by its owner's role in graph8 instead (webhooks still need an explicit scope).

## Check what a key can do

`GET /v1/me` describes the calling key. Read these fields before you rely on a key:

| Field | Meaning |
|-------|---------|
| `scopes` | The key's scope list (empty = no list) |
| `key_type` | `org` or `personal` |
| `unscoped_access` | For a key with no scopes: `non_strict_only` (org key: normal reads and writes; billable, destructive and external calls are refused as `strict_requires_explicit` describes) or `owner_role` (personal key). `null` for a scoped key |
| `strict_requires_explicit` | `true` when the strict gates need an explicit scope in `scopes` for this key. Some routes apply this now; all billable, destructive and external routes apply it once `enforcement.scopes_strict` is `true` |
| `strict_tiers` | The tiers that rule covers |
| `unrestricted` | Legacy: `true` when the key has no scope list. Prefer `unscoped_access` |
| `enforcement` | Which scope gates are switched on right now |

`npx @graph8/sdk doctor` reads these fields and fails when your blocks need a scope the key will be refused.

| Scope | Grants |
|-------|--------|
| `*` | Everything |
| `contacts:*` | Every action on contacts |
| `contacts:read` | Exactly contacts read |

## Mint a restricted key

Pass `scopes` to `POST /v1/api-keys`. The scopes are stored on the key (authoritative) and returned in the response.

**curl**

```bash
curl -X POST https://be.graph8.com/v1/api-keys \
  -H "Authorization: Bearer $G8_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "read-only usage", "scopes": ["usage:read", "contacts:read"]}'
# => { "api_key_id": "...", "api_key_token": "g8_live_...", "scopes": ["usage:read","contacts:read"], ... }
```

**Python**

```python
import httpx

r = httpx.post(
    "https://be.graph8.com/v1/api-keys",
    headers={"Authorization": f"Bearer {api_key}"},
    json={"name": "read-only usage", "scopes": ["usage:read", "contacts:read"]},
)
```

**TypeScript**

```ts
await fetch("https://be.graph8.com/v1/api-keys", {
  method: "POST",
  headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
  body: JSON.stringify({ name: "read-only usage", scopes: ["usage:read", "contacts:read"] }),
});
```

## Enforcement

A request to an endpoint whose required scope the key lacks returns **`403`** with `{ "type": "forbidden", "code": "403", ... }`. An unscoped key passes every check.

> **Note: Soak rollout**
> Scope enforcement ships in **soak mode** first — a missing scope is **logged** (server-side) but the request is **allowed**, so you can mint scoped keys and watch the logs before flipping on enforcement (`ENFORCE_DEVAPI_SCOPES=true`). The scope coverage of endpoints expands per release.
