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

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

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 — 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
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.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 org.

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

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

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

Revoking 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
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.
acceptedThe 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.
expired7 days passed without acceptance. The token stops working. To revive the invite, call resend — it generates a fresh token and resets the clock.
revokedSomeone 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_assignmentSent role_id without node_id (or vice versa). Pass both or neither.
400 invite.not_pendingTried 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_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.
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 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.

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 and revoked are stored flags, 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 — convenient for re-inviting after revoke, but watch out for accidentally double-onboarding the same person.