[data-reveal]{opacity:1!important;transform:none!important}
Invites
Interfaces

Invites

An invite brings a person into a tenant by email.

Overview

An invite brings a person into a tenant by email. inviteUserToTenant is the entry point: when the email already belongs to a user it grants them membership directly, and otherwise it creates a token-bearing invite row the person redeems later. The two outcomes are distinguished by the kind field of the result.

The functions live in @antelopejs/interface-dms/invites; the UserInvite table and its model live in @antelopejs/interface-dms/db.

Invite a User

import { inviteUserToTenant } from "@antelopejs/interface-dms/invites";

const result = await inviteUserToTenant({
  tenantId,
  email: "[email protected]",
  roleIds: ["role-editor"],
  asTenantOwner: false,
  sendEmail: true,
});

if (result.kind === "added") {
  // an account with that email existed: membership granted, no token
  return { userId: result.userId };
}

// no account yet: an invite row was created
return { inviteId: result.inviteId, token: result.token };

InviteUserToTenantOptions

OptionTypeDescription
tenantIdstringTenant the person is invited into. Required.
emailstringEmail to look up and, failing that, to invite. Required.
firstnamestring | nullInvitee's first name, stored on the invite row and used to address the email. Defaults to null.
lastnamestring | nullInvitee's last name. Defaults to null.
languagestringLanguage stored on the invite row. Defaults to "en".
roleIdsstring[]Roles granted on join. Defaults to [].
asTenantOwnerbooleanGrant tenant ownership on join. Defaults to false.
sendEmailbooleanDispatch the admin invite email after the row is created. Defaults to false.
awaitEmailDeliverybooleanWith sendEmail, wait for the email and report its outcome as emailDelivery instead of sending it in the background. Defaults to false.
inviterNamestringName of the sender shown in the invitation email.
invitedBystring | nullId of the inviting user, stored on the invitation.
extensionsInviteExtensionPayloadsPayloads keyed by registered invite extension key.

InviteUserToTenantResult

type InviteEmailDelivery = "sent" | "failed";

type InviteUserToTenantResult =
  | { kind: "added"; userId: string }
  | InvitedUserResult;

interface InvitedUserResult {
  kind: "invited";
  inviteId: string;
  token: string;
  emailDelivery?: InviteEmailDelivery; // only with sendEmail + awaitEmailDelivery
}

The "added" branch runs applyTenantOwnership with roleIds and asTenantOwner (Tenancy), so the member hooks fire and no token is generated. The "invited" branch creates the invite row through createUserInviteToken, then dispatches the email when sendEmail is set. By default that is fire-and-forget: the call does not wait on delivery and does not fail when delivery does.

Pass awaitEmailDelivery: true when someone is waiting on the answer and has to learn that the email did not leave. The call then waits for the email and sets emailDelivery to "sent" or "failed". It still does not throw: the invitation is kept either way, so the caller can offer to resend it. The failure's details (the provider's error) go to the server log only; tell the user something generic. The DMS members Invite form and the pending invitations' Resend action both work this way.

const result = await inviteUserToTenant({
  tenantId,
  email: "[email protected]",
  sendEmail: true,
  awaitEmailDelivery: true,
});

if (result.kind === "invited" && result.emailDelivery === "failed") {
  // the invite exists, but its email did not leave: say so, offer a resend
}

The function is deliberately side-effect-light: it does not throw on duplicates and performs no context-specific pre-checks. Decide in the caller what "already a member" should answer.

Create an Invite Token Directly

createUserInviteToken is the lower-level half — it always creates a row and never touches memberships or email.

import { createUserInviteToken } from "@antelopejs/interface-dms/invites";

const { inviteId, token } = await createUserInviteToken({
  tenantId,
  email: "[email protected]",
  language: "en",
  roleIds: [],
  asTenantOwner: false,
});

CreateUserInviteTokenOptions requires tenantId, email, language, roleIds and asTenantOwner; firstname and lastname are optional and default to null. Optional invitedBy records the sender and extensions carries InviteExtensionPayloads. replacementReason selects "replaced" (default) or "resent" when retiring an existing invitation. CreateUserInviteTokenResult is { inviteId: string; token: string }.

