1. Docs
  2. Identities
  3. API Invite

API Invite

Programmatic invitation flow: your backend issues invites, Canopy sends the email (or hands back the link for self-delivery).

Overview

API invite is the programmatic path to onboarding end users: your backend POSTs an invitation to POST /api/v1/identity-invites, and Canopy issues a tokenized URL the invitee uses to set their password and become an Identity. Choose it over API create when you want the user to set their own password rather than your service holding one. Choose it over the Console invite when admins shouldn't be involved in the invitation step (self-serve signup that needs approval, external-system-driven onboarding, etc.). The same invite can optionally include a role + node pair so the new identity lands with permissions already assigned.

Every invite issued through this endpoint belongs to the Environment the API key is scoped to. The public API has no Account-wide invite; account administrators invite people into the Account-wide directory from the Admin Workspace (see Send and Manage Invitations).

Prerequisites

Most of these are configured once per Environment. The optional ones (OAuth client, role + node) only matter if you want branded invite landing or one-shot invite-and-assign.

Send an invite

The single endpoint that creates the invite, generates the token, and (optionally) queues the email.

POST /api/v1/identity-invites

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 Where the invite is addressed. Normalized server-side (lowercase + trimmed) before duplicate lookup.
first_name No None Pre-filled on the acceptance form. If you leave it out, the invitee supplies it when accepting.
last_name No None Pre-filled on the acceptance form. If you leave it out, the invitee supplies it when accepting.
client_id No None The OAuth client's client_id. When set, the invite link routes to that client's invite_redirect_url; otherwise it lands on the Environment's auth-link origin or Canopy's hosted accept page.
role_id No None Role to assign at acceptance, at node_id. Required with node_id, except at an organization node, where an invitation may admit a member with no role.
node_id No None Hierarchy node where the role is assigned. Required with role_id. With organizations on, a node that is an organization or sits beneath one makes this an invitation into that organization: accepting admits the person as a member first.
effective_from No None When the role starts to count (ISO 8601), copied onto the assignment acceptance creates. Needs a role.
effective_to No None When the role stops counting, after effective_from. An invitation whose window has already ended is refused when sent, and when accepted.
intent No activate activate invites someone to join. password_reset sends an existing identity a link to set a new password and can't carry a role; that link expires after 24 hours rather than 7 days. Someone already in the Account but not in this Environment is invited to join it automatically. Someone already in this Environment, invited into an organization, receives a grant invitation instead: a request to join it, which they accept by proving it is them.
send_email No true When true, Canopy sends the invite email through its mail provider. When false, it skips delivery and you handle distribution using the accept_url in the response.

Request

curl -X POST https://auth.canopy-io.com/api/v1/identity-invites \
  -H "X-API-Key: $CANOPY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "alex@acme.com",
    "first_name": "Alex",
    "last_name": "Singh",
    "role_id": "4a9c1e7f-2b8d-4f3a-9c6e-5d1b8a2f7e09",
    "node_id": "e6b2d8f4-9a1c-4d7e-8b3f-2c5a9e1d6f48"
  }'

Response

{
  "data": {
    "id": "c8f1a3e6-7b2d-4e9c-a5f8-1d4b6e2a9c73",
    "email": "alex@acme.com",
    "intent": "activate",
    "first_name": "Alex",
    "last_name": "Singh",
    "name": "Alex Singh",
    "role_id": "4a9c1e7f-2b8d-4f3a-9c6e-5d1b8a2f7e09",
    "node_id": "e6b2d8f4-9a1c-4d7e-8b3f-2c5a9e1d6f48",
    "node_name": "EU Office",
    "organization_node_id": "7d3e9b1a-5c2f-4a8e-b6d1-9f4c2e8a3b57",
    "effective_from": null,
    "effective_to": "2026-12-31T23:59:59.999Z",
    "has_initial_assignment": true,
    "status": "pending",
    "expires_at": "2026-05-10T12:00:00.000Z",
    "invited_by": null,
    "invited_by_identity_id": null,
    "created_at": "2026-05-03T12:00:00.000Z",
    "accept_url": "https://app.example.com/accept-invite?token=..."
  }
}

