Handle errors
CapxulError is the single error type across React hooks and the Core SDK client — the error shape, where it surfaces, bootstrap retry, and the public error-code catalog.
Every failure the SDK surfaces — query errors, mutation rejections, direct Core SDK
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 | Diagnostic text; applications own display copy |
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 |
mode | FailureMode (optional) | Structured failure diagnosis |
chain | ChainCause (optional) | Upstream, retryability and safe chain evidence |
layer | string (optional) | Layer that reported the failure |
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">{"Could not load payments. Please try again."}</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("Payment failed. Please try again.");
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 Core SDK 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 Core SDK 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 Call the Core SDK directly.
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>{"Capxul failed to start. Please try again."}</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. |
ACCOUNT_CLAIMED_BY_OTHER_SIGNER | The Safe at this email's address is already owned by a different signer, so the claim cannot install this one; details.safeAddress / onChainOwner / signerAddress. Not retryable: only the on-chain owner can hand the account over. |
PROVIDER_ERROR | An upstream call gave no usable answer, or refused the request. mode says which: timeout, upstream-down or rate-limited for no answer, refused, unauthorized, forbidden or bad-request for a refusal. details.provider names the contacted role, such as chain-rpc, bridge or account-abstraction, details.operation its operation, and details.httpStatus the status it answered with. |
PROVIDER_RESPONSE_INVALID | An upstream answered, but its answer cannot be used; details.provider / operation name the call, details.field the unusable part and details.problem what is wrong with it: missing, invalid or conflict. |
CAPABILITY_UNAVAILABLE | The contacted role does not offer the required capability. details.provider names the role, and details.operation names the operation. |
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, budget_exhausted. A budget_exhausted refusal also names details.remaining and details.required as asset amounts. 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.
Who acts: the failure class
failureClass(error) tells you who must act on a failure. It is derived from
code and mode, so it never disagrees with them:
import { FAILURE_CLASSES, failureClass } from "@capxul/sdk";
import type { FailureClass } from "@capxul/sdk";| Class | What happened | Who acts |
|---|---|---|
input | The caller's request or environment is wrong | The caller: the person or your app |
state | The request is valid; the current state refuses it | The person or another party, after a re-read, or waits |
provider-unavailable | No usable answer arrived (timeout, 5xx, rate limit) | Nobody now: retry later |
provider-refused | The provider understood the request and refused it | Capxul operations: account, permission or policy |
provider-answer | The provider answered; Capxul could not use it | Capxul engineering |
defect | A Capxul bug or misconfiguration, or no known cause | Capxul engineering: report it with the correlationId |
A code with one meaning always has the same class: INVALID_INPUT is input,
PROVIDER_RESPONSE_INVALID is provider-answer and UNKNOWN is defect.
PROVIDER_ERROR, NETWORK_ERROR, TRANSACTION_FAILED and
EMAIL_DELIVERY_FAILED take their class from mode; with no mode they are
defect. Map the codes you know first, then use the class to choose the copy
for the rest.
Practical rules
- Use application-owned display copy. Error messages are diagnostic text.
- Branch on
error.code, and ondetailsfor code-specific data. - Log
correlationIdwith any error you report — it is the join key to backend telemetry. - Report a failure to PostHog with
failureProperties.failureProperties(error, { operation, text: "full" })from@capxul/sdkbuilds the same row the SDK and the backend send, with every string masked.text: "none"keeps closed facts only, for a surface that must send no free text. - Let queries retry, re-drive mutations yourself. The defaults already encode this.
The public catalog also includes these lifecycle outcomes:
| Code | Meaning |
|---|---|
STALE_EPOCH | Work belongs to an older runtime epoch. |
SUPERSEDED | Newer work replaced this operation. |
WORK_DIED | Runtime work ended before it returned a result. |
ACTOR_STOPPED | The owning actor stopped. |
Use code, mode and chain to choose recovery. Applications supply display
copy. Native cause objects stay local; the canonical serialized error carries
only the declared diagnostic fields, and the backend masks every credential in
its message and details before the error crosses the wire.
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.
Call the Core SDK directly
Call Core SDK client methods that do not have first-class React hooks yet — useCapxulClientOrNull, bootstrap gating, CapxulResult handling, and cache keys.