Build the auth flow
The complete email OTP journey — send the code, verify, route on session/profile/lifecycle state, complete onboarding, and sign out.
This guide takes you from the quickstart's single login page to a complete auth flow: send an OTP, verify it, route the user to the right screen based on their session, profile, and account-readiness state, hand off to onboarding, and sign out. It follows the same routing the reference app uses — OTP in, dashboard immediately, provisioning automatic.
Before you start
- You have finished Getting started:
CapxulProvideris mounted with a working publishable key and the two route rewrites are in place. - If your product moves money, mount the provider with
requirement="deployed"so the account lane runs automatically after sign-in. Auth-only apps can omitrequirement. SeeCapxulProviderand the account lane.
The four states that drive routing
Every screen decision in the flow derives from four reads:
| Read | Hook | Values that matter |
|---|---|---|
| SDK startup | useCapxul | "bootstrapping" / "ready" / "error" (with retry) |
| Session | useCapxulSession | Session or null (signed out) |
| Profile | useCapxulProfile | Profile or null — null until onboarding completes |
| Account readiness | useCapxulAccountLifecycle | loading / settingUp / ready / failed |
The routing rule, in order:
- Bootstrap not
readyor session still loading → show a splash. - No session → login screen.
- Session but no profile → onboarding (the user has an identity, but has never told you who they are).
- Profile but lifecycle
settingUporfailed→ provisioning screen. - Lifecycle
ready→ the app.
Send the OTP
useCapxulSignIn sends the one-time
code. Sending does not sign the user in — useCapxulSession stays null
until the code is verified, and this mutation invalidates nothing.
"use client";
import { useState } from "react";
import { useCapxulSignIn, useCapxulVerifyOtp } from "@capxul/sdk-react";
export function LoginForm() {
const signIn = useCapxulSignIn();
const verifyOtp = useCapxulVerifyOtp();
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [otpSent, setOtpSent] = useState(false);
const sendOtp = async () => {
await signIn.mutateAsync({ email });
setOtpSent(true);
};
const verify = async () => {
await verifyOtp.mutateAsync({ email, code });
// Session, profile, and lifecycle queries refetch automatically —
// the routing gate below takes over from here.
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
void (otpSent ? verify() : sendOtp());
}}
>
<input
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
{otpSent ? (
<input
inputMode="numeric"
autoComplete="one-time-code"
required
value={code}
onChange={(event) => setCode(event.target.value)}
/>
) : null}
<button type="submit" disabled={signIn.isPending || verifyOtp.isPending}>
{otpSent ? "Verify and sign in" : "Send code"}
</button>
{signIn.error ? <p>{signIn.error.message}</p> : null}
{verifyOtp.error ? <p>{verifyOtp.error.message}</p> : null}
</form>
);
}Two failure modes worth handling explicitly (both surface as a
CapxulError):
RATE_LIMITEDon send — back off and retry; the reference app waits ten seconds and resends once.WRONG_STATEon verify — the OTP flow was reset (for example the code expired and a new one must be sent). Flip your UI back to the email step.
Verify creates the identity
useCapxulVerifyOtp is both
"create user" and "log in". If the email has never signed in before, Capxul
creates the user identity during verification — there is no separate
createUser call. On success the hook invalidates the auth boundary (session,
profile, account lifecycle, balance), so every state read in the routing table
refetches in the background.
A freshly created identity has no profile — useCapxulProfile returns
null until onboarding completes. That is the signal to route to onboarding,
not an error.
Route on auth state
Fold the four reads into one gate and derive the route from it — no effects,
no imperative redirects scattered across screens. This mirrors how the
reference app routes (apps/reference in the platform repo).
"use client";
import {
useCapxul,
useCapxulAccountLifecycle,
useCapxulProfile,
useCapxulSession,
} from "@capxul/sdk-react";
import type { CapxulError } from "@capxul/sdk";
export type AuthGateState =
| { status: "loading" }
| { status: "bootstrap-error"; error: CapxulError | null; retry: () => void }
| { status: "signed-out" }
| { status: "needs-onboarding" }
| { status: "provisioning" }
| {
status: "setup-failed";
error: CapxulError | null;
retry: () => Promise<unknown>;
isRetrying: boolean;
}
| { status: "ready" };
export function useAuthGate(): AuthGateState {
const bootstrap = useCapxul();
const session = useCapxulSession();
const profile = useCapxulProfile();
const setup = useCapxulAccountLifecycle();
if (bootstrap.status === "error") {
return { status: "bootstrap-error", error: bootstrap.error, retry: bootstrap.retry };
}
if (bootstrap.status === "bootstrapping" || session.isLoading) {
return { status: "loading" };
}
if (session.data == null) {
return { status: "signed-out" };
}
if (profile.isLoading) {
return { status: "loading" };
}
if (profile.data === null) {
return { status: "needs-onboarding" };
}
if (setup.lifecycle.status === "failed") {
return {
status: "setup-failed",
error: setup.error,
retry: setup.retry,
isRetrying: setup.isRetrying,
};
}
if (setup.lifecycle.status !== "ready") {
return { status: "provisioning" };
}
return { status: "ready" };
}Then render (or navigate) from the gate:
const gate = useAuthGate();
switch (gate.status) {
case "loading":
return <Splash />;
case "bootstrap-error":
return <BootstrapError error={gate.error} retry={gate.retry} />;
case "signed-out":
return <Navigate to="/login" replace />;
case "needs-onboarding":
return <Navigate to="/signup" replace />;
case "provisioning":
case "setup-failed":
return <Navigate to="/signup/provisioning" replace />;
case "ready":
return <Navigate to="/dashboard" replace />;
}For bootstrap errors, always offer the retry from
useCapxul — it re-runs the SDK bootstrap in place.
Never rebuild the provider yourself.
Complete onboarding
Onboarding is the on-ramp: the user onboards once, as an individual or as an organization founder, and then acts within the resulting entity. The two completion hooks are the bridge from "signed in, no profile" to "profile saved, account lane running":
useCapxulCompletePersonalOnboarding— takesdisplayName,country, and an optionalwithdrawalAddress; returns{ lifecycle }.useCapxulCompleteOrganizationOnboarding— takesorganizationName,handle,country, andownerDisplayName; creates the org too and returns{ org, lifecycle }.
Call the mutation from the submit handler and route on the returned
lifecycle.status — no useEffect:
"use client";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useCapxulCompletePersonalOnboarding } from "@capxul/sdk-react";
export function PersonalOnboardingForm() {
const navigate = useNavigate();
const completePersonal = useCapxulCompletePersonalOnboarding();
const [displayName, setDisplayName] = useState("");
const [country, setCountry] = useState("");
const handleSubmit = async () => {
const { lifecycle } = await completePersonal.mutateAsync({
displayName: displayName.trim(),
country: country.trim(), // ISO-3166, e.g. "GH"
});
navigate(lifecycle.status === "ready" ? "/dashboard" : "/signup/provisioning");
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
>
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
<input value={country} onChange={(e) => setCountry(e.target.value)} placeholder="GH" />
<button
type="submit"
disabled={
completePersonal.isPending ||
displayName.trim().length === 0 ||
country.trim().length === 0
}
>
{completePersonal.isPending ? "Setting up…" : "Continue"}
</button>
{completePersonal.error ? <p>{completePersonal.error.message}</p> : null}
</form>
);
}On success both hooks invalidate the profile and account-lifecycle queries (the organization variant also invalidates the org list), so the routing gate sees the new state on its next render.
Build the provisioning screen
While the account lane provisions, useCapxulAccountLifecycle self-polls
every two seconds. The screen is pure derivation — the query is the source of
truth, the route is derived from it:
"use client";
import { Navigate } from "react-router-dom";
import { useCapxulAccountLifecycle } from "@capxul/sdk-react";
export function ProvisioningScreen() {
const { lifecycle, isSettingUp, error, retry, isRetrying } = useCapxulAccountLifecycle();
if (lifecycle.status === "ready") {
return <Navigate to="/dashboard" replace />;
}
if (lifecycle.status === "failed") {
return (
<section>
<h2>Setup didn't finish</h2>
<p>{error?.message ?? "Account setup failed."}</p>
<button type="button" onClick={() => void retry()} disabled={isRetrying}>
{isRetrying ? "Retrying…" : "Retry setup"}
</button>
</section>
);
}
const step = lifecycle.status === "settingUp" ? lifecycle.step : "preparing";
return (
<section>
<h2>Setting up your account…</h2>
<p>{isSettingUp ? `Step: ${step}` : "Preparing…"}</p>
</section>
);
}lifecycle.step is one of "connecting", "confirmingIdentity",
"registering", "activating". On failed, retry re-runs setup and
refetches the auth boundary on success.
Once ready, gate money UI on lifecycle.canTransact — ready with
canTransact: false means the account exists but activation has not finished.
Sign out
useCapxulSignOut ends the session
and immediately resets the cached session, profile, account-lifecycle, and
balance queries, so the routing gate lands back on signed-out without a
flash of stale data:
const signOut = useCapxulSignOut();
<button type="button" onClick={() => signOut.mutate()} disabled={signOut.isPending}>
{signOut.isPending ? "Signing out…" : "Sign out"}
</button>Other cached reads (org lists, payments) are not reset on sign-out — see what clears when if you need a full cache wipe.
What you end up with
/loginsends and verifies OTP codes; verification creates the identity on first sign-in.- A single
useAuthGateroutes every visit: signed out → login, no profile → onboarding, profile but not ready → provisioning, ready → app. - Onboarding completion mutations move the user forward without effects; the provisioning screen advances itself by polling.
- Sign-out drops the authenticated cache and lands back on login.
Agent-native
What agent-native means on this platform — agents doing what users and developers can do, through safe structured primitives, and docs built for machine readers.
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.