accept_url comes back on every successful create: store it if you want to deliver the link yourself, or just discard it if Canopy is sending the email. has_initial_assignment reflects whether you passed role+node; it's the cheap way to tell at a glance whether this invite turns into a permission grant on acceptance. invited_by is the inviting user, or null when an API key sent the invite; invited_by_identity_id is the organization's own administrator who sent it with their identity token, whose standing acceptance re-checks. organization_node_id names the organization the invitation joins (null outside one), node_name the place its role is held at, and effective_from / effective_to the window copied onto the role.

Send invites in bulk

Mass-onboarding flow: issue up to 200 invites in a single round trip. Each well-formed invite is processed independently, so a duplicate or a refused row doesn't abort the rest; a malformed row (an invalid email or id, a role without its node) fails the whole request with 400 before any invite is sent. Mirrors the bulk-create endpoint's partial-success contract, with one accept_url returned per successful row.

POST /api/v1/identity-invites/bulk-create

Auth: Same as single send: 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 invites array of standard send payloads. Each item takes the same fields documented above (email, first_name, last_name, optional role_id + node_id, optional client_id, optional send_email).

1 to 200 items per request: fewer than 1 returns 400, more than 200 also returns 400. Chunk client-side past that.send_email is per-row, so a single bulk call can mix Canopy-delivered and self-delivered invites if you want.Same-batch duplicates fail individually: two rows for the same email at the same node collide on the second one as 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. Successful rows include the created invite plus its own accept_url so self-delivery callers don't have to make a second call.

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/identity-invites/bulk-create \
  -H "X-API-Key: $CANOPY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "invites": [
      {
        "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",
        "send_email": false
      },
      {
        "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": "c8f1a3e6-7b2d-4e9c-a5f8-1d4b6e2a9c73",
        "email": "alex@acme.com",
        "first_name": "Alex",
        "last_name": "Singh",
        "name": "Alex Singh",
        "status": "pending",
        "expires_at": "2026-05-10T12:00:00.000Z",
        "created_at": "2026-05-03T12:00:00.000Z",
        "accept_url": "https://app.example.com/accept-invite?token=..."
      }
    },
    {
      "index": 1,
      "status": "success",
      "code": 201,
      "data": {
        "id": "5e2a7c9f-3d8b-4a1e-b6c4-9f2e1a8d3b57",
        "email": "jordan@acme.com",
        "...": "...",
        "accept_url": "https://app.example.com/accept-invite?token=..."
      }
    }
  ]
}

Response: mixed outcomes (207 Multi-Status)

{
  "summary": {
    "total": 3,
    "succeeded": 2,
    "failed": 1
  },
  "results": [
    {
      "index": 0,
      "status": "success",
      "code": 201,
      "data": { "id": "c8f1a3e6-7b2d-4e9c-a5f8-1d4b6e2a9c73", "accept_url": "https://...", "...": "..." }
    },
    {
      "index": 1,
      "status": "success",
      "code": 201,
      "data": { "id": "5e2a7c9f-3d8b-4a1e-b6c4-9f2e1a8d3b57", "accept_url": "https://...", "...": "..." }
    },
    {
      "index": 2,
      "status": "error",
      "code": 409,
      "input": {
        "email": "alex@acme.com",
        "first_name": "Alex",
        "last_name": "Duplicate"
      },
      "error": {
        "code": "invite.duplicate",
        "message": "A pending invite already exists for this email"
      }
    }
  ]
}
Per-row result fields

