Handle errors
CapxulError is the single error type across hooks and the raw client — the error shape, where it surfaces, bootstrap retry, and the public error-code catalog.
Every failure the SDK surfaces — query errors, mutation rejections, raw client
results, bootstrap failures — is one type: CapxulError. This guide shows
where it appears, how to render and branch on it, and the full public code
catalog.
Before you start
- You have hooks running against a working provider (Getting started).
The error type
import { CapxulError, isCapxulError } from "@capxul/sdk";
import type { CapxulErrorCode, CapxulErrorDetails } from "@capxul/sdk";CapxulError extends Error with structured fields:
| Field | Type | Meaning |
|---|---|---|
code | CapxulErrorCode | Stable machine-readable code — the field to branch on |
message | string | Human-readable, safe to show to users |
details | CapxulErrorDetails (optional) | Code-specific structured context, e.g. { field, reason } for INVALID_INPUT |
correlationId | string (optional) | Ties the failure to backend telemetry — include it in bug reports |
cause | unknown (standard Error.cause) | The underlying error, when one exists |
Branch on code, never by parsing message — messages can change between
releases; codes are the contract. Use isCapxulError(value) to narrow an
unknown caught value.
Errors from query hooks
Query hooks are typed UseQueryResult<Data, CapxulError>, so error is a
CapxulError — no casting:
"use client";
import { useCapxulPayments } from "@capxul/sdk-react";
function PaymentsList() {
const payments = useCapxulPayments();
if (payments.isError) {
return <p role="alert">{payments.error.message}</p>;
}
// …
}Remember the provider's defaults: queries retry twice before isError goes
true (see TanStack Query integration).
Errors from mutation hooks
Mutations surface the same error two ways:
mutation.error— for rendering inline, exactly like queries.mutateAsync— rejects with theCapxulError, for imperative handling in submit handlers. (mutatedoes not throw; it routes the error tomutation.errorand callbacks.)
"use client";
import { isCapxulError } from "@capxul/sdk";
import { useCapxulPay } from "@capxul/sdk-react";
function PayForm({ email }: { readonly email: string }) {
const pay = useCapxulPay();
const handlePay = async () => {
try {
await pay.mutateAsync({
to: { kind: "email", email },
amount: { currency: "USD", value: "25.00", decimals: 6 },
});
} catch (cause) {
if (isCapxulError(cause)) {
switch (cause.code) {
case "INSUFFICIENT_BALANCE":
showTopUpPrompt();
return;
case "RATE_LIMITED": {
const retryAfterMs = cause.details?.retryAfterMs;
scheduleRetry(typeof retryAfterMs === "number" ? retryAfterMs : 10_000);
return;
}
default:
showToast(cause.message);
return;
}
}
throw cause;
}
};
return (
<button type="button" onClick={() => void handlePay()} disabled={pay.isPending}>
Pay $25
</button>
);
}Mutations never retry automatically (retry: 0) — a failed money movement is
yours to re-drive deliberately.
Errors from the raw client
The core client never throws for operational failures. Every method returns a
CapxulResult<T> — a discriminated union:
type CapxulResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: CapxulError };const result = await client.payments.list();
if (!result.ok) {
console.error(result.error.code, result.error.message);
return;
}
render(result.value);When you wrap raw client calls in your own TanStack queries, throw
result.error so the hook's error field carries the same type as the
first-class hooks — see Drop to the raw client.
Bootstrap errors
If the provider's startup fails (bad key, unreachable backend, disallowed
origin), no hook ever leaves its pending state. The failure lives on
useCapxul:
"use client";
import { useCapxul } from "@capxul/sdk-react";
function BootstrapGate({ children }: { children: React.ReactNode }) {
const bootstrap = useCapxul();
if (bootstrap.status === "error") {
return (
<div role="alert">
<p>{bootstrap.error?.message ?? "Capxul failed to start."}</p>
<button type="button" onClick={bootstrap.retry}>
Retry
</button>
</div>
);
}
return children;
}retry() re-runs the bootstrap in place. Do not rebuild the provider or the
client yourself.
One related code you may see from mutations: firing a mutation while the
provider is still bootstrapping rejects with WRONG_STATE
(details.currentState: "bootstrapping"). Query hooks do not have this
problem — they stay disabled until the client is ready.
The error-code catalog
The public catalog is a closed set — every SDK failure maps to one of these codes:
| Code | When you see it |
|---|---|
NOT_AUTHENTICATED | An authenticated call ran without a signed-in session. |
EMAIL_DELIVERY_FAILED | The OTP (or invite) email could not be sent. |
PROFILE_NOT_FOUND | No identity profile exists for the user. |
SMART_ACCOUNT_MISSING | The user's account has not been provisioned yet. |
PLAYER_NOT_FOUND | A provider-side user record was not found. |
ACCOUNT_NOT_FOUND | A provider-side account record was not found. |
PROVIDER_ERROR | An upstream provider call failed; details.provider / details.operation name it, and details.reason: "timeout" marks timeouts. |
INVALID_INPUT | A field failed validation; details.field / details.reason. |
ENV_MISSING | Required configuration is missing (details.name). |
NOT_IMPLEMENTED | The method exists on the surface but is not available in this release (details.domain / details.method). |
VERIFICATION_REQUIRED | The operation needs a verification step the user has not completed. |
INSUFFICIENT_BALANCE | Not enough funds; details.asset / available / required. |
INVALID_RECIPIENT | The recipient was rejected — e.g. a bare address passed to pay (details.reason). |
ROLE_PERMISSION_DENIED | An org role denied the spend; details.reason is one of over_cap, daily_cap, not_member, disallowed_recipient, condition_violation. Distinct from INSUFFICIENT_BALANCE — the money was there; the role's authority was not. |
TRANSACTION_FAILED | A money movement failed to execute (details.operation). |
RATE_LIMITED | Too many attempts; details.retryAfterMs when known. |
NETWORK_ERROR | A network failure interrupted the operation. |
UNKNOWN | Unclassified failure — the cause chain has more. |
OTP_EXPIRED | The verification code expired; request a new one. |
SIGNER_REJECTED | The signing step was rejected or unavailable. |
CANCELLED | The operation was aborted (e.g. an AbortSignal fired). |
WRONG_STATE | The method was called from a state where its precondition fails; details.method / currentState / validStates. |
CapxulErrorCode is the exported union of exactly these strings, so an
exhaustive switch over codes type-checks.
Practical rules
- Show
error.message. It is written to be user-safe; you do not need a message map to ship. - Branch on
error.code, and ondetailsfor code-specific data. - Log
correlationIdwith any error you report — it is the join key to backend telemetry. - Let queries retry, re-drive mutations yourself. The defaults already encode this.
TanStack Query integration
How the SDK rides TanStack Query — the provider-owned QueryClient, the key namespace, automatic invalidation, manual invalidation, and what clears on sign-out and re-bootstrap.
Drop to the raw client
Call core client methods that don't have first-class hooks yet — useCapxulClientOrNull, gating on bootstrap, unwrapping CapxulResult, and keying under the capxul namespace.