# Keys and scopes

Source: https://capacms.com/docs/api/authentication

The two key families, minting a scoped key, every scope, presets, rotation, expiry and allowed origins.

There are two families of key, and which one you want depends on which surface
you are calling.

| Family | Looks like                                                                       | Works on                                                                 | Carries scopes                |
| ------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------- |
| Legacy | `pk_…` (production), `sk_…` (other environments), or an older key with no prefix | `/v2/api`, `/v3/api`, `/v2/schema`, `/v2/seo`, `/v2/agent/*` and `/api/` | no. One of four fixed presets |
| Scoped | `cap_live_…` (production), `cap_test_…` (other environments)                     | `/api/` only                                                             | yes                           |

**Your existing key keeps working, unchanged, everywhere it works today.**
Nothing in this page alters a legacy key. It also reaches `/api/`, so you can
point a new page at the new surface without minting anything. A key minted
before the prefixes existed is 40 hexadecimal characters with no prefix. It is
a legacy key like any other, and its environment is the one set on it.

Mint a `cap_` key when you want a key that does less than your current one:
one model, reads only, no deletes. That is the reason the family exists.

## What a scoped key can do today

Read this before you mint one. Today `/api/` reads and never writes. **A
`cap_` key holding `instance:read` reads entries**, with
`GET /api/entries/<model>` ([Entries](https://capacms.com/docs/api/entries)) and `GET` or
`POST /api/graphql` ([GraphQL](https://capacms.com/docs/api/graphql)). `GET /api/me` answers any
key, and `GET /api/versions` needs none. There are no writes anywhere on `/api/`:
`writes.enabled` in `/api/me` is `false` for every key, and a write method
under `/api/` is refused. A `cap_` key is also refused on `/v2/agent`, the
legacy write surface, for the reason in the next section.

So a write scope you grant today is stored, audited and enforced from the day
the route it unlocks exists, and does nothing before then. **Keep your legacy
key for every write and for anything that calls `/v2` or `/v3`.** Mint a
`cap_` key to read on `/api/`, and move the rest of your traffic onto it route
by route as the routes land. Apart from `instance:read`, each "Unlocks" column
below describes the route that scope will gate, not a route you can call this
week.

## Why a scoped key is refused on the legacy surface

A `cap_` key sent to `/v2/api`, `/v3/api`, `/v2/schema`, `/v2/seo` or
`/v2/agent/*` is refused with the same `401 {"error":"Invalid API key"}` an
unknown key gets. That is on purpose.

The legacy read routes do not consult scopes. If they accepted a scoped key
they would serve it everything, and a restriction you could see in the admin
would be quietly ignored on the surface most of your traffic uses. One
enforcement point, or none.

So a project that is half ported runs two keys: the legacy key its live site
already uses, and a `cap_` key for the new code. `GET /api/me` shows which
surfaces the key in your hand is accepted on.

## In the admin

Developers > Keys does everything on this page without curl.

**New key** opens the create dialog: a name, the environment, a starting point
(Read only, Content writer, Full integration, Schema builder) or Custom with
the groups of this page as checkboxes, a "Limit to models" picker on Entries
and Models, and an expiry. A preset that needs a scope you do not hold yourself
is greyed out and says which scope, rather than letting the mint refuse it
afterwards. Deletes sit under their own heading. Content deletes (entries,
media) are in no preset but Full integration; Schema builder carries
`type:delete` and `category:delete`, because a schema it cannot tidy is a
schema it cannot change; `model:delete` is in no preset at all.

**The secret is shown once**, in a copy field, right where the form was. It is
stored hashed, there is no reveal route, and the admin never writes it to a
log, a URL or browser storage. If you lose it, rotate.

**The list** shows each key's grants as chips, the surface it works on, when it
expires and whether it is active. Its Last used column is the key's
`lastUsedAt`, which `/api/me` reports too. It is recorded when the key
authenticates a request on any surface (`/api/`, `/v2/api`, `/v3/api` and the
other keyed routes), at most once a minute per key, so it can be up to a minute
behind. A refused request does not count. Never means the key has not been used
since this was switched on. A legacy row shows
what its preset grants, marked `Legacy`, and its row menu offers **Create cap\_
key with these grants**, which opens the create dialog prefilled from that
preset.

**Rotate** is in the row menu. Pick how long the current key keeps working
(Now, 1 hour, 24 hours, 7 days), and the new secret appears with how long the
old one has left: hours inside two days, days beyond that, and "The old key has
stopped working" for Now. **Edit** changes the name, the scopes and the expiry.
**Deactivate** is how a key is revoked; the row can be reactivated.

One thing the dialog will not edit: a key minted here with per-scope model
limits inside a single group, which only curl can do. The editor has one "Limit
to models" control per group, so that group is shown read-only, listing each
scope and the model it is limited to, and its grants are saved back exactly as
they were minted.

The query explorer under Developers > Queries names the key it is about to run
as, and says plainly that a `cap_` key is refused there, because it calls
`/v2`. The GraphQL Explorer under Developers > GraphQL calls `/api/` and runs
with a `cap_` key.

## Minting a scoped key

```bash
curl -X POST https://api.capacms.com/v2/tenants/api-keys \
  -H 'Authorization: Bearer <your session token>' \
  -H 'content-type: application/json' \
  -d '{
        "environment": "production",
        "name": "Shopify sync",
        "scopes": ["instance:read", "model:read", "instance:update:<modelId>"],
        "expiresAt": "2027-01-01T00:00:00.000Z",
        "apiVersion": "2026-10-01"
      }'
```

```json
{ "id": "…00a7",
  "name": "Shopify sync",
  "apiKey": "cap_live_EXAMPLE",
  "keyPrefix": "cap_live_EXAM",
  "environment": "production",
  "scopes": ["instance:read", "model:read", "instance:update:…0003"],
  "version": "2026-10-01",
  "contract": 1,
  "expiresAt": "2027-01-01T00:00:00.000Z" }
```

**`apiKey` is shown once.** It is stored as a SHA-256 hash and there is no
route that reveals it again. Copy it into your secret store before you close
the terminal. If you lose it, rotate the key.

| Field         | Required                     | Notes                                                                                                                         |
| ------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `environment` | no, defaults to `production` | `production` reads published entries only. Anything else also reads drafts                                                    |
| `name`        | no                           | up to 120 characters. It is what the keys list shows, so name it after the system that holds it                               |
| `scopes`      | to get a `cap_` key          | 1 to 100 grant strings. Sending this field is what makes the key a scoped one                                                 |
| `expiresAt`   | no                           | ISO 8601, at least one hour out. Omit for a key that never expires                                                            |
| `apiVersion`  | no                           | a supported date. Omit and the key is pinned to the newest version at the moment it is minted; the pin never moves on its own |
| `contractId`  | no                           | refused until contracts exist. Omit it                                                                                        |

Omit `scopes` entirely and you get today's legacy key with today's body, which
is what every existing integration and script already does.

### What you can grant

You cannot grant a scope you do not hold yourself. If you are denied
`instance:update` on one model, you cannot mint a key that holds
`instance:update` on every model; you can mint one restricted to a model you
are allowed to write. The check runs once, when the key is minted, and the
answer names the scope:

```json
{ "error": "Cannot grant a scope you do not hold", "scope": "model:create" }
```

## The scope list

A scope is `resource:action`, or `resource:action:<modelId>` to limit it to one
model. It is the same grammar the admin uses for people, so what you read in
`/api/me` is what is stored and what appears in the audit log.

### Read

Exactly what a legacy `read` key can do, so this group on its own is a
like-for-like replacement for one.

| Scope            | Unlocks                            |
| ---------------- | ---------------------------------- |
| `instance:read`  | reading entries                    |
| `model:read`     | reading models and the schema      |
| `media:read`     | reading media and folders          |
| `search:read`    | search                             |
| `category:read`  | reading categories                 |
| `type:read`      | reading types                      |
| `workspace:read` | reading workspaces and their nodes |

### Entries

| Scope              | Unlocks                                                  |
| ------------------ | -------------------------------------------------------- |
| `instance:create`  | creating entries, and the create half of upsert          |
| `instance:update`  | editing entries, and scheduled edits                     |
| `instance:publish` | publishing and unpublishing, including scheduled publish |
| `instance:delete`  | deleting entries, and bulk delete                        |

Delete is never implied by create or update. A key that imports a catalogue
nightly does not need it.

### Models

| Scope           | Unlocks                                                                           |
| --------------- | --------------------------------------------------------------------------------- |
| `model:create`  | creating a model                                                                  |
| `model:update`  | editing a model, its fields and its layout; starting and editing a draft contract |
| `model:publish` | publishing a model version, and publishing or deprecating a contract              |
| `model:delete`  | deleting a model, and retiring a contract                                         |

Deleting a model empties it, so the route also requires you to type the model's
namespace back in the request body.

### Media

| Scope          | Unlocks                                |
| -------------- | -------------------------------------- |
| `media:create` | requesting an upload and finalising it |
| `media:update` | editing metadata and folders           |
| `media:delete` | deleting media                         |

### Categories, types, workspaces

| Scope                                                      | Unlocks                    |
| ---------------------------------------------------------- | -------------------------- |
| `category:create`, `category:update`, `category:delete`    | the categories routes      |
| `type:create`, `type:update`, `type:delete`                | the schema types routes    |
| `workspace:create`, `workspace:update`, `workspace:delete` | workspaces and their nodes |

A key never sets a project's default workspace and never assigns a workspace to
a person. Those are account administration, not content, so they are not
reachable by any key.

### Webhooks

| Scope            | Unlocks                                                 |
| ---------------- | ------------------------------------------------------- |
| `webhook:read`   | listing endpoints and deliveries                        |
| `webhook:create` | registering an endpoint                                 |
| `webhook:update` | editing, pausing, resuming, rotating the signing secret |
| `webhook:delete` | removing an endpoint                                    |

`webhook:create` is in no preset, and it is worth knowing why: a key that can
register an endpoint receives every future publish at a URL of its choosing.
Grant it to the one integration that needs it and to nothing else. The signing
secret is returned once at create time and never revealed again.

### What no key can ever have

Not by omission, by a list in the code with a test behind it:

keys, members, invitations, billing, plan and subscription, project settings,
integrations, secrets and every reveal, cache purge, search reindex, metrics,
saved queries, the legacy job adapter, permission rows.

`*:*` and `*:read` are refused too. A key cannot mint a key, widen itself,
invite a person, change what you pay, or purge your cache.

### Wildcards

`model:*` is accepted on input and stored expanded, as the five model actions.
So is `media:*`, `category:*`, `type:*`, `workspace:*`, `webhook:*` and
`instance:*`. That matters: an action we add to the grammar next year does not
silently appear on a key you minted this year, because the key holds a list,
not a pattern.

`api_key:*` is refused, because that resource has no grantable action at all.

## Limiting a key to one model

Add the model's id as a third segment.

```json
"scopes": ["instance:read", "instance:update:8f2c…0003"]
```

That key reads every model and writes exactly one.

Two things follow, and both are deliberate:

* A scoped grant never satisfies an unscoped request. `instance:update:<A>`
  does not let the key write model B, and the refusal says so:
  `hint: "This key needs instance:update, or instance:update:<B> for products"`.
* Cross-model reads evaluate per model. A key holding only
  `instance:read:<A>` sees a one-model project: search drops the others, the
  schema lists only A, and `GET /api/me` reports exactly that in `models`.
  There is no surface on which the restriction is printed and then ignored.

Use the model **id**, not its namespace. Ids never change; a namespace can be
renamed, and a key that silently followed a rename would be a key that silently
changed what it could write.

## Presets

Starting points, not a separate vocabulary. Each one is a fixed list of the
scopes above.

| Preset           | Scopes                                                                                              |
| ---------------- | --------------------------------------------------------------------------------------------------- |
| Read only        | the Read group                                                                                      |
| Content writer   | Read, plus `instance:create`, `instance:update`, `instance:publish`, `media:create`, `media:update` |
| Full integration | Content writer, plus `instance:delete`, `media:delete`, `category:*`, `workspace:*`                 |
| Schema builder   | Read, plus `model:create`, `model:update`, `model:publish`, `type:*`, `category:*`                  |
| Custom           | anything in the list above that you hold yourself                                                   |

Content writer can create, edit and publish entries and upload files. It cannot
delete anything and cannot change models.

Schema builder can create and change models and publish schema. It cannot
delete models and does not touch entries.

## Editing a key

```bash
curl -X PATCH https://api.capacms.com/v2/tenants/api-keys/<keyId> \
  -H 'Authorization: Bearer <your session token>' \
  -H 'content-type: application/json' \
  -d '{ "name": "Shopify sync (EU)", "scopes": ["instance:read"], "expiresAt": null }'
```

Send any of `name`, `scopes`, `expiresAt`, `apiVersion`. `"expiresAt": null`
clears an expiry. The response is the key's current state, without the secret.

`scopes` on a legacy `pk_` or `sk_` row is refused with
`{"error":"Scopes need a cap_ key. Create one."}`. A legacy key is never
converted in place, for the reason at the top of this page: it would be a key
with scopes printed on it that the legacy surface ignores.

## Rotating a key

```bash
curl -X POST https://api.capacms.com/v2/tenants/api-keys/<keyId>/rotate \
  -H 'Authorization: Bearer <your session token>' \
  -H 'content-type: application/json' \
  -d '{ "graceHours": 24 }'
```

```json
{ "id": "…00b8", "name": "Shopify sync",
  "apiKey": "cap_live_EXAMPLE", "keyPrefix": "cap_live_EXAM",
  "environment": "production", "permission": "scoped",
  "scopes": ["instance:read", "model:read"],
  "version": "2026-10-01", "contract": 1, "expiresAt": null,
  "rotatedFrom": { "id": "…00a7", "expiresAt": "2026-10-02T12:00:00.000Z" } }
```

Rotation mints a **second** key with the same name, scopes, environment,
allowed origins and version pin, and gives the old one an expiry `graceHours`
from now. Both work until then, so you can deploy the new secret without a
window in which neither is valid.

|              |                                                                                                                              |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `graceHours` | integer, 0 to 168. Default 24. `0` retires the old key immediately                                                           |
| Family       | a `cap_` key rotates into a `cap_` key, a legacy key into a legacy key. Your live site's key stays a key its surface accepts |
| Twice        | a key can be rotated once. Rotating the same key again is `409`                                                              |
| After        | the old key answers `401` like any other invalid key                                                                         |

Rotate when a secret has leaked, when someone leaves, and on whatever schedule
your own policy sets. There is no reveal route, so rotation is also the answer
to "I lost it".

## Expiry

`expiresAt` is optional and must be at least one hour out. An expired key is
refused with the same `401` as an unknown key, on `/api/` and on the legacy
surface alike, so an expiry is never an oracle for whether a key existed.

Put an expiry in your own calendar too. Developers > Keys marks a key with a
dot and a line 14 days out, but nothing emails you and your deploy pipeline
would not read the screen in any case.

## Allowed origins

A key can be bound to a list of origins. A server-to-server integration sends
no `Origin` header and passes; a browser sending a different one is refused
with `403 origin_refused`.

If you ship a key to a browser, bind it, and use a production `cap_live_` key
with the Read group only. Never ship a `cap_test_` key: it is a development
key, so anyone who views source reads your drafts and unpublished changes with
it. A write-capable key open to every origin is a write-capable key anyone who
views source can use.

## Checking a key

```bash
export CAPA_KEY=...          # your key, from your secret store. Never in a script.
curl https://cdn.capacms.com/api/me -H "x-api-key: $CAPA_KEY"
```

Everything above is readable back from that one route: the scopes, the models
and actions they resolve to, the surfaces, the version pin, the expiry, and
whether the key was rotated from an older one. Before you debug a `403`, read
it. See [API reference](https://capacms.com/docs/api) for the full body.
