API Create
Server-to-server identity creation through the public API, with no email and 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 Console 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 access to the API key's Environment is granted 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 with Environment access but no concrete access, useful when role decisions come later. Account administrators can also create directory-only identities, with no Environment access, from the Admin Workspace (see Add and Manage Identities). 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 | None | Identifies the identity within the Account. Must be unique within the Account, and duplicates are rejected with 409. |
first_name | Yes | None | Display name. Surfaced in the Console and returned on every read. |
last_name | Yes | None | Display name. Surfaced in the Console and returned on every read. |
password | No | None | Initial password (8–64 characters). When set, the identity signs in with it once its email address is verified; until then login answers 401 auth.email_not_verified, and Hosted Login mails a verification link at the first sign-in. 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 through SSO or sets a password through a reset. |
external_id | No | None | Stable identifier from your own system. Useful for federated/SSO identities where Canopy mirrors a user managed elsewhere. Unique within the Account: reusing one answers 409 identity.external_id_conflict. |
metadata | No | None | Free-form JSON object for any extra data you want to store alongside the identity. Returned as stored by GET /api/v1/identities/:id/detail; the other reads leave it out. |
role_id | No | None | Role to assign at the same time the identity is created. Required if node_id is set. |
node_id | No | None | 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": "4a9c1e7f-2b8d-4f3a-9c6e-5d1b8a2f7e09", "node_id": "e6b2d8f4-9a1c-4d7e-8b3f-2c5a9e1d6f48" }'
Response
{ "data": { "id": "7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34", "email": "alex@acme.com", "first_name": "Alex", "last_name": "Singh", "avatar_url": null, "external_id": "hr-sys:42", "is_active": true, "created_at": "2026-05-03T12:00:00.000Z" } }
The identity is committed by the time you receive the 201, with no async write or eventual-consistency window. If role_id + node_id 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: an 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, so 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": "4a9c1e7f-2b8d-4f3a-9c6e-5d1b8a2f7e09", "node_id": "e6b2d8f4-9a1c-4d7e-8b3f-2c5a9e1d6f48" }, { "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": "7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34", "email": "alex@acme.com", "first_name": "Alex", "last_name": "Singh", "avatar_url": null, "is_active": true, "created_at": "2026-05-03T12:00:00.000Z" } }, { "index": 1, "status": "success", "code": 201, "data": { "id": "b2e8c4a1-6f3d-4b9e-8a7c-3d5f1e9c2b60", "email": "jordan@acme.com", "first_name": "Jordan", "last_name": "Lee", "avatar_url": null, "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": "7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34", "email": "alex@acme.com", "...": "..." } }, { "index": 1, "status": "success", "code": 201, "data": { "id": "b2e8c4a1-6f3d-4b9e-8a7c-3d5f1e9c2b60", "email": "jordan@acme.com", "...": "..." } }, { "index": 2, "status": "error", "code": 409, "input": { "email": "alex@acme.com", "first_name": "Alex", "last_name": "Duplicate" }, "error": { "code": "identity.email_conflict", "message": "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
A malformed body (a required field missing, an invalid email or UUID, a role without its node) fails the whole request with 400 before any row is processed. So does anything else outside a row's data (auth failure, the database becoming unreachable mid-batch), as a normal error response (401, 403, 500). 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.
Without a password
If you leave out password, the identity exists but can't sign in with a password until one is set. Three ways to get there:
POST /api/v1/identities/:id/password/reset, and Canopy emails the identity a reset link. They click it, set a password, and can then sign in via Direct API or Hosted Login.POST /api/v1/identities/:id/password sets a password server-to-server, for example when migrating users off a legacy store.The identity can also start the reset itself from your sign-in page's forgot-password link.
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. With organizations on, a node_id at an organization node also makes the new identity a member of that organization (see Enable Organizations and Manage Members).
Active by default
is_active: true on creation. The flag is Account-wide, so it can't be changed with an Environment-scoped key.
Remove from an Environment
Remove takes the identity out of this Environment only: its membership here is deactivated and its role assignments here are dropped, emitting identity.env_membership.removed; the identity itself is kept, and memberships in other Environments are untouched. Deactivating an identity and erasing its personal data are Account-wide, so an Environment-scoped key can do neither: both happen in the Admin Workspace. A directory's deprovisioning is per Environment, like Remove.
Remove
DELETE /api/v1/identities/:idTakes the identity out of this Environment: its membership here is deactivated and its role assignments here are dropped for good. Any still-pending invite for that email into this Environment is revoked in the same transaction, so an older invitation cannot re-grant access when accepted. The identity itself is kept, so its email stays taken in the Account and creating it again answers 409 identity.email_conflict. To bring it back, add it from the Console's Add from directory and assign its roles again.
# Remove from this Environment (drops its assignments here for good)
curl -X DELETE https://auth.canopy-io.com/api/v1/identities/7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34 \
-H "X-API-Key: $CANOPY_API_KEY"Update
Update an identity's profile fields: first_name, last_name and metadata. email and external_id can't be changed here, and neither can the Account-wide is_active.
PATCH /api/v1/identities/:idAuth: Same as create: an API key or user JWT with identity.manage.
curl -X PATCH https://auth.canopy-io.com/api/v1/identities/7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34 \ -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 for programmatic handling and a message for display, plus the originating request's path, method, and timestamp.
Sample error response
{ "error": { "statusCode": 409, "code": "identity.email_conflict", "message": "Email already exists in this Account", "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. |
400 rbac.role_system_not_assignable | role_id names a system role, which cannot be assigned. |
400 rbac.role_outside_organization | role_id names an organization's own role and node_id is outside that organization. Its roles are held only at the organization node or beneath it. |
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, or permission to manage assignments at the node you named. |
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, such as update and remove, when the id doesn't match.) |
409 identity.email_conflict | An identity with the same email already exists in this Account. Email is the unique identifier scoped to Account. |
409 identity.external_id_conflict | Another identity in this Account already has the external_id you sent. |
409 organization.membership_required | With organizations on, the node_id you named sits beneath an organization. A grant there stands on a membership in that organization, which a new identity does not have yet, so nothing is created. Create the identity, add the membership, then assign the role. |
409 plan_limit_exceeded | The Account has reached its plan's identity signup ceiling, so no identity is created. The same answer comes back per row on bulk create and when an invitee accepts. |
Gotchas
Things that catch teams the first time they wire up automated identity creation.
Tell us how we can improve this guide.