Optional replacesInvite: UserInvite identifies the exact incarnation being replaced, including a retained snapshot during a retry.

inviteeDisplayName(firstname?, lastname?) joins the two into the name the invite email addresses the person by, and returns undefined when the invite carries neither. inviteUserToTenant calls it for you; call it yourself when you render your own invite surface from a stored row, so the fallback for a nameless invite matches what the email does.

An existing invite for the same email in the same tenant is retired through a durable decision. Completion invokes Hook.INVITE_DELETED for the old invitation, then creates the replacement through Hook.INVITE_BEING_CREATED and Hook.INVITE_CREATED, and removes the old row. Failed completion can replay hooks with the same delivery identity; see durable invite resolution and Hooks.

Token Constants

ConstantValueMeaning
INVITE_TOKEN_LENGTH64Length of the generated token.
INVITE_EXPIRY_DAYS7Days from creation to expiresAt.
import {
  INVITE_EXPIRY_DAYS,
  INVITE_TOKEN_LENGTH,
} from "@antelopejs/interface-dms/invites";

The UserInvite Table

Table user_invites in the dms-tenant schema, so invites are per-tenant like memberships. The table-name constant USER_INVITES_TABLE_NAME is exported next to it.

FieldTypeNotes
_idstringInvite id — the inviteId the create functions return.
createdAtDateCreation time, indexed.
emailstringIndexed.
firstnamestring | nullInvitee's first name, when the invite carried one.
lastnamestring | nullInvitee's last name, when the invite carried one.
roles_idsstring[]Related to Role, many. Roles to grant on redemption.
languagestringLanguage the invite was issued in.
tokenstringIndexed. The secret handed to the invitee.
asTenantOwnerbooleanGrant tenant ownership on redemption.
expiresAtDateIndexed. INVITE_EXPIRY_DAYS after creation.

Note the field is roles_ids, not roleIds — the membership field of that name lives on TenantMember (Tenancy).

UserInviteModel

UserInviteModel extends BasicDataModel(UserInvite, USER_INVITES_TABLE_NAME):

MethodReturns
getByEmail(email)Promise<UserInvite | undefined>
getByToken(token)Promise<UserInvite | undefined>
getByTokenWithTenant(token)Promise<{ invite: UserInvite; tenantId: string } | undefined>
deleteByToken(token)Deletes every row carrying the token

Invitations are stored with their e-mail trimmed and lowercased, and getByEmail normalizes its argument the same way, so the casing a caller passes does not matter.

import { GetModel } from "@antelopejs/interface-database-decorators";
import { UserInviteModel } from "@antelopejs/interface-dms/db";

export async function hasExpiredInvite(tenantId: string, email: string): Promise<boolean> {
  const invite = await GetModel(UserInviteModel, tenantId).getByEmail(email);
  return invite !== undefined && invite.expiresAt.getTime() < Date.now();
}

getByTokenWithTenant reports the tenant the row came from, which is what a redemption flow needs: the person following an invite link is not authenticated yet, so there is no tenant claim to bind the handle to.

Raw deletion, including deleteByToken, does not coordinate durable decisions or extension cleanup. Use the invite-resolution contract below for terminal actions, not direct model writes.

RegisterInviteExtension

RegisterInviteExtension<T>(options: InviteExtensionOptions<T>): () => void, from @antelopejs/interface-dms/invite-extensions, contributes fields to the members invite form. Registration belongs to the module and is removed when it stops; the returned function removes it earlier.

OptionTypeContract
keystringRequired non-empty namespace. Must not contain __, the field-id separator.
componentFormBuilderRequired form whose fields join the invite modal.
schemaZodType<T, ZodTypeDef, unknown>Required validator at submission and delivery. Uses unprefixed field ids; callbacks receive parsed output T.
onAcceptInviteAcceptHandler<T>Required callback after membership exists.
onCleanupInviteCleanupHandler<T>Optional cleanup on cancellation, replacement, expiry or member removal, never acceptance.
editablebooleanDefaults to true. false makes fields read-only on the pending-invitation edit form.
onUpdateInviteUpdateHandler<T>Called after a changed pending payload is stored. Failure is logged, not raised to the caller.
labelstringHeading, defaulting to the form's title.
descriptionstringDescription of the contribution.
placementInviteExtensionPlacementOptional side ("before", "after", "end"), anchorField, and numeric order. Defaults to end, order 0.

