Skip to content

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 ​

ModeWho can join
publicAnyone can sign up
invite-onlyOnly people with an invitation
domainAnyone 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:

bash
primitive config set app app.mode=invite-only
primitive config push --only app

The 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:

bash
# 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 10

Inviting Users ​

Admins and app owners can always invite:

bash
primitive users invite alice@example.com

Or from your app:

ts
await client.invitations.create({
  email: "alice@example.com",
  role: "member",
});
swift
_ = 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:

FieldMeaning
memberInvitationsEnabledIf true, users with role "member" can create invitations
memberInvitationLimitMax 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):

toml
# primitive/dev/app.toml
[invitations]
enabled = true
limit = 5
bash
primitive config push --only app

Admins 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:

ts
const quota = await client.invitations.quota();
// { used: 2, limit: 5, remaining: 3, unlimited: false }

const canInvite = quota.unlimited || quota.remaining > 0;
swift
let quota = try await client.invitations.quota()
// InvitationQuota(used: 2, limit: 5, remaining: 3, unlimited: false)

let canInvite = quota.unlimited || quota.remaining > 0

Inviting 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 ​

ts
const { items } = await client.invitations.list();

await client.invitations.delete(invitationId);
swift
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.)

ts
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.
swift
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:

ts
// 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.
swift
// 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:

ts
const result = await client.invitations.accept(inviteToken);
swift
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:

  1. Signed-in invitee — confirm "Accept with this account?", then call client.invitations.accept(inviteToken) and redirect into the app.
  2. 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.
  3. 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 ​

Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30