Every result entry carries an index matching its position in the request array. Successes carry data (the created invite plus its accept_url, in the same shape as the single-send 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 invite.duplicate: a pending invite for that email (and node, when assigning) already exists, either in the database or earlier in the same batch.400 (validation): required field missing, invalid email, malformed UUID, or role/node sent without its pair. These refuse the whole request before any row is processed, so they never appear as a row in results.400 oauth.client_no_invite_url: the client_id is set but the matching OAuth client has no invite_redirect_url configured. Fix the client config and retry just the failed rows.400 oauth.client_not_found: the client_id doesn't match an active OAuth client in this Environment.

Anything else (auth failure, the database becoming unreachable mid-batch) 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.

Self-deliver vs Canopy-deliver

send_email picks who's responsible for getting the link in front of the invitee.

Canopy delivers (default)

Set send_email: true (or omit it). Canopy sends a templated email through its mail provider with the invitee's first name, the Application's name, and the tokenized link, which expires in 7 days, or 24 hours for a password_reset invite. You still receive accept_url in the API response so you can log it for auditing or display it in your dashboard, but you don't have to do anything with it.

You deliver

Set send_email: false. Canopy creates the invite but skips the email entirely. The accept_url in the response is the only copy of the tokenized link: you must capture it and own delivery (your own transactional email, in-app notification, Slack DM, QR code, anything). If you lose the URL before delivering it, resend returns a fresh one, but resend always emails the invitee too.

When to choose self-delivery. Pick self-delivery when you need to brand the email yourself, batch invitations into a digest, route through a different mail provider, or attach the invite to an existing onboarding email you already send. Otherwise let Canopy handle it: the default email gets the link in front of the user with no extra code.

Resend

Generates a fresh token for a pending or expired invite, resets the expiry (7 days, or 24 hours for password_reset), and sends the email again. It always sends, even when the invite was created with send_email: false. Subject to a 5-minute cooldown from the last create or resend.

POST /api/v1/identity-invites/:id/resend

Auth: Same as create: an API key or user JWT with identity.manage.

curl -X POST https://auth.canopy-io.com/api/v1/identity-invites/c8f1a3e6-7b2d-4e9c-a5f8-1d4b6e2a9c73/resend \
  -H "X-API-Key: $CANOPY_API_KEY"

# 200 OK
# {
#   "data": {
#     "message": "Invite resent",
#     "accept_url": "https://app.example.com/accept-invite?token=NEW..."
#   }
# }

Resend rotates the token. The accept_url in this response is brand-new; the URL you stored from the original create call (or any earlier resend) stops working. If you're self-delivering invites, replace whatever you stored with the new URL.

Revoke

Cancels a pending invite by id. The token stops working immediately and the invite drops out of the duplicate-pending guard, so the same email can be re-invited if needed.

DELETE /api/v1/identity-invites/:id

Auth: Same as create: an API key or user JWT with identity.manage.

curl -X DELETE https://auth.canopy-io.com/api/v1/identity-invites/c8f1a3e6-7b2d-4e9c-a5f8-1d4b6e2a9c73 \
  -H "X-API-Key: $CANOPY_API_KEY"

# 204 No Content

Revoking is destructive, and there's no undo. Revoking an accepted invite returns 400 invite.not_pending. An expired invite can be revoked too, and revoking an already-revoked invite answers 204 again with no change. To re-invite the same email after revoking, just call create again: the duplicate guard only counts pending invites.

Invite lifecycle

Every invite is in exactly one of four states. The state is computed at read time from the invite's timestamps (accepted_at, revoked_at, expires_at), so the value you see in status always reflects current reality.

Status What it means
pendingThe starting state. Token is valid, the link works, and resend / revoke are both allowed. Stays pending for up to 7 days from the last create or resend, or 24 hours for a password_reset invite.
acceptedThe invitee opened the link, set their password, and finished the acceptance form. In the same transaction the identity was created (or found), admitted to the organization the invitation names, and given its role (if has_initial_assignment) with the invitation's window.
expiredThe expiry passed without acceptance: 7 days, or 24 hours for password_reset. The token stops working. To revive the invite, call resend: it generates a fresh token, resets the clock and emails the invitee.
revokedSomeone revoked it before the user accepted. Token stops working immediately. The invite stays in your list for audit; resend returns 400 invite.not_resendable.

More endpoints

Read-side endpoints for surfacing invites in your dashboard or running periodic reports.

Error responses

Direct API errors use Canopy's standard envelope. Every body has a code for programmatic handling (null on a validation failure, which lists the problems in details instead) and a message for display, plus the originating request's path, method, and timestamp.

Sample error response

{
  "error": {
    "statusCode": 409,
    "code": "invite.duplicate",
    "message": "A pending invite already exists for this email",
    "timestamp": "2026-05-03T12:00:00.000Z",
    "path": "/api/v1/identity-invites",
    "method": "POST"
  }
}
Status When it fires
400 (validation)Sent role_id without node_id. The refusal is a validation failure whose details names the missing field.
400 invite.malformed_assignmentnode_id came with no role_id at a node that is not an organization node. Only an organization node admits a member holding no role.
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.
400 invite.window_requires_role / invite.window_endedeffective_from or effective_to came with no role, or the window has already ended. A window that ends before it starts is refused as 400 auth.effective_date_invalid.
400 invite.password_reset_no_identityintent is password_reset but no identity in the Account has that email.
400 invite.password_reset_forbids_assignmentA password_reset invite carried role_id / node_id. Reset invites never carry a role.
400 oauth.client_not_found / oauth.client_no_invite_urlclient_id names no active OAuth client in this Environment, or one without an invite_redirect_url.
400 invite.not_pendingTried to revoke an invite that's already accepted.
400 invite.not_resendableTried to resend an invite that's already accepted or revoked. Expired invites can be resent.
400 invite.resend_cooldownResend called within 5 minutes of the last create or resend. Wait for the cooldown to expire and try again.
400 (validation)Required field missing, malformed UUID for role_id / node_id, or invalid email. Per-field details in the response's details array.
403 assignment.grant_ceilingOnly on POST /api/v1/organizations/{id}/invites, called with an identity access token: the inviter does not hold, at the invitation's node or above, every permission role_id carries. An invitation is a grant, so the inviter's ceiling applies when it is sent. This endpoint takes an API key or a Console user's token, neither of which is bounded.
404 invite.not_foundThe id doesn't match any invite in the current Environment. Either the id is wrong or the invite belongs to a different Environment.
409 invite.duplicateA pending invite already exists for this email at the same node (when assignment-bound) or, for an invite-only invitation, another invite-only one for this email. Revoke the existing one or wait for it to expire before re-inviting.
409 invite.identity_already_in_appThe email already belongs to an identity with active access to this Environment, and the invitation joins no organization. Use the assignments API to add a role. An invitation into an organization reaches the same person as a request to join instead.
409 organization.member_existsThe invitation joins an organization the person is already an active member of. Give them the role through the organization's assignment routes instead.

Gotchas

Things that catch teams the first time they wire up automated invitations.

Resend rotates the token. If you're self-delivering, replace your stored accept_url with the new one. Old URLs are dead the moment the resend response is sent.role_id and node_id are mutually dependent. Either both or neither: partial pairs return 400.Email is normalized server-side. Canopy lowercases and trims before duplicate lookup, so Alex@Acme.com and alex@acme.com are the same invite.Resend has a 5-minute cooldown per invite. Don't auto-retry a failed resend in a tight loop: back off for at least 5 minutes.Branded landing requires invite_redirect_url on the OAuth client. Passing a client_id whose client lacks the redirect URL returns 400: Canopy won't silently fall back to the hosted page when you've explicitly named a target.Acceptance creates the identity but doesn't issue tokens. The user has to log in afterward (Direct API or Hosted Login). The acceptance response carries success but no session.Status is computed, not stored. accepted_at and revoked_at are stored timestamps, but expired is derived from expires_at at read time. Two invites with identical timestamps can have different statuses if one was accepted/revoked.The duplicate guard only counts pending invites. Accepted, expired, and revoked invites don't block a new invite to the same email, which is convenient for re-inviting after revoke, but watch out for accidentally double-onboarding the same person.
Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.