Create, send, and manage organization invitations with the AuthPI Core API — inviter attribution, the invitee journey, lifecycle states, resend semantics, and events.
Last updated 2026-06-13
“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.
Invitations live under an organization. Create one with an API key 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:
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.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:
{
"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.400 A pending invitation already exists for this email. To re-invite with different scopes, revoke the old invitation first, then create a new one. To just nudge the invitee, resend instead.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.
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:
?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.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, acceptance is rejected but the invitation stays pending — it becomes acceptable again when the organization is reactivated. See Organization 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.
All management endpoints live under the same path. Reads need issuers.organizations.invitations:read, updates and resends need :write, revocation needs :manage.
List with an optional status filter — the practical query is “what’s still pending”:
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.
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:
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.
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:
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.
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.
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.
Invitation activity emits events you can consume via 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.
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.PATCH the pending invitation. Wrong email? Revoke and re-create — a second create for the same address fails while one is pending.max_members) blocks it at the door. Watch for organization.invitation.accepted rather than assuming creation completed the onboarding.