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.
Dashboard → Integrations → API Keys and click Create.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.scopes: [] on the API.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-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 | — | 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-keysAuth: 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/:idAuth: 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 ContentRevocation 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 |
|---|---|
active | is_active: true AND no expiry OR expiry in the future. Authentication succeeds. last_used_at updates on every successful request. |
revoked | is_active: false. Authentication fails with 401. Set by calling DELETE; not reversible. Row stays in the database for audit. |
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 — 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_scope | One 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. |
403 | Authenticated 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_conflict | Another 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.