1. Docs
  2. API Keys

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.view Read the Environment's API keys.
Context
Select an Application and an Environment in the switcher first; the page belongs to that Environment.
In the Developer Console, open Access Control → Integrations → API Keys and click Create API Key. The Create API Key wizard opens on its Details step.Step 1, Details. Enter a Name (required, e.g. CI/CD pipeline) and an optional Description. Pick an Expiration: None, 30, 60 or 90 days, or Custom with a date of your own.Step 2, Permissions. Tick the scopes the key needs, grouped by category. Leave them all unchecked for full access within the Environment: the form then sends access_mode: "full_access" with no scopes, and any ticked scopes send access_mode: "scoped" with that list.Step 3, Review. Confirm the name, description, expiration, and selected permissions. Click Create API Key to submit.A credentials screen appears with the plaintext key. Click Copy and paste it into your secrets manager before clicking Done. The key is shown only once, so closing the dialog without copying means deleting the key and starting over.
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:

API Keys is not under Integrations in the left nav. Either no Application and Environment is selected yet, or your administrator role does not carry api_keys.view.The list shows but there is no Create API Key, and the drawer offers no Deactivate, Rotate key, or Delete. You hold api_keys.view but not api_keys.manage.The key you created is not listed. Keys belong to one Environment. Check the Environment in the switcher; a key created in Development never authenticates against Production.Access Control → Integrations → Overview is missing but the tables are there. The hub is create-flow cards, so it shows only for someone holding a manage capability on at least one integration surface.

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-keys

Auth: 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-keys

Auth: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/:id

Auth: 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 Content

Both 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-secret

Auth: 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
activeis_active: true AND no expiry OR expiry in the future. Authentication succeeds. last_used_at updates on every successful request.
inactiveis_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.)
expiredis_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.
403Authenticated 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_exceededThe Account's plan allows no more active keys this month. The error's data names the limit and how many are in use.
429More 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.

Plaintext is shown only once. Capture key from the create response before you return: there's no recovery flow. The one exception is a retry with the same Idempotency-Key within 24 hours, which replays the same response; the same key with a different request body is refused with 422.Can't combine X-API-Key with Authorization. Sending both returns 400.Deactivating keeps the row, deleting removes it. Deactivation only flips is_active and can be reversed; a deleted key is gone, and only the audit log remembers it.Expired keys still exist. Expiry is checked at use time, not on a cron, so the row stays is_active: true but auth fails.last_used_at updates fire-and-forget. The value can lag a few seconds under load, so don't rely on it for sub-second precision.Access mode is explicit. access_mode: "full_access" takes no scopes; access_mode: "scoped" requires a non-empty list, each one a scope the platform offers, as listed on the create operation. Scopes are exact: identity.manage does not include identity.view, so a key that reads and writes lists both.Public-API create is rate-limited to 20 requests per minute. It is counted per signed-in user for a Console token and per client address for an API key. Avoid hot-loop creating keys in tests; stub the call out instead.Names aren't unique. Use {purpose}-{env} patterns (e.g. ci-prod, analytics-staging) to keep them readable in the list.
Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.