1. Docs
  2. Identities
  3. API create

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

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

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 — 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
409 identity.duplicate_email: an identity with that email already exists, either in the database or earlier in the same batch.400 (validation): required field missing, invalid email, or malformed UUID.400 password.breached: the password appeared in a known data breach (HaveIBeenPwned check).

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:

Forgot-password reset. Call 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.Federated / SSO sign-in. If you provisioned with 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/activate

Re-enables a deactivated identity. Restores authentication and assignment evaluation. No-op if the identity is already active.

Deactivate
POST /api/v1/identities/:id/deactivate

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

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

Auth: 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.
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.
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 — update, deactivate, etc. — when the id doesn't match.)
409 identity.duplicate_emailAn 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.

No password is set on creation. The identity exists but can't sign in until you trigger a forgot-password flow or wire up SSO via external_id.Email is Account-scoped unique. Two identities with the same email can exist in different Accounts, but never in the same one. 409 identity.duplicate_email 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.Identities are active by default. If you need them to be created disabled (e.g. pre-provisioning before launch), create then immediately deactivate.Deactivate is reversible; remove is not. Default to deactivate unless you're absolutely sure the identity will never come back.external_id is yours, not Canopy's. Canopy stores it as-is and indexes it for lookup, but doesn't validate format or uniqueness against any external system. If your source system later changes the id, you have to update the row yourself.No audit-log API yet from this surface. Identity creation does emit an audit event (IDENTITY_CREATED), but it's surfaced in the dashboard, not via this controller. To inspect from your backend, query the audit endpoints separately.