Sharing and Invitations
Primitive apps are multi-user. This page is about getting other people into your app and giving them access to your data — invitations, document sharing, group membership, access requests, and bookmarks.
The building blocks work together. Most real apps end up using several of them:
| Mechanism | What it does |
|---|---|
| App invitations | Bring someone new into your app |
| Member invitations | Let non-admin members invite teammates (with quotas) |
| Document sharing | Grant document-level access to a user, email, or group |
| Group membership | Bulk access via team/role membership |
| Email-based sharing | Share by email to someone who isn't a user yet — resolves when they sign up |
| Document access requests | "Request access" flow for users with a link |
| Bookmarks | User-curated list of documents, databases, or any target |
App Invitations
App invitations are how new users get into your app.
Admin Invitations
Admins and app owners can always invite new users — no configuration needed:
primitive users invite alice@example.comOr from your app:
await client.invitations.create({
email: "alice@example.com",
role: "member",
});The invitee receives an email. 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 Accepting an Invitation with a Different Email).
Member Invitations (with Quotas)
By default only admins can invite. If you want regular members to invite teammates, enable member invitations:
primitive apps update --member-invitations-enabled --member-invitation-limit 5or in the admin console under 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 |
Admins and owners are always exempt from the quota.
Members check their quota before showing an invite UI:
const quota = await client.invitations.quota();
// { used: 2, limit: 5, remaining: 3, unlimited: false }
if (quota.unlimited || quota.remaining > 0) {
showInviteButton();
}Attempting to invite over the quota returns a 403 INVITATION_QUOTA_EXCEEDED error.
Only role: "member" is allowed from members
Members cannot invite other admins, even when member invitations are enabled. If a member passes role: "admin", the server rejects the request.
Listing and Canceling Invitations
const { items } = await client.invitations.list();
await client.invitations.delete(invitationId);delete cascades — any pending email-based document shares or group adds attached to the invitation are removed in the same operation. There is no separate revoke() method.
Sending Your Own Invitation Emails
By default the platform sends invitation emails. If you'd rather send branded emails from your own ESP, every invitation exposes a tokenized inviteToken that you can drop into a CTA URL:
const invitation = await client.invitations.create({
email: "alice@example.com",
role: "member",
sendEmail: false, // suppress the platform email
});
const acceptUrl = `https://myapp.example/invite/accept?inviteToken=${invitation.inviteToken}`;
await myEmailService.send({ to: invitation.email, link: acceptUrl });The same inviteToken is also surfaced inline on the deferred entries returned by client.documents.updatePermissions({ email }) and client.groups.addMember({ email }), so the same custom-email pattern works for share-by-email and add-to-group flows. To look up the token for an existing invitation later — e.g. on a "resend invite" button — use:
const token = await client.invitations.getAcceptToken(invitationId);
// { invitationId, inviteToken, email, expiresAt, accepted, acceptedAt, status }For details on what happens when the recipient clicks the link — auto-resolution on email match, explicit accept under a different identity, and the landing-page wiring your app needs (or what the template ships out of the box) — see How Email-Based Sharing Resolves below.
Document Sharing
Documents are private by default. You share them by granting a permission level to a user, an email, or a group.
Permission Levels
| Permission | Can View | Can Edit | Can Share | Can Delete |
|---|---|---|---|---|
reader | Yes | |||
read-write | Yes | Yes | ||
owner | Yes | Yes | Yes | Yes |
Share by User ID
await client.documents.updatePermissions(documentId, {
userId: "user-abc",
permission: "read-write",
});Share by Email
The most common case — you have a colleague's email but don't know (or care) whether they've signed up yet:
await client.documents.updatePermissions(documentId, {
email: "alice@example.com",
permission: "read-write",
});Two paths:
- Existing user — the server resolves the email to a userId and grants access immediately.
- Non-member — the server creates an invitation (if one doesn't exist) and remembers the pending share. The recipient receives a share email when
sendEmail: trueis passed (existing members get thedocument-sharetemplate; non-members get thedocument-share-deferredtemplate, which carries a tokenized accept URL composed fromapp.baseUrl). When they sign up with that email, the share is applied automatically. Repeated calls for the same recipient are idempotent — the latestpermissionvalue wins and only one pending entry is tracked.
Batch shares can mix both forms:
await client.documents.updatePermissions(documentId, {
permissions: [
{ userId: "user-abc", permission: "read-write" },
{ email: "alice@example.com", permission: "reader" },
{ email: "bob@example.com", permission: "read-write" },
],
});When you set sendEmail: true, you also need documentUrl in the request and app.baseUrl configured on the app — the server uses both to compose the share/accept links.
Share with a Group
Grant document access to everyone in a group. When group membership changes, access updates automatically:
await client.documents.grantGroupPermission(documentId, {
groupType: "team",
groupId: "engineering",
permission: "read-write",
});Checking Who a Document Is Shared With
const result = await client.users.lookup("alice@example.com");
// { exists: true, user: { userId, name, email } } | { exists: false }See Working with Documents for document fundamentals.
Group Membership by Email
Groups support the same email-based pattern for invitations:
const result = await client.groups.addMember("team", "engineering", {
email: "alice@example.com",
});The result is a discriminated union — branch on result.status to drive your UI:
status | Meaning |
|---|---|
"added" | Email matched an existing user; new membership row was just created |
"already_member" | Email matched an existing user who was already in the group (idempotent — no error) |
"pending_signup" | Email is not yet an app user; a deferred add was created and will resolve at signup or token-acceptance |
The "pending_signup" branch carries invitationId and inviteToken so you can plug them into a custom invitation email if you're not using the platform's default email path.
See Users and Groups for more on groups.
How Email-Based Sharing Resolves
When you share by email to someone who isn't a user yet, Primitive internally records the pending grant and resolves it as soon as the right person redeems the invitation. The sharing APIs accept emails transparently — the platform handles the rest.
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 user clicks the invite link and signs up using the same email the invitation was sent to:
- You share a document with
newhire@example.comatread-write. The platform creates anAppInvitation+DeferredDocumentPermissionand sends them an email. - They click the link, land on your app, and sign up using
newhire@example.com(magic link, OTP, Google, passkey — any method). - The signup flow detects the email match and resolves every pending deferred grant linked to that invitation in one transaction —
AppMembership,DocumentPermission,DocumentGroupPermission, etc. The document is auto-bookmarked. Noacceptcall is needed.
This is what runs whenever the recipient's signup email matches the invited email. Your app does not need to call client.invitations.accept(...) for this case.
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 — for example, invited at work@example.com but signing in with their personal home@gmail.com — or they're an existing user who wants to bind a fresh deferred grant to their current account. In both cases the platform can't infer intent from the email, so the app calls accept explicitly:
const result = await client.invitations.accept(inviteToken);
// { status: "accepted", invitationId, grantsResolved: { groups, documents } }The invitation is marked accepted (write-once) and every deferred grant linked to it is bound to the currently signed-in user — regardless of the email the invite was sent to. AppInvitation.acceptedByUserId records the user that actually accepted, which may differ from the invited email.
What your app needs to wire up
The inviteToken is what carries the invitation across the wire — every share-by-email flow returns one (client.invitations.create({ email }), the deferred branch of client.documents.updatePermissions({ email }), and client.groups.addMember({ email }) all expose inviteToken on the response). Your app decides where to send the recipient: the platform's default email templates use a conventional URL shape of ${app.baseUrl}/invite/accept?inviteToken=..., but that's a convention the templates use — not a platform-enforced shape. If you send your own email (sendEmail: false plus your own ESP, or a custom email template), you can drop the token into any URL you like as long as the page on the other end knows how to read it.
Whatever URL you land them on, the page needs to handle three states:
- Signed-in invitee — show "Accept with this account?", confirm, then call
client.invitations.accept(inviteToken)and redirect into the app. - Signed-out invitee — stash the token in
sessionStorage, redirect to the login flow, and pass the token through to whichever auth method the user picks (magic link, OTP, passkey, OAuth) so the server resolves grants in the same round-trip — no second click needed after signup. - Errors —
INVITE_TOKEN_INVALID,INVITE_TOKEN_EXPIRED,INVITE_ALREADY_ACCEPTEDeach need their own UI. Don't show raw API errors.
The primitive-app-template ships a working implementation of all three — src/pages/InviteAcceptPage.vue (the landing page, mounted at /invite/accept) and src/lib/inviteToken.ts (the sessionStorage carry-over) wired into userStore so each auth method forwards the pending token automatically. If you're using the template, this is already done — point your invitation emails at ${yourApp.baseUrl}/invite/accept?inviteToken=${token} (the convention the template's router expects). If you let the platform send the default email and your app.baseUrl is configured, this is also the URL shape the default templates use, so the two line up out of the box.
If you're hand-rolling the flow (custom email + custom landing page), the steps:
- Pick a URL shape that suits your app. Anything that round-trips the
inviteTokenworks — the platform doesn't enforce a specific path. The token can ride in a query string, a fragment, or as part of a short-link redirect — your call. - Render that URL inside your custom email body (or inline magic-link page), substituting the
inviteTokenyou got back fromclient.invitations.create({ email, sendEmail: false }), the deferred branch ofclient.documents.updatePermissions({ email }), orclient.groups.addMember({ email }). - Build a route in your app that reads the token from wherever you put it.
- On mount, branch on signed-in status:
- Signed in: confirm with the user (so they don't bind grants to the wrong account), then
await client.invitations.accept(inviteToken). - Signed out: save the token in
sessionStorage(primitive:pendingInviteTokenis the convention used by the template) and redirect to your login page.
- Signed in: confirm with the user (so they don't bind grants to the wrong account), then
- In your auth flow, after the user picks a method (magic link / OTP / passkey / OAuth), read the pending token from
sessionStorageand pass it as theinviteTokenargument to the corresponding verify/finish call (magicLinkVerify,otpVerify,passkeyRegisterFinish,startOAuthFlow). The server resolves grants atomically with signup. - Clear the token from
sessionStorageon success or on a clear error path. - Validate token shape before accepting — the template uses a loose check (
isPlausibleInviteTokeninsrc/lib/inviteToken.ts) to avoid round-tripping obvious garbage.
The primitive-app-template and primitive-app-demo projects in Primitive-Labs/primitive-app-dev both implement this end-to-end — read primitive-app-template/src/pages/InviteAcceptPage.vue together with primitive-app-template/src/lib/inviteToken.ts and primitive-app-template/src/stores/userStore.ts to see all the wiring in one place.
Domain-Mode Apps
If your app restricts signup to specific email domains, pending shares 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 share or group add that was attached to it — there's no risk of an orphan share activating after you change your mind.
Document Access Requests
When a user has a document link (or ID) but no permission, they can request access. This is the Google-Docs-style "Request access" flow.
How It Works for the Requester
A 403 from client.documents.get(documentId) includes a canRequestAccess hint when the document is configured to accept access requests:
try {
await client.documents.get(documentId);
} catch (err) {
if (err.code === "DOC_ACCESS_DENIED" && err.details?.canRequestAccess) {
// Show a "Request access" button
}
}Submitting a request:
await client.documents.requestAccess(documentId, {
message: "Working on the Q2 planning deck",
});The requester receives an access-request-created email confirmation. Document owners and app admins receive a WebSocket event (document:access-request-created) so their UI can show a badge immediately.
How It Works for Owners/Admins
// List pending requests for a document
const { requests } = await client.documents.listAccessRequests(documentId);
// Approve — grants the requested permission
await client.documents.approveAccessRequest(documentId, requestId, {
permission: "read-write",
});
// Deny
await client.documents.denyAccessRequest(documentId, requestId, {
reason: "Please email sales instead",
});When resolved, the requester receives:
- A
document:access-request-resolvedWebSocket event (so the UI can update live if they're still in the app) - An
access-request-resolvedemail with the outcome
Rate Limiting and TTL
- Access requests have a 30-day TTL and are indexed per document and per requester.
- A requester cannot re-submit while a previous request for the same document is still pending.
- Once a request is resolved (approved or denied), it can't be re-resolved.
Bookmarks
Bookmarks are how users curate their own list of "things I care about" — documents they've been shared into, databases they work in, workflows they use, anything.
What Bookmarks Are (and Aren't)
Bookmarks are presentational, not access control. A bookmark is just a pointer that says "this user wants this item on their home screen." The underlying document/database permissions are still what decides access.
The model is intentionally generic:
- Primary key:
userId - Sort key:
userDefinedKey(arbitrary string you pick — supports prefix queries) - Fields:
targetObjType(e.g."document","database"),targetObjId
Client API
// Add a bookmark — targetObjType is "d" for documents (single-letter prefix).
await client.me.bookmarks.add({
targetObjType: "d",
targetObjId: documentId,
key: "projects/acme/q2-planning", // optional; hierarchical keys enable prefix queries
});
// List with prefix — this is the call to power your "my documents" UI.
const { bookmarks, nextCursor } = await client.me.bookmarks.list({
prefix: "projects/acme/",
});
// Rename (change the user-defined key)
await client.me.bookmarks.rename("projects/acme/q2-planning", "archived/q2-planning");
// Remove
await client.me.bookmarks.remove("archived/q2-planning");Bookmarks are the primary "my documents" surface — the user-curated list, independent of permissions. Users can drop a bookmark they no longer want without losing access, and add bookmarks for shares they want to keep handy.
Auto-Bookmarking
The platform pre-populates bookmarks for two common cases so apps don't have to:
- Document creation — when a user creates a document, it's bookmarked automatically.
- Deferred grant resolution at signup — when a recipient signs in with the email a share or group-add was sent to, the platform creates the appropriate
AppMembership/DocumentPermissionrows AND auto-bookmarks any documents that resolved through that flow.
Direct grants to existing users (sharing by userId) and group/collection-shared documents are NOT auto-bookmarked — those land in client.me.sharedDocuments() until the recipient bookmarks them. Bookmarks are also auto-removed when access is revoked.
Shared Documents Helper
client.me.sharedDocuments() returns the inbox view — documents directly shared with the user that aren't yet on their curated list:
const { documents } = await client.me.sharedDocuments();It returns docs the user has a non-owner DocumentPermission on plus pending document invitations. Group- and collection-shared documents do not appear here — those become visible to the user via group/collection listings or by bookmarking them. Use sharedDocuments when you want an "inbox of recent shares"; use me.bookmarks.list for the primary "my documents" surface.
WebSocket Events
Sharing and invitations emit events so your UI can update without polling:
| Event | Fires when |
|---|---|
invitation/accepted | A user accepts an invitation (including acceptance via a document GET) |
document:access-request-created | A user requests access to a document you own or admin |
document:access-request-resolved | A request you submitted was approved or denied |
Subscribe as you would any client event:
client.on("invitation", (event) => {
if (event.type === "accepted") {
refreshMembersList();
}
});
client.on("document:access-request-created", (event) => {
showAccessRequestBadge(event.documentId);
});Building a "Members + Pending" Panel
Sharing UIs typically show two sections: people who currently have access, and people who've been invited but haven't signed up yet.
Current Members
For a document:
const members = await client.documents.getPermissions(documentId);
// [{ userId, email, name, avatarUrl, permission, grantedAt }, ...]For a group:
const members = await client.groups.listMembers(groupType, groupId);
// [{ userId, userName, userEmail, addedAt, addedBy }, ...]Pending Invitations
App-wide:
const { items: invitations } = await client.invitations.list();
const pending = invitations.filter(i => !i.accepted);
// [{ invitationId, email, role, invitedAt, expiresAt, source, inviteToken, ... }]Per-resource — for "this specific document" or "this specific group" panels:
const docPending = await client.documents.listPendingInvitations(documentId);
// [{ email, permission, invitationId, createdAt, expiresAt, grantedBy? }]
const groupPending = await client.groups.listPendingInvitations(groupType, groupId);
// [{ email, role, invitationId, createdAt, expiresAt, addedBy? }]Canceling a Pending Invitation
To withdraw a pending invitation — and any pending document shares or group adds attached to it — delete the invitation itself:
await client.invitations.delete(invitationId);Removing Someone
The removal APIs handle both "currently has access" and "was invited but hasn't signed up yet" through a single call.
Document:
// Existing user — by userId
await client.documents.removePermission(documentId, userId);
// By email — removes a current member if one matches, OR cancels the
// pending deferred share for that email if no direct grant exists.
await client.documents.removePermission(documentId, { email: "alice@example.com" });Group:
// Existing member — by userId
await client.groups.removeMember(groupType, groupId, userId);
// By email — removes the membership if one exists, OR cancels the
// pending DeferredGroupAdd for that email if no direct membership does.
await client.groups.removeMember(groupType, groupId, { email });Use the email form whenever you don't want to think about whether the target has signed up yet — it does the right thing in either case. Revoking the whole AppInvitation is only needed when you want to cancel every grant attached to that invitation (the document share and the group add and the right to join the app).
A Worked Example
A typical "invite a teammate and share a project with them" flow:
// 1. Member (quota-checked) invites a teammate
const quota = await client.invitations.quota();
if (!quota.unlimited && quota.remaining <= 0) return showUpgradePrompt();
await client.invitations.create({
email: "newhire@example.com",
role: "member",
});
// 2. Share the project document with them (pending until signup)
await client.documents.updatePermissions(projectDocId, {
email: "newhire@example.com",
permission: "read-write",
});
// 3. Also add them to the engineering group (pending until signup)
await client.groups.addMember("team", "engineering", {
email: "newhire@example.com",
});
// When the new user signs up, all three pending actions apply atomically.
// They land in the app with the project bookmarked and full team-group access.Next Steps
- Working with Documents — Document fundamentals and CRUD
- Users and Groups — Group concepts and CEL access patterns
- Authentication — How users sign in and pending shares resolve