Core SDKGuides

Invitations

Authorize, inspect, and end an Organization invitation with the Core SDK and the CLI.

An invitation offers exact authority in one Organization to one exact grantee. This guide covers the Core SDK and CLI surface that exists today.

Acceptance is the grantee's explicit consent

The exact grantee accepts an authorized offer with the offer's current digest. Consent is recorded once, and the offer then moves to pending_grant while its grant is executed automatically by the deployment's technical executor. Neither the grantee nor an Admin signs anything for the acceptance itself. Read the settled active state through get or wait; see Acceptance below.

The SDK surface

Every method returns Promise<CapxulResult<T>> and accepts the shared signal options.

CallResultNotes
org(orgId).invitations.preview(input, options?)InvitePreviewRead only. It writes nothing.
org(orgId).invitations.invite(input, options?)InvitationViewAuthorizes one exact offer and executes its configuration hop.
org(orgId).invitations.accept({ invitationId, expectedOfferDigest })InvitationViewThe exact grantee consents; starts the automatic grant.
org(orgId).invitations.list({ cursor?, limit?, phases? })InvitationPageThe Organization's invitations; Admin only.
org(orgId).invitations.get({ invitationId }, options?)InvitationViewOne invitation in the named Organization.
org(orgId).invitations.decline({ invitationId })InvitationViewThe exact grantee ends the offer.
org(orgId).invitations.cancel({ invitationId })InvitationViewThe current Admin ends the offer.
org(orgId).invitations.resend({ invitationId })InvitationViewRetries delivery of a verified pending offer.
org(orgId).invitations.retry({ invitationId })InvitationViewResumes the current offer state.
orgs.invitations({ cursor?, limit?, phases? }, options?)InvitationPageYour own offers, with a versioned cursor.

invite takes the bounded request:

type InviteInput = {
  readonly requestKey: string;
  readonly to:
    | { readonly type: "email"; readonly email: string }
    | { readonly type: "account"; readonly accountId: AccountId };
  readonly grants: readonly InviteGrant[];
};

preview takes the same request without requestKey. The key is write identity, so it takes no part in a read. The bounds are 1–16 distinct authorities, at most four new Budgets, 1–32 recipient Accounts per allowlist, and a requestKey of 1–128 printable ASCII characters.

The same requestKey replays the same invitation. A different request under the same key is refused. Use a new key only when you intend a new offer.

The configuration hop

invite does not stop at the durable offer. After the write persists the invitation and its configure command, the method drives that command through the same human UserOperation owner the Permission and payment writes use: it prepares the stored command for this Account's Safe, delivers the durable identity to onPrepared, signs the returned digest with the client's signer, and submits it.

The client must therefore be execution-capable. In Node, compose it with the package's signer entry:

import { localPrivateKeySigner } from "@capxul/sdk/node";

const client = await createCapxulClient({
  // …
  signer: localPrivateKeySigner({ privateKey }),
});

onPrepared receives { invitationId, commandId }. It runs after the prepared authority has been checked and before any digest is signed, and the write awaits it: a caller that cannot record the identity does not authorize the execution. A client composed without a signer refuses invite with NOT_IMPLEMENTED before it writes anything, because an offer that cannot be configured can never leave authorizing.

On a fresh request the view invite returns is the authorization-time view: authorizing, with its commandId and its configuration_pending recovery. The offer reaches pending with authorizedAt and expiresAt only when the configuration verification lands, after the call returns — a re-read here would show the same authorizing view. Read the settled state through get or wait. A settled replay is the exception: a requestKey whose invitation has already left authorizing returns that durable current view unchanged, and composes no signer.

The replay rules do not change: call invite again with the same requestKey. The same invitation and the same stored execution are reused, and a prepared response lost in transit is recovered by the same identity.

