Work with organizations
List the orgs a user belongs to, create one, switch the acting entity, read treasury/members/roles, and invite a member with a role.
This guide covers the organization surface end to end: list the organizations the signed-in user belongs to, create a new one, switch which entity the UI is acting as, read an org's treasury, members, and roles, and invite a member by email with a role.
Before you start
- You have a working auth flow (Build the auth flow) and a signed-in, onboarded user.
- Read Identity & orgs if you want the model behind entities, members, and roles — this guide only uses it.
Everything below is entity-scoped and explicit: the SDK holds no global
"acting as" state. Every org read and write names its orgId, either as a
hook argument or through client.org(orgId).
Render org screens behind your auth gate (bootstrap ready, session present). Most org hooks
expect a bootstrapped provider and throw if rendered while the SDK is still starting up — the gate
from Build the auth flow guarantees they never are.
List organizations
useCapxulOrgs lists the orgs
the user can access. It is a session-scoped authenticated read — gate it on
auth readiness so it does not fire before the session settles and surface a
spurious NOT_AUTHENTICATED:
"use client";
import { useCapxulOrgs, useCapxulIdentity } from "@capxul/sdk-react";
export function OrgList() {
const identity = useCapxulIdentity();
const orgs = useCapxulOrgs({ enabled: identity.phase === "authenticated" });
if (orgs.isLoading) return <p>Loading organizations…</p>;
if (orgs.isError) return <p>Could not load your Organizations ({orgs.error.code}).</p>;
return (
<ul>
{(orgs.data ?? []).map((org) => (
<li key={org.id}>
<a href={`/orgs/${org.id}`}>{org.name}</a> · @{org.handle}
</li>
))}
</ul>
);
}Each OrganizationAccess carries id, name, handle, account, setup,
member, capabilities, custody, and observedAt. The backend supplies these
facts. List inclusion does not grant a protected treasury or members read.
Create an organization
useCapxulCreateOrg calls
client.onboarding.completeOrganization(input). It returns after setup is
ready. The input takes:
name— display name.handle— globally unique, normalized slug (^[a-z0-9-]{3,32}$).country— required ISO country code.bio,size, andlogoStorageId— optional.
"use client";
import { useCapxulCreateOrg } from "@capxul/sdk-react";
function CreateOrgButton({ navigate }: { navigate: (path: string) => void }) {
const createOrg = useCapxulCreateOrg();
return (
<button
onClick={async () => {
const result = await createOrg.mutateAsync({
name: "Acme Studio",
handle: "acme-studio",
country: "GH",
});
navigate(`/orgs/${result.organizationId}`);
}}
>
Create Organization
</button>
);
}On success the hook invalidates the org list, so any mounted useCapxulOrgs
refetches automatically.
To retain an ID before the signer opens, call
client.onboarding.beginOrganization(input) directly. Store its orgId, then
authorize only if the returned setup is awaitingAuthorization:
import type { CapxulClient, OrgId } from "@capxul/sdk";
declare const client: CapxulClient;
declare function saveRecoveryId(orgId: OrgId): Promise<void>;
const begun = await client.onboarding.beginOrganization({
name: "Acme Studio",
handle: "acme-studio",
country: "GH",
});
if (!begun.ok) throw begun.error;
const { orgId } = begun.value;
await saveRecoveryId(orgId);
if (begun.value.setup.status === "awaitingAuthorization") {
const authorized = await client.org(orgId).setup.authorize();
if (!authorized.ok) throw authorized.error;
}setup.get() reads once. setup.resume() asks the backend to check the same
accepted operation; it does not send another one. Use setup.subscribe() for
updates. Subscription acquisition is asynchronous, so clean up even if the
component unmounts before acquisition resolves:
import { useEffect } from "react";
import type { CapxulClient, CapxulError, OrgId, OrganizationSetupSnapshot } from "@capxul/sdk";
function useOrganizationSetup(
client: CapxulClient,
orgId: OrgId,
onSetup: (snapshot: OrganizationSetupSnapshot) => void,
onError: (error: CapxulError) => void,
) {
useEffect(() => {
const controller = new AbortController();
let closed = false;
let stop: (() => void) | null = null;
void client
.org(orgId)
.setup.subscribe(onSetup, { signal: controller.signal })
.then((result) => {
if (!result.ok) {
if (!closed) onError(result.error);
return;
}
stop = result.value;
if (closed) stop();
});
return () => {
closed = true;
controller.abort();
stop?.();
};
}, [client, orgId, onSetup, onError]);
}An authenticated client can discover unfinished founder setup with
client.onboarding.listOrganizationSetups({ limit: 50 }). Pass the returned
cursor on the next call until isDone is true. The list is a read and does
not require a local recovery file.
If the user is onboarding as an organization founder for the first time, use
CapxulOnboardingController
instead — it saves the founder's identity profile and creates the org in one step.
useCapxulCreateOrg is for adding further orgs to an existing identity.
Switch the acting entity
"Acting as" (personal account vs. a specific org) is your app's state, not
the SDK's. The idiomatic pattern is to derive the active entity from the route
(/dashboard = personal, /orgs/:orgId = that org) and pass the derived
orgId into the org hooks on those screens.
There is no SDK hook for the switch, and there should not be: the switch is
navigation. useCapxulSwitchActingEntity used to sit here as a "stable seam",
but it carried no SDK side effect at all — it was a documented no-op — so
alpha.23 removed it. Navigate, and let the org hooks re-read for the new
orgId:
"use client";
import { useNavigate } from "react-router-dom";
import type { OrganizationAccess } from "@capxul/sdk";
function EntitySwitcher({ orgs }: { orgs: readonly OrganizationAccess[] }) {
const navigate = useNavigate();
return (
<select
onChange={(event) => {
const value = event.target.value;
navigate(value === "personal" ? "/dashboard" : `/orgs/${value}`);
}}
>
<option value="personal">Personal</option>
{orgs.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
);
}Read treasury, members, and roles
All three reads are parameterized by orgId. Gate them with options.enabled
until the exact Organization setup is ready. The backend still checks current
authority on each protected read. Use CapxulDashboardAccess for a dashboard
gate; a past ready setup does not grant access after authority is revoked.
OrgId is an opaque type you get from SDK data — OrganizationAccess.id from the org
list, or the orgId from a begin/onboarding result. To turn a route param back
into an OrgId, match it against the org list
(orgs.data?.find((org) => org.id === param)?.id) rather than casting the raw
string.
The SDK root also exports toOrgId and toAccountId from the shared type owner.
toOrgId accepts a nonempty string. toAccountId requires account_ plus an
alphanumeric ID. Both throw INVALID_INPUT for rejected values. These validators
do not prove that an ID exists or that the current user can access it.
"use client";
import {
useCapxulAuth,
useCapxulIdentity,
useCapxulOrgMembers,
useCapxulOrgRoles,
useCapxulOrgTreasury,
useCapxulOrgs,
} from "@capxul/sdk-react";
import type { OrgId } from "@capxul/sdk";
export function OrgDetail({ orgId }: { orgId: OrgId }) {
const identity = useCapxulIdentity();
const auth = useCapxulAuth();
const orgs = useCapxulOrgs({ enabled: identity.phase === "authenticated" });
const org = orgs.data?.find((candidate) => candidate.id === orgId);
const lane =
identity.phase === "authenticated" && identity.account.at === "claimed"
? identity.account.org
: null;
const setup = lane?.orgId === orgId && lane.observation === "connected" ? lane.setup : null;
const ready = setup?.status === "ready";
const treasury = useCapxulOrgTreasury(orgId, { enabled: ready });
const members = useCapxulOrgMembers(orgId, { enabled: ready });
const roles = useCapxulOrgRoles(orgId, { enabled: ready });
if (
lane?.orgId === orgId &&
(lane.observation === "error" ||
setup?.status === "awaitingAuthorization" ||
setup?.status === "needsAttention" ||
setup?.status === "failed")
) {
return <button onClick={() => void auth.retry()}>Continue this setup</button>;
}
if (!ready) {
return <p>Organization setup: {setup?.status ?? "loading"}</p>;
}
if (orgs.isLoading || treasury.isPending || members.isPending) {
return <p>Loading Organization reads…</p>;
}
if (orgs.isError) return <p>Could not load your Organizations ({orgs.error.code}).</p>;
if (org == null) return <p>Organization not found.</p>;
return (
<>
<h2>{org.name}</h2>
{treasury.data?.balance && treasury.data.available ? (
<p>
Treasury: {treasury.data.balance.value} {treasury.data.balance.currency} (available:{" "}
{treasury.data.available.value})
</p>
) : null}
<ul>
{members.data?.map((member) => (
<li key={`${member.orgId}:${member.email}`}>
{member.email} · {member.role} · {member.status}
</li>
))}
</ul>
<ul>
{roles.data?.map((role) => (
<li key={role.roleKey}>{role.label}</li>
))}
</ul>
</>
);
}What each returns:
useCapxulOrgTreasury— the org's single treasuryAccount. Its exact asset quantities are inbalances. Itsbalanceandavailablevaluations can benull.useCapxulOrgMembers—MemberView[]:email,name,rolelabel, and a lifecyclestatusrunningpending(including the intermediatepending_safe/pending_grantsteps) →active, withrevoked/expiredoff-ramps. Apendingmember has been invited but has not finished joining yet.useCapxulOrgRoles—RoleView[]: each role'slabeland itsdefinition, including optional spend caps (perTx,perDay, allowed recipients) and management permissions. Role labels are what you pass when inviting or assigning.
What you end up with
- An org list gated on auth, a create form that lands on the new org's page, and an entity switcher whose state lives in your router.
- An org detail screen reading treasury, members, and roles for one explicit
orgId. - An invite form that adds members by email with a role, including people who are not Capxul users yet.
Organization spending uses an explicit Permission through
client.org(orgId).payments or the matching React hooks.