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_…. Keys can be scoped to a subset of permissions (or left unscoped for full Environment access) and 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 your scopes intentionally — leaving them empty grants the key full Environment access.

Create from the dashboard

The dashboard 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.

Open Dashboard → Integrations → API Keys and click Create.Step 1 — Details. Enter a Name (required, e.g. CI/CD pipeline) and an optional Description. Pick an Expiration from the presets (30 days, 90 days, 1 year, never) or set a custom date.Step 2 — Permissions. Tick the scopes the key needs, grouped by category. Leave them all unchecked for full org access — the form treats no selection the same as scopes: [] on the API.Step 3 — Review. Confirm the name, description, expiration, and selected permissions. Click Create to submit.A credentials screen appears with the plaintext key. Click Copy and paste it into your secrets manager before closing the dialog. The key is shown only once — closing the dialog without copying means revoking and starting over.

After the dialog closes, the new key shows up in the list with its key_preview (first 12 chars + ****), 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 — capture it before doing anything else. Same field set as the dashboard 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 Human-readable label shown in the dashboard list. Helps you identify the key when scrolling through several. The public-API create returns 409 api_keys.name_conflict on duplicates within the same Environment.
description No Free-text notes about what the key is used for. Surfaced in the list but not exposed on requests.
scopes No [] (full access) Array of permission strings restricting what the key can do. Empty array or omitted means full Environment access — the key inherits the Environment's default permission set.
expires_at No 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",
    "scopes": ["identity.manage"],
    "expires_at": "2027-05-03T00:00:00.000Z"
  }'

Response

{
  "data": {
    "id": "ak_01HXABC...",
    "name": "CI/CD pipeline",
    "description": "Deploys from main",
    "key": "cnpy_abcd1234ef5678901234567890abcdef1234567890abcdef1234567890abc",
    "key_preview": "cnpy_abcd1234****",
    "scopes": ["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, revoke the key and create a new one — there's no recovery flow.

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 401 — 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 org for an audit dashboard or stale-credentials report. Plaintext is never returned — only the masked preview and metadata.

GET /api/v1/api-keys

Auth: Same as create — API key or user JWT with api_key.manage. Standard pagination params (page, take, order, order_by) apply.

Response

{
  "items": [
    {
      "id": "ak_01HXABC...",
      "client_id": "...",
      "name": "CI/CD pipeline",
      "description": "Deploys from main",
      "scopes": ["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 — 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.

Revoke

Soft-delete by id. The key is marked is_active: false; subsequent auth attempts fail with 401. The row stays in the database for audit.

DELETE /api/v1/api-keys/:id

Auth: Same as create — API key or user JWT with api_key.manage.

curl -X DELETE https://auth.canopy-io.com/api/v1/api-keys/ak_01HXABC... \
  -H "X-API-Key: $CANOPY_API_KEY"

# 204 No Content

Revocation is immediate — no propagation delay or cache to flush. In-flight requests already past the auth guard complete normally; new requests with the same key get 401. There's no way to reactivate a revoked key; create a new one (with the same scopes if you want a rotation pattern).

Key lifecycle

Every key is in exactly one state at any moment. The state is computed from is_active and expires_at; you don't transition keys manually.

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.
revokedis_active: false. Authentication fails with 401. Set by calling DELETE; not reversible. Row stays in the database for audit.
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 — 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 no body details (deliberate — don't leak whether the key existed); management-time failures (create / revoke) return the standard structured shape.

Sample error response

{
  "error": {
    "statusCode": 409,
    "code": "api_keys.name_conflict",
    "message": "An API key with that name already exists",
    "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, etc. Per-field details in the response's details array.
400 api_keys.invalid_scopeOne or more entries in scopes isn't a valid permission string for your org.
401 (no key)Missing X-API-Key header on a public-API endpoint.
401 (invalid key)Key wasn't found, didn't match, was revoked, or has expired. The 401 is intentionally vague — Canopy doesn't tell you which reason.
401 (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 / revoke) 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 api_keys.name_conflictAnother active key in this Environment already has the requested name. Choose a different name.

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.Can't combine X-API-Key with Authorization. Sending both returns 401.Revocation is a soft delete. The key row stays for audit; only is_active flips. There's no "undo revoke".Expired keys still exist. Expiry is checked at use time, not on a cron — 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 — don't rely on it for sub-second precision.Empty scopes = full access. scopes: [] isn't "no permissions", it's "inherits everything". To restrict, list specific permission strings.Public-API create is rate-limited to 20/min per Environment. Avoid hot-loop creating keys in tests; stub the call out instead.Names aren't globally unique, but the public-API create rejects duplicates within an Environment with 409. Use {purpose}-{env} patterns (e.g. ci-prod, analytics-staging) to keep them readable and conflict-free.