org(orgId).invitations.list is the Admin view of one Organization. It reads every invitation of that Organization, including offers another Admin authored. A current Admin gets the page; a member without management authority and a caller outside the Organization get the existing typed refusal, never a page. The page uses the same versioned cursor as the own-offer list, and every row is checked against the Organization the bundle was resolved for.

orgs.invitations is the grantee projection. It never consults a saved Organization: the session decides whose offers it returns.

The invitation lifecycle

InvitationView.phase is the durable state. The phases are authorizing, pending, pending_grant, active, revoking, declined, cancelled, expired, and revoked.

  • authorizing means the offer and its configuration command are durable, and the Admin's configuration transaction is not applied yet.
  • pending means the offer is authorized and can be accepted within its expiry.
  • pending_grant means the grantee accepted: acceptedAt and the accepted digest are durable, and the automatic grant execution is in flight. It does not expire while it waits, and a provider outage leaves it here with consent intact.
  • active means the grant receipt verified and the exact offered assignments were applied.
  • The four terminal phases are declined, cancelled, expired, and revoked.

recovery names the next responsible actor and action. delivery reports not_confirmed, sent, or failed. sent means the configured email owner durably accepted the message for delivery; it is not proof of inbox arrival, and a later provider outcome does not change the field. resend attempts one delivery message through the configured email provider and reports that attempt's outcome: sent when the provider accepted the message, failed otherwise. It does not extend expiry and does not change consent.

decline and cancel are idempotent for the same ending reason. decline requires the exact grantee. cancel requires current Admin authority. Both start cleanup. A request in a different state is refused with WRONG_STATE.

The offer carries a live policy, not a copy

An offered Budget resolves against the Permission identity that exists now. If an Admin changes that same Budget policy before acceptance, the accepted authority follows the current policy. The offerDigest on the view is the value acceptance must name, and it changes when the policy changes. A different Budget, a different asset, or a different grantee requires a newly authorized invitation.

InvitationView.offers carries the current label and policy. grants carries the exact public authorities. offerDigest covers asset, cap, action, and destination facts. Labels, Account attachment, readiness, and timestamps are excluded from the digest.

Account readiness is independent

InvitationView.grantee.accountReady is nullable, and it is independent of the invitation's phase and of the authority being granted. true means the grantee's Account is deployed and claimed. false means it is not. null means no Account is attached yet, or its readiness is not known.

A grantee without an Account can hold an authorized or active invitation, and an invitation can stay pending while the grantee's Account is ready. Readiness never grants authority, and an offered authority never implies a ready signer. Read accountReady for the signer question and phase for the authority question; do not derive one from the other.

Trust boundary

An invited person never receives Admin authority through this surface. The grant application uses a dedicated technical executor with one-use rights, and that executor cannot change policy. The offer you authorize is the exact authority the grantee can receive.

Operational receipts and pending work

Invitation work is nonfinancial. The result shows the offer state: a commandId for the configuration command, authorizedAt, acceptedAt, and expiresAt. A transaction hash is not an accepted invitation, and an authorizing or pending_grant phase is not active membership.

retry returns the current view or a typed dependency failure. It never creates configuration, consent, a generation, or a logical grant.

Acceptance

org(orgId).invitations.accept({ invitationId, expectedOfferDigest }) is the exact grantee's consent commit. expectedOfferDigest is the offerDigest the caller read for that invitation: get and the own-offer list show it, and nothing else may be substituted for it.

Consent is recorded once, in one transaction that also creates the offer's grant command and hands that command to the execution lane:

  • A repeat with the stored digest returns the same accepted result and writes nothing. A different digest is refused with WRONG_STATE invitation-offer-changed; it can never overwrite the stored consent.
  • The wrong authenticated grantee, an offer that is not pending, and an offer at or past its expiresAt all refuse before any consent is written. Acceptance requires now < expiresAt, so an offer that expired unaccepted cannot be brought back by accepting it.
  • The write signs nothing. The grant transaction is executed by the deployment's technical executor with its one-use rights after the consent commits, so the grantee never holds a key for it and no Admin returns to help. An Admin returns only when the offer must change.

