1. Docs
  2. Identities
  3. API Create

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/identities

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

Auth: 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).

1 to 200 items per request: fewer than 1 returns 400, more than 200 also returns 400. Chunk client-side past that.Same-batch duplicates fail individually: two rows with the same email collide on the second one, which becomes a per-row 409 instead of breaking the batch.All rows must belong to the Environment the auth credential is scoped to. Cross-Environment rows are not supported.
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
409 identity.email_conflict: an identity with that email already exists, either in the database or earlier in the same batch.409 identity.external_id_conflict: another identity in the Account already has that external_id.400 auth.password_breached: the password appeared in a known data breach (HaveIBeenPwned check).409 plan_limit_exceeded: the Account's signup ceiling was reached before this 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:

Reset email from your backend. Call 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.Set it directly. POST /api/v1/identities/:id/password sets a password server-to-server, for example when migrating users off a legacy store.Federated / SSO sign-in. If the identity will only ever sign in through your SSO provider, the password stays unset, which is by design.

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

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

Auth: 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_assignablerole_id names a system role, which cannot be assigned.
400 rbac.role_outside_organizationrole_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.
401Missing or invalid auth credential: no X-API-Key, no bearer, or the value didn't match a known key/user.
403Authenticated but the principal lacks identity.manage in the target Environment, or permission to manage assignments at the node you named.
404The 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_conflictAn identity with the same email already exists in this Account. Email is the unique identifier scoped to Account.
409 identity.external_id_conflictAnother identity in this Account already has the external_id you sent.
409 organization.membership_requiredWith 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_exceededThe 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.

Without a password, the identity can't sign in with one yet. Send a reset email, set a password from your backend, or rely on SSO.Email is Account-scoped unique. Two identities with the same email can exist in different Accounts, but never in the same one. 409 identity.email_conflict on collision.For batch provisioning, use the bulk endpoint. POST /api/v1/identities/bulk-create handles up to 200 rows per call with per-row partial-success semantics. Looping the single endpoint costs more round trips and gets you no atomicity in exchange.role_id and node_id are mutually dependent. Either both or neither: partial pairs return 400.external_id is yours, not Canopy's. Canopy trims it (an empty value is stored as none) and requires it to be unique within the Account, but doesn't validate it against any external system. It cannot be changed afterwards through the API (an update that names it is refused with 400), so use a value your source system will not change; only a directory's provisioning rewrites it.Creation is audited. Each create writes an identity.created event. Read it from your backend through GET /api/v1/audit-events, or in the Console's Audit Log.Remove drops this Environment's role assignments for good. Adding the identity back restores its access to the Environment, not its roles.
Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.