Keys and scopes
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) and GET or
POST /api/graphql (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
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"
}'{ "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:
{ "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.
"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, andGET /api/mereports exactly that inmodels. 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
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
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 }'{ "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
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 for the full body.