Skip to content

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.

POST /api/server/v1/invitations with your secret key. Torii emails the invitee.

Terminal window
curl -X POST https://api.toriiauth.eu/api/server/v1/invitations \
-H "Authorization: Bearer $TORII_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"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.

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.

GET /api/server/v1/invitations # cursor-paginated; filter by status or search by email
POST /api/server/v1/invitations/search # structured email, status and metadata filters
GET /api/server/v1/invitations/{id}
DELETE /api/server/v1/invitations/{id} # revoke
POST /api/server/v1/invitations/{id}/resend
PATCH /api/server/v1/invitations/{id}

A resend mints a new token and refreshes the expiry, which kills the link in the original email.

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.

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.

Terminal window
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 below tenant.
  • {"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 number 42.
  • 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.

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.

Terminal window
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.

  • 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. Contact [email protected] before 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 with PATCH /api/server/v1/users/{userId}/metadata.