The view the call returns is the authorization-time view of the consent commit: pending_grant, with acceptedAt and the accepted digest durable. The offer reaches active only when the grant receipt verifies and the exact assignments are applied, which happens in the lane the commit started. Read the settled state through get or wait. A repeat on an offer whose consent is already recorded returns that durable current view unchanged.

Until acceptance is recorded:

  • An authorized offer stays unaccepted. It is not membership.
  • The exact grantee can read and decline the offer through their own offers.
  • An Admin can read, cancel, and resend it.
  • Do not build an integration that assumes a member becomes active.

The CLI

The CLI calls the same SDK methods. It holds no route of its own.

capxul org invite list [--mine] [--limit N] [--cursor C] [--phase PHASE ...] [--org O]
capxul org invite get --invitation-id I [--org O]
capxul org invite wait --invitation-id I [--org O] [--timeout-seconds N]
capxul org invite review --invitation-id I --org O [--timeout-seconds N]
capxul org invite accept --invitation-id I --offer-digest D --org O [--confirm] [--timeout-seconds N]
capxul org invite decline --invitation-id I --org O [--confirm]
capxul org invite cancel --invitation-id I --org O [--confirm]
capxul org invite resend --invitation-id I --org O [--confirm]
capxul org invite retry --invitation-id I --org O [--confirm]
capxul org member invite (--to-email E | --account-id ID) [--budget-id B ...] [--permission-id M ...] [--new-budget-name N --asset A (--limit Q | --unlimited) (--recipient-account ID ... | --any-recipient) --actions pay[,commitments]] --org O [--request-key K] [--preview] [--confirm]

org invite list returns your own offers. With --org O it returns that Organization's invitations instead, for a current Admin. The result carries invitations, nextCursor, and observedAt: pass nextCursor back as --cursor to read the next page. --mine names the own-offer projection, which is already the default; it refuses together with --org, because the Organization list is not filtered to you. org invite get and org invite wait use --org when you name it. Without --org, they walk your own-offer pages until they find the invitation's own row. They never consult a saved read default, and --timeout-seconds bounds their resolution together with the read.

org invite wait only reads. It finishes when the offer settles or when the recovery asks a person to act. It never retries, resends, or accepts.

org invite review requires the exact --org and returns the current InvitationView directly in data. It performs no write or signing.

org invite accept needs --org and the exact digest the offer shows as --offer-digest. A noninteractive run must supply --offer-digest and --confirm; without the digest it refuses with a usage error before it creates a client, because it will not choose a digest for you. An interactive run may omit it: the command reads the offer, shows the digest it read, and fills it from that displayed value behind the standard confirmation prompt. A digest you state is never replaced by the displayed one — if they differ, the backend refuses and nothing is written. --expected-offer remains a compatibility spelling; if both spellings are present they must match. The command returns the InvitationView directly in data. It signs nothing, so it carries no browser-bridge requirement: any authenticated grantee session can run it, including a headless or CI session.

org member invite --preview writes nothing and needs no request key. A noninteractive authoring write needs --request-key and --confirm. An interactive run prints the request key it used before it submits, so a lost response is recoverable with the same identity. It also executes the configuration hop, and prints invitation.prepared with the durable {invitationId, commandId} pair before any signature is forwarded.

The CLI signs that hop through its browser bridge, exactly like the other Organization writes; the browser opens only after you confirm. This is the only signer the installed CLI carries, so a CLI-driven org member invite needs an Admin whose Account is browser-owned. For a headless or CI-provisioned Organization, call the SDK from Node with an injected signer (see The configuration hop); the CLI has no private-key flag, by design.

Then it uses the same result envelope and exit codes as every other command. NOT_AUTHENTICATED returns exit 3, ROLE_PERMISSION_DENIED returns exit 4, a usage or state refusal returns exit 2, and a dependency failure returns exit 5.

On this page