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 Dashboard 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 — there is no Account-tier path on the public API. If you need a directory-only invite that doesn't attach to an Environment (federated/SSO pre-staging, for example), use the dashboard's Tenant > Identities → Invite surface, which writes through a portal endpoint that allows environment_id to be NULL.
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 | — | Where the invite is addressed. Normalized server-side (lowercase + trimmed) before duplicate lookup. |
first_name | Yes | — | Pre-filled on the acceptance form. The invitee can edit it before submitting. |
last_name | Yes | — | Pre-filled on the acceptance form. The invitee can edit it before submitting. |
client_id | No | — | OAuth client UUID. When set, the invite link routes to that client's invite_redirect_url; otherwise it lands on Canopy's hosted accept page. |
role_id | No | — | Role to assign the new identity at acceptance time. Required if node_id is set. |
node_id | No | — | Hierarchy node where the role is assigned. Required if role_id is set. |
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": "role_01HXABC...",
"node_id": "node_01HXDEF..."
}'Response
{
"data": {
"id": "inv_01HXABC...",
"email": "alex@acme.com",
"first_name": "Alex",
"last_name": "Singh",
"name": "Alex Singh",
"role_id": "role_01HXABC...",
"node_id": "node_01HXDEF...",
"has_initial_assignment": true,
"status": "pending",
"expires_at": "2026-05-10T12:00:00.000Z",
"invited_by": "id_01HX...",
"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.
Send invites in bulk
Mass-onboarding flow — issue up to 200 invites in a single round trip. Each invite is processed independently so a duplicate or validation failure on one row doesn't abort the rest. 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 — 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 — 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": "role_01HXABC...",
"node_id": "node_01HXDEF...",
"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": "inv_01HXABC...",
"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": "inv_01HXDEF...",
"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": "inv_01HXABC...", "accept_url": "https://...", "...": "..." }
},
{
"index": 1,
"status": "success",
"code": 201,
"data": { "id": "inv_01HXDEF...", "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 for this email already exists"
}
}
]
}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, an invalid org 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.
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 queues a templated email through its mail provider — currently Mailgun — and renders the invitee's first name, the inviter's name, and the tokenized URL. 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, you'll have to call resend to get a fresh one.
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 an existing pending invite, resets the 7-day expiry, and (when <code>send_email</code> was originally <code>true</code>) sends the email again. Subject to a 5-minute cooldown.
POST /api/v1/identity-invites/:id/resendAuth: Same as create — API key or user JWT with identity.manage.
curl -X POST https://auth.canopy-io.com/api/v1/identity-invites/inv_01HXABC.../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) is now invalid. If you're self-delivering invites, replace whatever you stored with the new URL before sending — using the old one will redirect the invitee to an "invite no longer valid" page.
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 — API key or user JWT with identity.manage.
curl -X DELETE https://auth.canopy-io.com/api/v1/identity-invites/inv_01HXABC... \
-H "X-API-Key: $CANOPY_API_KEY"
# 204 No ContentRevoking is destructive — there's no undo. Only pending invites can be revoked; calling DELETE on an accepted, expired, or already-revoked invite returns 400 InviteNotPending. 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 underlying timestamps and the accepted/revoked flags, so the value you see in <code>status</code> 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. |
accepted | The invitee opened the link, set their password, and finished the acceptance form. The Identity row was created and (if has_initial_assignment) the role assignment was added in the same transaction. |
expired | 7 days passed without acceptance. The token stops working. To revive the invite, call resend — it generates a fresh token and resets the clock. |
revoked | Someone called DELETE before the user accepted. Token stops working immediately. The invite stays in your list for audit, but resend / re-revoke return 400. |
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 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 invite.malformed_assignment | Sent role_id without node_id (or vice versa). Pass both or neither. |
400 invite.not_pending | Tried to resend or revoke an invite that's already accepted, expired, or revoked. The status moved on; only pending invites support those operations. |
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. |
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 this email overall (when invite-only). Revoke the existing one or wait for it to expire before re-inviting. |
Gotchas
Things that catch teams the first time they wire up automated invitations.