anchorField names a top-level field or group of the invite form. Without an anchor, before places the contribution first; after and end append it. Lower orders come first, with extension keys breaking ties. Form field ids are namespaced by key; stored InviteExtensionPayloads is Record<string, unknown>, keyed by extension.

Callback contracts

CallbackArgumentsReturn
InviteAcceptHandler<T>(payload: T, member: TenantMember, context: InviteExtensionContext)MaybePromise<void>
InviteUpdateHandler<T>(payload: T, previous: T | undefined, context: InviteExtensionContext)MaybePromise<void>
InviteCleanupHandler<T>(payload: T | undefined, context: InviteCleanupContext)MaybePromise<void>

InviteExtensionContext has tenantId, email, and optional deliveryId and inviteId. Direct addition of an existing account calls onAccept without an inviteId. previous is undefined when no old payload exists or it no longer validates.

InviteCleanupContext has tenantId, reason: "invite-deleted" | "member-removed", and optional deliveryId, inviteId, email, and userId. Invite cleanup identifies the invitation; member cleanup identifies the user. Durable delivery may replay concurrently with the same deliveryId: deduplicate external effects and scope invite resource cleanup to inviteId. See durable delivery guidance for operational obligations.

Durable Invite Resolution

The functions in @antelopejs/interface-dms/invite-resolution coordinate competing terminal actions. They do not authorize the caller. Prefer the higher-level invitation entry points unless implementing a terminal action yourself.

FunctionSignature and result
loadInviteForAction(tenantId: string, inviteId: string) => Promise<UserInvite | undefined> — loads the exact invitation or its durable snapshot after the live row is gone.
assertInviteReady(tenantId: string, invite: UserInvite) => Promise<void> — rejects with HTTP 409 while a replacement's creation is incomplete.
decideInvite(input: ResolveInviteInput) => Promise<InviteResolution> — records one permanent terminal outcome before effects. Conflicting reason, user or replacement content rejects with HTTP 409. A new acceptance of an expired invitation also rejects.
completeInviteResolution(resolution: InviteResolution) => Promise<void> — enters tenant lifecycle admission and replays effects; callback failures retain the snapshot for retry.

ResolveInviteInput contains tenantId, invite: UserInvite, reason: InviteResolutionReason, and optional userId and replacement: UserInvite. InviteResolutionReason is "accepted" | "cancelled" | "replaced" | "resent" | "expired". The API captures extension keys itself; callers do not supply them.

InviteResolution (from /db/tables/inviteResolutions.table) is the stored decision and retry snapshot: _id, tenantId, reason, nullable userId, invite, optional inviteToken, nullable replacement, extensionKeys, revision, membershipPhase, replacementPhase, completed, and decidedAt. Both phase fields use InviteMembershipPhase ("pending" | "applying" | "applied"). Treat these as read-only results and pass the decision back to completion; do not mutate the model to advance phases.

Custom membership/invite producers must follow tenant lifecycle admission, and all competing terminal paths must decide before producing effects. A durable decision does not make external effects exactly-once; callback implementations still own idempotency.

Mark the Invite Action Unavailable

RegisterInviteAvailability, from @antelopejs/interface-dms/invite-extensions, lets a module disable the members page's Invite button while one of its conditions holds — a plan whose seats are all taken, say. The resolver runs per request while the page layout is built, with { tenantId, user }; returning { reason } disables the button and shows the reason next to it, undefined leaves it enabled. The first resolver to refuse wins, and one that throws is logged and ignored.

import { RegisterInviteAvailability } from "@antelopejs/interface-dms/invite-extensions";

RegisterInviteAvailability(async ({ tenantId }) =>
  (await hasFreeSeat(tenantId))
    ? undefined
    : { reason: "$acme.seats.invite_unavailable" },
);

It only shapes the UI. A module that must refuse the invitation itself does so from Hook.INVITE_BEING_CREATED and Hook.MEMBER_BEING_ADDED. The registration is lifted when the registering module stops; the returned function removes it earlier.

Next Steps

Hooks covers the invite and membership events these functions fire, and how to react to them.