Inviting users
Torii has two invitation primitives, and which one you want depends on whether the invitee is joining your app or an organization inside your app.
| Environment invitation | Organization invitation | |
|---|---|---|
| Invitee joins | your app (the environment) | one organization in your app |
| Created with | your secret key, from your backend | an org admin, from the SDK |
| Carries | publicMetadata + privateMetadata (arbitrary JSON) |
a role |
| Grants a membership | no | yes, with the invited role |
| Guide | this page | Organizations & roles |
If you model tenancy yourself in user metadata rather than with Torii organizations, the environment invitation is the one you want: it is the only invitation that carries an arbitrary payload, and that payload is written onto the user as they are created.
Create an invitation
Section titled “Create an invitation”POST /api/server/v1/invitations with your secret key. Torii emails the invitee.
curl -X POST https://api.toriiauth.eu/api/server/v1/invitations \ -H "Authorization: Bearer $TORII_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "chef@acme.example", "expiresInDays": 14, "publicMetadata": { "tenants": [{ "id": "tenant-a", "role": "admin" }] }, "privateMetadata": { "importedFrom": "crm-4821" } }'| Field | Required | Notes |
|---|---|---|
email |
yes | Normalized. Rejected with 409 if the address already has an account in this environment. |
expiresInDays |
no | Default 30, clamped to 1–365. |
redirectUrl |
no | Where this invite’s link points. Overrides the environment’s sign-up URL for this invitation only. |
publicMetadata |
no | Arbitrary JSON. Readable by the user’s own SDK session; writable only with your secret key. |
privateMetadata |
no | Arbitrary JSON. Never leaves your backend — not readable by the SDK, never in a token. |
Both bags count against the user’s combined 8 KB metadata budget (see user metadata), and are validated at create time so an oversized payload fails before an email goes out.
What the invitee experiences
Section titled “What the invitee experiences”The emailed link is your URL with a single-use ticket appended:
https://app.example.com/sign-up?__torii_ticket=<token>Mount <InvitationSignUp> (or
<AcceptInvitation>, which handles both
invitation kinds) on that page. It reads the ticket, fetches the invited email,
and renders a sign-up form with no email field and no verification code step —
holding the token is proof the person controls that address, so Torii creates the
user with the email already verified.
Everything happens in one transaction: the user is created with your metadata already stamped on, the invitation is consumed, legal consent is recorded if you require it, and a session is returned. There is no follow-up call for you to make, and no window in which the user exists without their metadata.
Afterwards the browser goes to the environment’s after-sign-up URL with
?__torii_invited=1 appended, so your app can tell an invited first-run from an
ordinary one.
If you would rather build the form yourself, drive
useInvitationSignUp() instead — same flow, your markup.
Manage pending invitations
Section titled “Manage pending invitations”GET /api/server/v1/invitations # cursor-paginated; filter by status or search by emailPOST /api/server/v1/invitations/search # structured email, status and metadata filtersGET /api/server/v1/invitations/{id}DELETE /api/server/v1/invitations/{id} # revokePOST /api/server/v1/invitations/{id}/resendPATCH /api/server/v1/invitations/{id}A resend mints a new token and refreshes the expiry, which kills the link in the original email.
Audit trail
Section titled “Audit trail”Invitation lifecycle changes are written to the environment audit log:
| Operation | Audit action | Target |
|---|---|---|
| Create | environment_invitation.created |
The created invitation ID |
| Update metadata | environment_invitation.updated |
The existing invitation ID |
| Resend | environment_invitation.created |
The replacement invitation’s new ID |
| Revoke | environment_invitation.revoked |
The revoked invitation ID |
| Accept during password or OAuth sign-up | environment_invitation.accepted |
The accepted invitation ID |
Resend invalidates the original link as part of replacement, but does not emit
environment_invitation.revoked for the old ID. A revoke event means the
explicit revoke operation ran. List, search, and get are reads and emit no
invitation lifecycle event.
Secret-key mutations have a server actor. Dashboard mutations identify the
platform user instead. These are built-in events, so producer is null; that
field identifies the credential only for events submitted through the customer
audit ingestion endpoint. See Built-in audit event actions.
Search by metadata
Section titled “Search by metadata”Use the structured search endpoint when metadata identifies the invitations you
need. Every supplied dimension is combined with AND. Values inside statuses
are combined with OR. emailSearch is a literal case-insensitive substring, so
characters such as % and _ have no wildcard meaning.
curl -X POST 'https://api.toriiauth.eu/api/server/v1/invitations/search?limit=20' \ -H "Authorization: Bearer $TORII_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "statuses": ["PENDING", "EXPIRED"], "emailSearch": "@example.com", "publicMetadata": { "tenant": { "id": "acme" }, "tags": ["municipal"] }, "privateMetadata": { "source": { "system": "crm" } } }'Metadata filters use PostgreSQL JSONB containment:
{"tenant":{"id":"acme"}}also matches an object with more keys belowtenant.{"tags":["municipal"]}matches arrays containing that value in any order, including arrays with additional values.- JSON types are significant. The string
"42"does not match the number42. - An explicit
{}matches every object. Omit the field when no metadata filter is needed.
The two metadata filter documents share an 8 KB combined metadata-filter limit.
The complete request body is capped at 24 KiB. Pagination is newest first. Pass
nextCursor from one response as the next request’s cursor query parameter.
Search returns the same summary shape as list and never includes either metadata
bag. Use the detail endpoint to read them.
Reading and correcting metadata
Section titled “Reading and correcting metadata”GET /api/server/v1/invitations/{id} returns both metadata bags, and PATCH
changes them on a pending invitation, so a mistyped tenant id is a one-call
fix rather than a revoke-and-re-invite that mails the person twice.
curl -X PATCH https://api.toriiauth.eu/api/server/v1/invitations/{id} \ -H "Authorization: Bearer $TORII_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "publicMetadata": { "flags": { "beta": false } } }'The patch is a deep merge, matching organization and membership metadata: keys
you do not mention are preserved, nested objects merge recursively, and a key is
removed by patching it to null. Omitting a bag entirely leaves it untouched.
The merged result is re-checked against the 8 KB combined budget, so a small patch
can still be rejected for pushing the row over.
Limits worth designing around
Section titled “Limits worth designing around”- One live invitation per address per environment. Re-inviting the same email revokes the previous invitation.
- No bulk endpoint. Onboarding N people is N calls.
- Rate limit: 100 invitations per environment per hour by default, and a
resend spends the same budget. Past that, creation returns
429. Contactsupport@torii.sobefore a bulk onboarding and we will raise it for your environment — it is a configured limit, not a hard ceiling. - Email quota. Invitations are ordinary tenant email, so they draw on the environment’s sending quota. Sandbox environments are capped at 100 emails per rolling 30 days, which is the limit most trials hit first.
- Already has an account? Creation returns
409. To add an existing user to something, write their metadata directly withPATCH /api/server/v1/users/{userId}/metadata.