Invitations
How do people get into your app? Primitive treats app membership as a first-class concept: every app has an access mode that decides who can sign up, an invitation system for bringing specific people in, and automatic resolution of anything that was shared with someone before they became a user — so a new user's first sign-in lands them in an app where their team, documents, and permissions are already in place.
Access Modes
| Mode | Who can join |
|---|---|
public | Anyone can sign up |
invite-only | Only people with an invitation |
domain | Anyone with an email on your allowed domains (e.g. @mycompany.com) |
The mode is an app setting in app.toml — edit and push it, or change it in the Admin Console:
primitive config set app app.mode=invite-only
primitive config push --only appThe Waitlist
Invite-only apps can collect a waitlist: visitors who try to sign up leave their email, and admins invite them when ready — one at a time, or in batches as you scale capacity:
# See who's waiting
primitive waitlist list
# Invite one entry (optionally sending the invitation email)
primitive waitlist invite <waitlist-id> --send-email
# Invite the next N in line
primitive waitlist bulk-invite --count 10Inviting Users
Admins and app owners can always invite:
primitive users invite alice@example.comOr from your app:
await client.invitations.create({
email: "alice@example.com",
role: "member",
});_ = try await client.invitations.create(
params: CreateInvitationParams(email: "alice@example.com", role: "member")
)Creating an invitation doesn't send an email by default — you get back the invitation's inviteToken to deliver however you like. Pass sendEmail: true to have the platform send its own invitation email to the invitee instead (see Sending Your Own Invitation Emails). The invitation is consumed when they sign up with the invited email, or when they accept it under a different account using the invitation's token (see How Invitations Resolve).
Member Invitations (with Quotas)
By default only admins can invite. To let regular members invite teammates, enable member invitations in your app settings. Two fields control the behavior:
| Field | Meaning |
|---|---|
memberInvitationsEnabled | If true, users with role "member" can create invitations |
memberInvitationLimit | Max active (non-accepted, non-expired) invitations per member |
Both are app settings — the enabled and limit keys of the [invitations] table in app.toml (a limit of 0 means unlimited):
# primitive/dev/app.toml
[invitations]
enabled = true
limit = 5primitive config push --only appAdmins and owners are exempt from the quota, and members can only invite other members (passing role: "admin" is rejected). Members check their quota before showing an invite UI:
const quota = await client.invitations.quota();
// { used: 2, limit: 5, remaining: 3, unlimited: false }
const canInvite = quota.unlimited || quota.remaining > 0;let quota = try await client.invitations.quota()
// InvitationQuota(used: 2, limit: 5, remaining: 3, unlimited: false)
let canInvite = quota.unlimited || quota.remaining > 0Inviting over the quota returns a 403 with error: "INVITATION_LIMIT_REACHED".
A member who can invite can also list and cancel the invitations they created — so they can copy an accept link after the fact or free a quota slot they no longer need. This is independent of memberInvitationsEnabled: even after an admin turns member invitations off, a member can still see and revoke invitations they already sent.
Listing and Canceling
const { items } = await client.invitations.list();
await client.invitations.delete(invitationId);let list = try await client.invitations.list()
let items = list.items
_ = try await client.invitations.delete(invitationId: invitationId)list is scoped by role: admins and owners see every invitation in the app, while a member sees only the ones they created themselves (an empty list, not an error, when they have none). delete follows the same rule — admins and owners can cancel any invitation, a member only their own (a 403 otherwise). delete cascades — any pending document shares, group adds, or collection adds attached to the invitation are removed in the same operation.
Sending Your Own Invitation Emails
The platform doesn't send invitation emails by default — you deliver the invitation yourself. To send a branded email from your own provider, drop the invitation's inviteToken into your own CTA URL and send it however you like. (To have the platform send its own invitation email instead, pass sendEmail: true when you create the invitation.)
const invitation = await client.invitations.create({
email: "alice@example.com",
role: "member",
sendEmail: false, // the default — no platform email is sent
});
const acceptUrl = `https://myapp.example/invite/accept?inviteToken=${invitation.inviteToken}`;
// Send `acceptUrl` to `invitation.email` from your own email provider.let invitation = try await client.invitations.create(
params: CreateInvitationParams(
email: "alice@example.com",
role: "member",
sendEmail: false // the default — no platform email is sent
)
)
let inviteToken = invitation.inviteToken ?? ""
let acceptUrl = "https://myapp.example/invite/accept?inviteToken=\(inviteToken)"
// Send `acceptUrl` to the invitee from your own email provider.To look up the token for an existing invitation later — e.g. on a "resend invite" button — use client.invitations.get(invitationId), which returns the full invitation including inviteToken.
Sharing with People Who Aren't Users Yet
Invitations carry more than app membership. When you share a document, add someone to a group, or add them to a collection by email, and that email isn't a user yet, the platform creates an invitation and remembers the pending grant. The same inviteToken pattern applies — those APIs return it on their deferred branch (status: "pending_signup"), so custom invitation emails work for every flow.
When the recipient becomes a user, everything waiting for them applies atomically:
// 1. Invite a teammate
await client.invitations.create({ email: "newhire@example.com", role: "member" });
// 2. Share a project document with them (pending until signup)
await client.documents.updatePermissions(projectDocId, {
email: "newhire@example.com",
permission: "read-write",
});
// 3. Add them to the engineering group (pending until signup)
await client.groups.addMember("team", "engineering", { email: "newhire@example.com" });
// When they sign up, all three apply in one transaction. They land in the
// app with team-group access and the project already shared with them.// 1. Invite a teammate
_ = try await client.invitations.create(
params: CreateInvitationParams(email: "newhire@example.com", role: "member")
)
// 2. Share a project document with them (pending until signup)
_ = try await client.documents.updatePermissions(
documentId: projectDocId,
params: .email("newhire@example.com", permission: "read-write")
)
// 3. Add them to the engineering group (pending until signup)
_ = try await client.groups.addMember(
groupType: "team",
groupId: "engineering",
params: .email("newhire@example.com")
)
// When they sign up, all three apply in one transaction. They land in the
// app with team-group access and the project already shared with them.How Invitations Resolve
There are two resolution paths — apps don't pick which one runs; the recipient does, by what they click and which email they sign in with.
Path A — Sign up with the invited email (automatic)
The common case. The recipient clicks the invite link, lands on your app, and signs up using the same email the invitation was sent to — with any sign-in method. The signup detects the email match and resolves every pending grant linked to that invitation in one transaction: app membership, document shares, group adds, collection memberships. No accept call needed.
Path B — Accept under a different identity (explicit)
The recipient is signed in (or wants to sign in) under a different email than the invitation was sent to — invited at work@example.com, signing in with home@gmail.com — or they're an existing user binding a fresh grant to their current account. The platform can't infer intent from the email, so the app calls accept explicitly with the invitation token:
const result = await client.invitations.accept(inviteToken);let result = try await client.invitations.accept(inviteToken: inviteToken)The invitation is marked accepted (write-once) and every grant linked to it is bound to the currently signed-in user, regardless of the email the invite was sent to.
What Your App Wires Up
The inviteToken carries the invitation across the wire. The platform's own invitation emails point recipients at ${baseUrl}/invite/accept?inviteToken=...; if you send your own emails you can put the token in any URL your app knows how to read.
Whatever URL the recipient lands on, the page handles three states:
- Signed-in invitee — confirm "Accept with this account?", then call
client.invitations.accept(inviteToken)and redirect into the app. - Signed-out invitee — stash the token (e.g.
sessionStorage), send them through your login flow, and pass the token to whichever auth verify call the user ends up on (magicLinkVerify,otpVerify,passkeyRegisterFinish,startOAuthFlow) — the server resolves the grants atomically with signup, no second click needed. - Errors — any invalid, expired, or already-used token returns
401 INVITE_TOKEN_INVALID; show one clear "this invitation is no longer valid" message with a path to request a new invite.
The web template ships all of this — a landing page mounted at /invite/accept plus the token carry-over wired into every auth method. If you're using the template, just point invitation emails at ${yourApp.baseUrl}/invite/accept?inviteToken=${token} and you're done. On iOS, resolve the incoming invite URL with client.links (see Deep links and universal links), then follow the same three states: accept(inviteToken) when signed in, or — when signed out — hold the token and pass it to a sign-in method that carries one: magicLinkVerify, otpVerify, passkeyRegisterFinish, signInWithGoogle(inviteToken:), or signInWithApple(inviteToken:) — each resolves the invitation atomically as part of a first sign-in, so no follow-up accept call is needed. A repeat sign-in from an existing Apple identity doesn't resolve inviteToken this way — call invitations.accept(inviteToken) once signed in instead.
Domain Re-Validation
In domain mode, pending grants are re-validated at resolution time. A share to alice@external.com won't land if the app only accepts @mycompany.com — the invitation is rejected at signup rather than granting access silently.
Cascade on Revoke
Revoking an invitation also removes every pending grant attached to it — there's no risk of an orphan share activating after you change your mind.
Next Steps
- Working with Documents — Sharing the documents your invitees will land in
- Users and Groups — Group membership and roles
- Authentication — The sign-in flows that consume invitations