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-invitesAuth: 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-createAuth: 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).
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
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/resendAuth: 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/:idAuth: 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 |
|---|---|
pending | The 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. |
accepted | The 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. |
expired | The 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. |
revoked | Someone 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_assignment | node_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_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. |
400 invite.window_requires_role / invite.window_ended | effective_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_identity | intent is password_reset but no identity in the Account has that email. |
400 invite.password_reset_forbids_assignment | A password_reset invite carried role_id / node_id. Reset invites never carry a role. |
400 oauth.client_not_found / oauth.client_no_invite_url | client_id names no active OAuth client in this Environment, or one without an invite_redirect_url. |
400 invite.not_pending | Tried to revoke an invite that's already accepted. |
400 invite.not_resendable | Tried to resend an invite that's already accepted or revoked. Expired invites can be resent. |
400 invite.resend_cooldown | Resend 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_ceiling | Only 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_found | The 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.duplicate | A 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_app | The 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_exists | The 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.
Tell us how we can improve this guide.