> Markdown version of https://authpi.com/docs/guides/invitations/ — fetch the complete AuthPI docs index at https://authpi.com/llms.txt to discover all available pages.

# Invite users to organizations

Create, send, and manage organization invitations with the AuthPI Core API — inviter attribution, the invitee journey, lifecycle states, resend semantics, and events.

"Invite a teammate" is the canonical B2B onboarding flow: an admin of one of your tenants types a colleague's email, the colleague clicks a link, and a moment later they're a member of the right organization with the right permissions. With AuthPI you implement that with a single API call — AuthPI sends the email, hosts the entire invitee journey (signup, login, even the MFA path), and creates the membership when the invitation is accepted. Your backend never touches a signup form or an email template.

This guide covers the full flow from your backend's perspective: creating invitations with an API key, attributing the inviter, what the invitee actually sees, managing the invitation lifecycle, and the contract details that bite if you don't know them. For what organizations and memberships *are*, see the [Organizations concept](/docs/concepts/organizations/).

## Create an invitation

Invitations live under an organization. Create one with an [API key](/docs/guides/api-keys/) holding the `issuers.organizations.invitations:write` scope — no user session is required, so this works from your backend, an admin panel, or a provisioning script:

```bash
curl -X POST "https://api.authpi.com/v1/accounts/acc_7k2m9x4p1q8w5e3r6t0y2u4i7/issuers/i_4r8w2k9m5x1p7q3e6t0y2u4i8/organizations/org_0kfz3m8q1w5e9r2t6y4u7i3o5/invitations" \
  -u "$AUTHPI_KEY_ID:$AUTHPI_KEY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "email_invited": "dana@example.com",
    "scopes": ["member", "projects:write"],
    "user_title": "Engineering Manager",
    "message": "Your team is already set up in our production workspace — join us!",
    "inviter": { "name": "Alice Demir" },
    "expires_in_seconds": 1209600
  }'
```

The fields:

- **`email_invited`** (required) — where the invitation email goes. Stored lowercased.
- **`scopes`** (required, at least one) — the membership scopes the invitee receives **on acceptance**. These are the invitation's scopes, not the organization's `default_member_scopes` — an invited member gets exactly what the invitation says, nothing else.
- **`user_title`** (optional, ≤ 100 characters) — a title stored on the resulting membership, e.g. "Engineering Manager".
- **`message`** (optional, ≤ 1,000 characters) — a personal note included in the invitation email.
- **`inviter`** (optional) — attribution for the email; see [the next section](#attribute-the-inviter).
- **`expires_in_seconds`** (optional) — defaults to 7 days, capped at 30 days.
- **`metadata`** (optional) — your own key-value data, returned on every read.

The `201` response is the public invitation record:

```json
{
  "data": {
    "id": "inv_0kg2x7m4q9w1e6r3t8y5u2i7o",
    "org_id": "org_0kfz3m8q1w5e9r2t6y4u7i3o5",
    "issuer_id": "i_4r8w2k9m5x1p7q3e6t0y2u4i8",
    "status": "pending",
    "email_invited": "dana@example.com",
    "user_title": "Engineering Manager",
    "message": "Your team is already set up in our production workspace — join us!",
    "scopes": ["member", "projects:write"],
    "inviter": { "name": "Alice Demir" },
    "created_at": 1781308800000,
    "expires_at": 1782518400000,
    "last_email_sent_at": 1781308800000
  }
}
```

What's *not* in the response matters: every invitation carries a secret 45-character **challenge** that the invitee must present to accept, and the challenge is **never returned by any API** — not at creation, not on reads. It travels only inside the invitation email. The challenge is the bearer credential for acceptance: whoever authenticates while presenting it becomes the member, with no further email check. Treat invite links the way you treat password-reset links.

Two gates can reject creation:

- **`invitation_enabled: false`** on the organization rejects every create with a `400`. Use this toggle when a tenant manages membership some other way (SCIM-style provisioning, SSO auto-join) and you want the invite path closed.
- **One pending invitation per email per organization.** Creating a second invitation for an email that already has a pending one fails with `400 A pending invitation already exists for this email`. To re-invite with different scopes, [revoke](#revoke) the old invitation first, then create a new one. To just nudge the invitee, [resend](#resend) instead.

## Attribute the inviter

The `inviter` object is **attribution, not authorization**. The API key's `write` scope is what authorizes the call; `inviter` only controls what the email says and what reads return:

- **`inviter.name`** (≤ 200 characters) is display attribution: the email renders "Alice Demir invited you to join Acme" instead of an anonymous invitation. This is the field API-key callers should set — pass the human who clicked "invite" in *your* product.
- **`inviter.id`** (a `usr_...` id) records *which AuthPI user* sent the invitation. When the caller is a **user session** — the AuthPI console, or your app calling with a user token — `id` defaults to the authenticated user automatically, so session-driven invites are attributed without any extra work. API-key callers can set it explicitly if they know the AuthPI user id, or omit it.

Neither field is verified to exist — `inviter` has the same trust level as audit fields like `status_by` on the organization. You can omit the object entirely; the invitation works the same, the email is just unattributed.

## What the invitee sees

Creating the invitation queues the email automatically. It contains a link to your issuer's hosted signup page:

```
https://idp.authpi.com/i_4r8w2k9m5x1p7q3e6t0y2u4i8/signup?invite=<challenge>
```

The invitee lands on the signup page with an **invitation banner** — the organization's name, their title if you set one, and the invited email prefilled. From there, every path leads to membership:

- **New user.** They complete signup, and the invitation is accepted as part of signup completion. One flow: account created, membership created, done.
- **Existing user.** They switch to the login link (the `?invite=` parameter carries over) and sign in. The invitation is accepted right after authentication — including when their account requires MFA; acceptance happens after the MFA step succeeds.
- **Already signed in.** A user who opens the link while holding an active session has the invitation accepted for that session immediately, with no re-authentication.

In all three cases the membership is created with the **invitation's scopes** and `user_title`, an `organization.invitation.accepted` event fires, and the challenge is invalidated — invitations are single-use. If the invitee is somehow already a member (added through another path while the invite was pending), acceptance marks the invitation accepted and leaves the existing membership untouched rather than failing.

Two things can block acceptance even though creation succeeded:

- **Suspended organization.** While an organization is `suspended`, acceptance is rejected but the invitation **stays pending** — it becomes acceptable again when the organization is reactivated. See [Organization lifecycle](/docs/guides/org-lifecycle/) for the full suspension contract.
- **`max_members` reached.** The member limit is enforced **at acceptance time**, not at creation. You can successfully create more pending invitations than the organization has room for; the ones that arrive after the limit is hit fail to accept. If you cap tenant seats, check the member count before inviting.

The new member appears in tokens at the next issuance (login, refresh, or `/userinfo`) — the same propagation contract as every other membership change, described in [Organization lifecycle](/docs/guides/org-lifecycle/).

## Manage pending invitations

All management endpoints live under the same path. Reads need `issuers.organizations.invitations:read`, updates and resends need `:write`, revocation needs `:manage`.

### List and get

List with an optional `status` filter — the practical query is "what's still pending":

```bash
curl "https://api.authpi.com/v1/accounts/acc_7k2m9x4p1q8w5e3r6t0y2u4i7/issuers/i_4r8w2k9m5x1p7q3e6t0y2u4i8/organizations/org_0kfz3m8q1w5e9r2t6y4u7i3o5/invitations?status=pending&limit=50" \
  -u "$AUTHPI_KEY_ID:$AUTHPI_KEY_SECRET"
```

The response is `{ "data": [...], "has_more": false }`. Filter by any of the five statuses (`pending`, `accepted`, `declined`, `expired`, `revoked`) to audit history. `GET .../invitations/{invitation_id}` fetches a single record.

### Update

`PATCH .../invitations/{invitation_id}` adjusts a **pending** invitation in place — `scopes`, `user_title`, `message`, `expires_at` (must be in the future), and `metadata`. Updating a non-pending invitation fails with a `400`. The classic use: the invitee's role changed between inviting and accepting, and you want them to land with the new scopes without re-issuing the link:

```bash
curl -X PATCH "https://api.authpi.com/v1/accounts/acc_7k2m9x4p1q8w5e3r6t0y2u4i7/issuers/i_4r8w2k9m5x1p7q3e6t0y2u4i8/organizations/org_0kfz3m8q1w5e9r2t6y4u7i3o5/invitations/inv_0kg2x7m4q9w1e6r3t8y5u2i7o" \
  -u "$AUTHPI_KEY_ID:$AUTHPI_KEY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "scopes": ["member", "projects:write", "billing:read"] }'
```

Updates do not re-send the email — the already-delivered link keeps working and now grants the updated scopes on acceptance.

### Resend

`POST .../invitations/{invitation_id}/resend` re-sends the email for a pending, non-expired invitation. Understand exactly what this does: it re-sends the **same challenge** with the **same expiry** and updates `last_email_sent_at` — nothing else. It does not extend the deadline, does not rotate the link, and does not emit an event. If the invitation is close to expiring, `PATCH` a later `expires_at` first, then resend:

```bash
curl -X POST "https://api.authpi.com/v1/accounts/acc_7k2m9x4p1q8w5e3r6t0y2u4i7/issuers/i_4r8w2k9m5x1p7q3e6t0y2u4i8/organizations/org_0kfz3m8q1w5e9r2t6y4u7i3o5/invitations/inv_0kg2x7m4q9w1e6r3t8y5u2i7o/resend" \
  -u "$AUTHPI_KEY_ID:$AUTHPI_KEY_SECRET"
```

A successful resend returns `204`.

### Revoke

`POST .../invitations/{invitation_id}/revoke` (scope `:manage`) kills a pending invitation permanently: the status becomes `revoked`, the challenge is invalidated immediately, and the emailed link stops working. Revocation is terminal — to invite the same person again, create a new invitation (which is also the only way to re-invite, given the one-pending-per-email rule). Returns `204`.

## The status model

An invitation is always in exactly one state, and every transition out of `pending` is terminal:

| Status | Meaning | Set by |
|--------|---------|--------|
| `pending` | Awaiting action; the only state that can be updated, resent, accepted, declined, or revoked | Creation |
| `accepted` | Invitee became a member; `user_id_invited` and `accepted_at` are set | The invitee journey |
| `declined` | Invitee explicitly declined; `declined_at` is set | The invitee |
| `expired` | `expires_at` passed without action | Time |
| `revoked` | An admin cancelled it; `revoked_at` is set | You |

Expiry is enforced lazily — an invitation past its `expires_at` is rejected at acceptance and flipped to `expired` when touched, but there is no background job racing to update rows, and **no event fires on expiry**. If your product shows "invitation expired" states, compute them from `expires_at` rather than waiting for the status field or a webhook.

## React to events

Invitation activity emits events you can consume via [webhooks](/docs/guides/webhooks/):

- `organization.invitation.created` — fired on creation, with the invitation id, invited email, expiry, and the `inviter` attribution when present.
- `organization.invitation.accepted` — fired on acceptance, with the accepting user's id. An `organization.membership.created` event fires alongside it when a new membership is created.
- `organization.invitation.declined` — fired when the invitee declines.
- `organization.invitation.deleted` — fired on **revocation**, with `reason: "revoked"`. There is no separate `.revoked` event type — revocation *is* the deleted event.

Equally important is what does **not** fire: resend emits no event, and expiry emits no event. The webhook stream tells you about deliberate actions, not the passage of time.

## Plan for the edge cases

- **Email delivery is best-effort.** A `201` means the invitation exists and the email was queued — not that it landed in an inbox. If the invitee reports nothing arrived, `resend` is the recovery path; `last_email_sent_at` tells you when the last attempt was queued.
- **One pending invitation per email.** Wrong scopes? `PATCH` the pending invitation. Wrong email? Revoke and re-create — a second create for the same address fails while one is pending.
- **The link is the credential.** Acceptance is by challenge possession, not email-address verification. A forwarded invite link can be accepted by whoever holds it once they authenticate. Invite the address you mean, and revoke immediately if a link goes to the wrong place.
- **Creation succeeding doesn't guarantee acceptance will.** Suspension blocks acceptance temporarily (the invite survives); a full organization (`max_members`) blocks it at the door. Watch for `organization.invitation.accepted` rather than assuming creation completed the onboarding.
- **Deleting the organization deletes its invitations** in every state, and pending links die immediately — part of the cascade described in [Organization lifecycle](/docs/guides/org-lifecycle/).

## Next steps

- [Organization lifecycle & guarantees](/docs/guides/org-lifecycle/) — suspension, deletion, and membership-to-token propagation
- [Organizations concept](/docs/concepts/organizations/) — modeling tenants, memberships, scopes, and groups
- [Webhooks](/docs/guides/webhooks/) — subscribing to invitation and membership events
- [API keys](/docs/guides/api-keys/) — minting the scoped credential your backend uses to create invitations