API Keys
Authenticate server-to-server requests with scoped, Environment-bound API keys.
Overview
API keys are long-lived, Environment-scoped credentials your backend uses to call the public API on its own behalf: no user, no session, no OAuth flow. Each key is a 69-character string prefixed with cnpy_; you send it on every request as X-API-Key: cnpy_…. Each key is either full access within its Environment or scoped to a list of permissions, and can be given an optional expiration. Use them for CI scripts, server-side integrations, automation jobs, and anything else that runs without a logged-in user. For end-user authentication, use Hosted Login or Direct API instead: keys aren't appropriate when the principal needs to be a real person.
Prerequisites
Most of these are configured once per Environment. Choose the access mode intentionally: a full-access key can do anything in its Environment.
Create from the Console
The Console is the fastest path for creating a key by hand, useful for one-off integrations or bootstrapping the first key your automation will use. It walks you through a three-step wizard with the same fields as the API, then surfaces the plaintext value in a copy-once dialog.
- Application
- Developer Console
- Path
- Access ControlIntegrationsAPI Keys
- Opens with
api_keys.viewRead the Environment's API keys.
- Context
- Select an Application and an Environment in the switcher first; the page belongs to that Environment.
CI/CD pipeline) and an optional Description. Pick an Expiration: None, 30, 60 or 90 days, or Custom with a date of your own.access_mode: "full_access" with no scopes, and any ticked scopes send access_mode: "scoped" with that list.If you don't see this
Each control on the API Keys page exists only for someone who may use it. When something described above is missing, one of these is why:
After the dialog closes, the new key shows up in the list with its key_preview (first 12 characters + ****), scopes, expires_at, and a last_used_at that stays empty until the key authenticates its first request.
Create via the API
The single endpoint that mints a new key. Returns the plaintext value once and never again, so capture it before doing anything else. Same field set as the Console wizard, just expressed as one POST.
POST /api/v1/api-keysAuth: API key (X-API-Key) OR portal user JWT (Authorization: Bearer). Either way the principal needs api_key.manage in the target Environment. Available on both the public API surface (/api/v1/api-keys) and the portal surface (env-scoped under /portal/v1/accounts/:accountSlug/applications/:appSlug/environments/:envSlug/api-keys); the portal surface is JWT-only.
Body fields
| Field | Required | Default | Description |
|---|---|---|---|
name | Yes | None | Human-readable label shown in the Console list. Helps you identify the key when scrolling through several. Names are not unique: two keys may share one. |
description | No | None | Free-text notes about what the key is used for. Surfaced in the list but not exposed on requests. |
access_mode | Yes | None | scoped limits the key to its scopes; full_access grants everything in the key's Environment and takes no scopes. Prefer scoped. |
scopes | When scoped | None | The scopes the key may use. Required, with at least one, when access_mode is scoped; refused when it is full_access. Each must be a scope the platform offers, as listed on the create operation. |
expires_at | No | None | ISO-8601 timestamp. Past this date, key auth fails with 401 even though is_active is still true. Omit for no expiration. |
Request
curl -X POST https://auth.canopy-io.com/api/v1/api-keys \ -H "X-API-Key: $CANOPY_BOOTSTRAP_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "CI/CD pipeline", "description": "Deploys from main", "access_mode": "scoped", "scopes": ["identity.view", "identity.manage"], "expires_at": "2027-05-03T00:00:00.000Z" }'
Response
{ "data": { "id": "9f4d2b7a-8e1c-4c3f-a2b9-6e5d1c8f4a20", "name": "CI/CD pipeline", "description": "Deploys from main", "key": "cnpy_abcd1234ef5678901234567890abcdef1234567890abcdef1234567890abc", "key_preview": "cnpy_abcd1234****", "access_mode": "scoped", "scopes": ["identity.view", "identity.manage"], "expires_at": "2027-05-03T00:00:00.000Z", "created_at": "2026-05-03T12:00:00.000Z" } }
The key field in the response is the only time the plaintext value is ever exposed. Canopy stores a bcrypt hash and there is no way to retrieve the original later. Capture it into your secrets manager (Vault, AWS Secrets Manager, GitHub Actions secret, etc.) before responding to the API call. If you lose it, rotate the key's secret (or delete it and create a new one). There's no recovery flow for the old value.
Use a key on requests
Every public-API call carries the key in the X-API-Key header. There's no body parameter, no query string, no Authorization header for keys.
Header:
X-API-Key: cnpy_abcd1234...Example request
curl https://auth.canopy-io.com/api/v1/identities \ -H "X-API-Key: $CANOPY_API_KEY"
Don't combine with Authorization. Sending both X-API-Key and Authorization: Bearer … on the same request returns 400: Canopy enforces one auth method per request to avoid ambiguity. Pick one based on whether the call is server-to-server (key) or user-context (JWT).
List & inspect
Pull every key in the Environment for an audit dashboard or stale-credentials report. Plaintext is never returned: only the masked preview and metadata.
GET /api/v1/api-keysAuth:An API key or user JWT with api_key.view; a scoped key needs api_key.view itself, since scopes are exact. It pages with page and take, sorts with sort_by (name or created, newest first by default) and order, and searches names with q.
Response
{ "items": [ { "id": "9f4d2b7a-8e1c-4c3f-a2b9-6e5d1c8f4a20", "client_id": "...", "name": "CI/CD pipeline", "description": "Deploys from main", "key_preview": "cnpy_abcd1234****", "access_mode": "scoped", "scopes": ["identity.view", "identity.manage"], "is_active": true, "last_used_at": "2026-05-03T11:55:00.000Z", "expires_at": "2027-05-03T00:00:00.000Z", "created_at": "2026-05-03T12:00:00.000Z" } ], "pagination": { "page": 1, "take": 20, "item_count": 1, "page_count": 1, "has_previous_page": false, "has_next_page": false } }
last_used_at updates fire-and-forget on every successful authentication, which is perfect for finding stale keys nobody calls anymore. The value can lag a few seconds under heavy load since the update doesn't block the request.
Deactivate & delete
Two ways to retire a key, mirroring OAuth clients and webhooks. Deactivate (PATCH with is_active: false) is reversible: auth fails with 401 immediately, but the row stays and you can flip it back on with is_active: true. Delete (DELETE) is permanent: the row is removed and the key can never authenticate again. Both require api_key.manage; the lifecycle stays in the audit log either way.
PATCH /api/v1/api-keys/:id { "is_active": false }
DELETE /api/v1/api-keys/:idAuth: Same as create: an API key or user JWT with api_key.manage. Available on both the public API surface and the env-scoped portal surface.
# Deactivate (reversible): PATCH returns the updated key
curl -X PATCH https://auth.canopy-io.com/api/v1/api-keys/9f4d2b7a-8e1c-4c3f-a2b9-6e5d1c8f4a20 \
-H "X-API-Key: $CANOPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"is_active": false}'
# Reactivate later
curl -X PATCH https://auth.canopy-io.com/api/v1/api-keys/9f4d2b7a-8e1c-4c3f-a2b9-6e5d1c8f4a20 \
-H "X-API-Key: $CANOPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"is_active": true}'
# Delete (permanent)
curl -X DELETE https://auth.canopy-io.com/api/v1/api-keys/9f4d2b7a-8e1c-4c3f-a2b9-6e5d1c8f4a20 \
-H "X-API-Key: $CANOPY_API_KEY"
# 204 No ContentBoth take effect immediately, with no propagation delay or cache to flush. In-flight requests already past the auth guard complete normally; new requests with the key get 401. Reach for deactivate when you might re-enable the key (or want it to linger as a visible record); reach for delete when you want it gone for good.
Rotate the secret
Swap a key's secret in place: same row (id, name, scopes, expiry, and audit lineage all preserved), brand-new cnpy_… value. The old value stops working immediately and the new plaintext is returned once. Use it to retire an aging or possibly-leaked key without losing its configuration, or as a scheduled-rotation step. (Same model as OAuth-client secret rotation; a webhook secret, by contrast, keeps the replaced value verifying for 24 hours. Rotating does not reactivate a deactivated key; is_active is left as-is.)
POST /api/v1/api-keys/:id/rotate-secretAuth: Same as create: an API key or user JWT with api_key.manage. Available on both the public API surface and the env-scoped portal surface.
curl -X POST https://auth.canopy-io.com/api/v1/api-keys/9f4d2b7a-8e1c-4c3f-a2b9-6e5d1c8f4a20/rotate-secret \ -H "X-API-Key: $CANOPY_API_KEY" # 200 OK: response carries the new plaintext key ONCE: # { # "data": { # "id": "9f4d2b7a-8e1c-4c3f-a2b9-6e5d1c8f4a20", # "key": "cnpy_<new-value-shown-once>", # "key_preview": "cnpy_<first12>****", # "scopes": [...], # ... # } # }
Capture the new key into your secrets manager before doing anything else. Like create, it's the only time the plaintext is exposed. Because the value itself changes, every consumer of the key must be updated; rotation simply lets you do that without recreating the key's identity, scopes, or audit history.
Key lifecycle
Every key is in exactly one state at any moment. The state is computed from is_active and expires_at. You can flip is_active manually by deactivating / reactivating; expiry is automatic.
| State | What it means |
|---|---|
active | is_active: true AND no expiry OR expiry in the future. Authentication succeeds. last_used_at updates on every successful request. |
inactive | is_active: false. Authentication fails with 401. Set by PATCH with is_active: false and reversible: flip it back with is_active: true. (A DELETEd key isn't a state; the row is gone entirely.) |
expired | is_active: true BUT expires_at is in the past. Authentication fails with 401. The expiry check runs at validation time, not on a cron, so the row stays "active" in the database. To extend, create a new key. |
Error responses
Errors use Canopy's standard envelope. Auth-time failures return 401 with a single code, auth.missing_api_key or auth.invalid_api_key, and never say which reason applied (deliberate, so nothing leaks about whether the key existed); management-time failures (create / update / delete) return the standard structured shape.
Sample error response
{ "error": { "statusCode": 409, "code": "plan_limit_exceeded", "message": "Your current plan's limit for this resource has been reached. Upgrade your plan or remove existing items to continue.", "timestamp": "2026-05-03T12:00:00.000Z", "path": "/api/v1/api-keys", "method": "POST" } }
| Status | When it fires |
|---|---|
400 (validation) | Required field missing, malformed expires_at, scopes not an array, a scope the platform does not offer, or scopes present on a full_access key. Per-field details in the response's details array. |
401 (no key) | Missing X-API-Key header on a public-API endpoint. |
401 (invalid key) | Key wasn't found, didn't match, was deactivated or deleted, or has expired. The 401 is intentionally vague: Canopy doesn't tell you which reason. |
400 (header conflict) | Both X-API-Key and Authorization headers were sent. Send one or the other, never both. |
403 | Authenticated but the principal lacks api_key.manage (for create / update / delete) or the scope required by the downstream endpoint (for normal API calls). |
404 (revoke) | The id doesn't match a key in the current Environment. |
409 plan_limit_exceeded | The Account's plan allows no more active keys this month. The error's data names the limit and how many are in use. |
429 | More than 20 create requests per minute from one caller. Wait for the window to pass. |
Gotchas
Things that bite the first time you wire up keys.
Tell us how we can improve this guide.