API create
Server-to-server identity creation through the public API — no email, no acceptance ceremony.
Overview
API direct create is the lowest-ceremony path to provisioning identities — your backend POSTs an identity, Canopy writes the row immediately, and the new identity exists right away. No email, no token, no acceptance form. Choose it over API invite when you don't want Canopy involved in delivering credentials (the user already exists in your system, your app handles password set on its own, etc.). Choose it over the Dashboard invite when the request is automated rather than a human admin clicking a dialog. The same call can optionally include a role + node pair so the new identity lands with permissions already assigned.
What gets written: the Identity row is created at the Account, and the AppMembership for the API key's Application is created in the same transaction. If you also pass role_id + node_id, a role assignment is created in the API key's Environment too. Skip the role/node pair to leave the identity at AppMembership level with no concrete access — useful when role decisions come later. To create a directory-only Identity with no AppMembership at all (Account-tier only), use the dashboard's Tenant > Identities → Create Identity surface, which writes through a portal endpoint with optional App attachment. See the Identities overview for the three-layer model.
Prerequisites
API direct create has the smallest setup of any identity-provisioning path — no OAuth client, no redirect URIs, no email config. You just need the right credential and (optionally) a role + node to attach.
Create an identity
The single endpoint that writes an identity row to the database. Returns the new identity in the response. No follow-up step required — the identity exists the moment this returns 201.
POST /api/v1/identitiesAuth: API key (X-API-Key) OR portal user JWT (Authorization: Bearer). Either way, the principal needs identity.manage in the target Environment.
Body fields
| Field | Required | Default | Description |
|---|---|---|---|
email | Yes | — | Identifies the identity within the Account. Must be unique within the Account — duplicates are rejected with 409. |
first_name | Yes | — | Display name. Surfaced in the dashboard and returned on every read. |
last_name | Yes | — | Display name. Surfaced in the dashboard and returned on every read. |
password | No | — | Initial password (8–64 chars). When set, the identity can sign in immediately. NIST SP 800-63B aligned — no composition rules, but the password is checked against the HaveIBeenPwned breach list and rejected with 400 if it appears there. Omit to create a passwordless identity that signs in via SSO/social or forgot-password reset. |
external_id | No | — | Stable identifier from your own system. Useful for federated/SSO identities where Canopy mirrors a user managed elsewhere. Indexed for lookup. |
metadata | No | — | Free-form JSON object for any extra data you want to store alongside the identity. Returned as-is on every read. |
role_id | No | — | Role to assign at the same time the identity is created. Required if node_id is set. |
node_id | No | — | Hierarchy node where the role is assigned. Required if role_id is set. |
Request
curl -X POST https://auth.canopy-io.com/api/v1/identities \
-H "X-API-Key: $CANOPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "alex@acme.com",
"first_name": "Alex",
"last_name": "Singh",
"external_id": "hr-sys:42",
"role_id": "role_01HXABC...",
"node_id": "node_01HXDEF..."
}'Response
{
"data": {
"id": "id_01HXABC...",
"email": "alex@acme.com",
"first_name": "Alex",
"last_name": "Singh",
"external_id": "hr-sys:42",
"metadata": null,
"is_active": true,
"created_at": "2026-05-03T12:00:00.000Z"
}
}The identity is committed by the time you receive the 201 — there's no async write or eventual-consistency window. If <code>role_id</code> + <code>node_id</code> were passed, the matching role assignment is in the database too, in the same transaction.
Bulk create identities
When you have more than a handful of identities to provision in one go — migrations, mass onboarding, weekly imports from another system — call the bulk endpoint instead of looping the single one. Up to 200 identities per request, each row processed independently so a single bad row doesn't abort the batch.
POST /api/v1/identities/bulk-createAuth: Same as single create — API key or user JWT with identity.manage in the target Environment. Authorization is checked once upfront, not per row.
Request shape
The body wraps an identities array of standard create payloads. Each item takes the same fields documented above (email, first_name, last_name, optional password / external_id / metadata / role_id + node_id).
Response envelope
Every successful response — full success or partial — has the same body shape: a summary with totals and a results array with one entry per input row, ordered by index.
Status code: 200 vs 207
200 OK when every row succeeded. 207 Multi-Status when at least one row failed. The body shape is identical either way — branch on the summary.failed count, not the HTTP status, if you want a single code path.
Request
curl -X POST https://auth.canopy-io.com/api/v1/identities/bulk-create \
-H "X-API-Key: $CANOPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"identities": [
{
"email": "alex@acme.com",
"first_name": "Alex",
"last_name": "Singh"
},
{
"email": "jordan@acme.com",
"first_name": "Jordan",
"last_name": "Lee",
"role_id": "role_01HXABC...",
"node_id": "node_01HXDEF..."
},
{
"email": "alex@acme.com",
"first_name": "Alex",
"last_name": "Duplicate"
}
]
}'Response — every row succeeded (200 OK)
{
"summary": {
"total": 2,
"succeeded": 2,
"failed": 0
},
"results": [
{
"index": 0,
"status": "success",
"code": 201,
"data": {
"id": "id_01HXABC...",
"email": "alex@acme.com",
"first_name": "Alex",
"last_name": "Singh",
"is_active": true,
"created_at": "2026-05-03T12:00:00.000Z"
}
},
{
"index": 1,
"status": "success",
"code": 201,
"data": {
"id": "id_01HXDEF...",
"email": "jordan@acme.com",
"first_name": "Jordan",
"last_name": "Lee",
"is_active": true,
"created_at": "2026-05-03T12:00:00.000Z"
}
}
]
}Response — mixed outcomes (207 Multi-Status)
{
"summary": {
"total": 3,
"succeeded": 2,
"failed": 1
},
"results": [
{
"index": 0,
"status": "success",
"code": 201,
"data": { "id": "id_01HXABC...", "email": "alex@acme.com", "...": "..." }
},
{
"index": 1,
"status": "success",
"code": 201,
"data": { "id": "id_01HXDEF...", "email": "jordan@acme.com", "...": "..." }
},
{
"index": 2,
"status": "error",
"code": 409,
"input": {
"email": "alex@acme.com",
"first_name": "Alex",
"last_name": "Duplicate"
},
"error": {
"code": "identity.duplicate_email",
"message": "An identity with this email already exists in this Account"
}
}
]
}Per-row result fields
Every result entry carries an index matching its position in the request array. Successes carry data (the created identity, in the same shape as the single-create response). Failures carry input (the original row, echoed back so you can correlate without bookkeeping) plus an error object with a stable machine-readable code and a human message.
What can fail per row
Anything else — auth failure, the database becoming unreachable mid-batch, an invalid Environment context — short-circuits the whole request as a normal error response (401, 403, 500) rather than a per-row failure. Per-row failures are reserved for business errors that are intrinsically about the row's data.
What you receive
What's in the response and what it implies for the new identity.
No password is set
API direct create writes the identity row without a password. The identity exists, but they can't sign in with a password until one is set. Two ways to get there today:
POST /v1/identity/auth/forgot-password with the new identity's email — Canopy sends them a reset link. They click it, set a password, and can then sign in via Direct API or Hosted Login.external_id and the identity will only ever sign in via your own SSO or social provider, the password column stays null forever — that's by design.Route password set-up through the forgot-password flow when you need the identity to authenticate with a password.
Optional role assignment
If you passed role_id + node_id, the response identity already holds that role at the named node — the assignment was created in the same transaction. Use GET /api/v1/identities/:id/assignments to read it back.
Active by default
is_active: true on creation. To pre-provision an identity in a disabled state, create it then immediately call deactivate (see below).
Activate, deactivate, remove
Identities have a soft-delete-style lifecycle: deactivation is reversible, removal is permanent. Both endpoints take just the id.
Activate
POST /api/v1/identities/:id/activateRe-enables a deactivated identity. Restores authentication and assignment evaluation. No-op if the identity is already active.
Deactivate
POST /api/v1/identities/:id/deactivateMarks is_active: false. Authentication fails (login returns 401), and authorization checks treat the identity as ineligible for any role-based grant. Existing assignments are preserved — re-activating restores everything.
Remove
DELETE /api/v1/identities/:idHard delete. The identity row is removed; cascading rules clean up role assignments. Use deactivate instead if you might ever want them back.
# Deactivate
curl -X POST https://auth.canopy-io.com/api/v1/identities/id_01HXABC.../deactivate \
-H "X-API-Key: $CANOPY_API_KEY"
# Activate
curl -X POST https://auth.canopy-io.com/api/v1/identities/id_01HXABC.../activate \
-H "X-API-Key: $CANOPY_API_KEY"
# Remove (irreversible)
curl -X DELETE https://auth.canopy-io.com/api/v1/identities/id_01HXABC... \
-H "X-API-Key: $CANOPY_API_KEY"Update
Update mutable fields on an existing identity. Email is updatable, but mind that it's the human-facing identifier — changing it is rare in practice.
PATCH /api/v1/identities/:idAuth: Same as create — API key or user JWT with identity.manage.
curl -X PATCH https://auth.canopy-io.com/api/v1/identities/id_01HXABC... \
-H "X-API-Key: $CANOPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Alex",
"last_name": "Singh-Patel",
"metadata": { "department": "eng-platform" }
}'
# 200 OK
# Returns the updated identity in the same shape as create.More endpoints
Read-side endpoints for surfacing identities in your UI or running periodic reports.
Error responses
API direct create errors use Canopy's standard envelope. Every body has a <code>code</code> for programmatic handling and a <code>message</code> for display, plus the originating request's path, method, and timestamp.
Sample error response
{
"error": {
"statusCode": 409,
"code": "identity.duplicate_email",
"message": "Email already exists in this Application",
"timestamp": "2026-05-03T12:00:00.000Z",
"path": "/api/v1/identities",
"method": "POST"
}
}| Status | When it fires |
|---|---|
400 (validation) | Required field missing, invalid email, or malformed UUID for role_id / node_id. Per-field details in the response's details array. |
401 | Missing or invalid auth credential — no X-API-Key, no bearer, or the value didn't match a known key/user. |
403 | Authenticated but the principal lacks identity.manage in the target Environment. |
404 | The role or node referenced by role_id / node_id doesn't exist in this Environment. (Identity-level 404s come back from the per-id endpoints — update, deactivate, etc. — when the id doesn't match.) |
409 identity.duplicate_email | An identity with the same email already exists in this Account. Email is the unique identifier scoped to Account. |
Gotchas
Things that catch teams the first time they wire up automated identity creation.