Capxul Docs
Guides

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: CapxulProvider is 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 omit requirement. See CapxulProvider and the account lane.

The four states that drive routing

Every screen decision in the flow derives from four reads:

ReadHookValues that matter
SDK startupuseCapxul"bootstrapping" / "ready" / "error" (with retry)
SessionuseCapxulSessionSession or null (signed out)
ProfileuseCapxulProfileProfile or nullnull until onboarding completes
Account readinessuseCapxulAccountLifecycleloading / settingUp / ready / failed

The routing rule, in order:

  1. Bootstrap not ready or session still loading → show a splash.
  2. No session → login screen.
  3. Session but no profile → onboarding (the user has an identity, but has never told you who they are).
  4. Profile but lifecycle settingUp or failed → provisioning screen.
  5. 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_LIMITED on send — back off and retry; the reference app waits ten seconds and resends once.
  • WRONG_STATE on 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 profileuseCapxulProfile 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":

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.canTransactready 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

  • /login sends and verifies OTP codes; verification creates the identity on first sign-in.
  • A single useAuthGate routes 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.

On this page