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

The error type

import { CapxulError, isCapxulError } from "@capxul/sdk";
import type { CapxulErrorCode, CapxulErrorDetails } from "@capxul/sdk";

CapxulError extends Error with structured fields:

FieldTypeMeaning
codeCapxulErrorCodeStable machine-readable code — the field to branch on
messagestringDiagnostic text; applications own display copy
detailsCapxulErrorDetails (optional)Code-specific structured context, e.g. { field, reason } for INVALID_INPUT
correlationIdstring (optional)Ties the failure to backend telemetry — include it in bug reports
modeFailureMode (optional)Structured failure diagnosis
chainChainCause (optional)Upstream, retryability and safe chain evidence
layerstring (optional)Layer that reported the failure
causeunknown (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 the CapxulError, for imperative handling in submit handlers. (mutate does not throw; it routes the error to mutation.error and 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:

CodeWhen you see it
NOT_AUTHENTICATEDAn authenticated call ran without a signed-in session.
EMAIL_DELIVERY_FAILEDThe OTP (or invite) email could not be sent.
PROFILE_NOT_FOUNDNo identity profile exists for the user.
SMART_ACCOUNT_MISSINGThe user's account has not been provisioned yet.
PLAYER_NOT_FOUNDA provider-side user record was not found.
ACCOUNT_NOT_FOUNDA provider-side account record was not found.
ACCOUNT_CLAIMED_BY_OTHER_SIGNERThe 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_ERRORAn 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_INVALIDAn 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_UNAVAILABLEThe contacted role does not offer the required capability. details.provider names the role, and details.operation names the operation.
INVALID_INPUTA field failed validation; details.field / details.reason.
ENV_MISSINGRequired configuration is missing (details.name).
NOT_IMPLEMENTEDThe method exists on the surface but is not available in this release (details.domain / details.method).
VERIFICATION_REQUIREDThe operation needs a verification step the user has not completed.
INSUFFICIENT_BALANCENot enough funds; details.asset / available / required.
INVALID_RECIPIENTThe recipient was rejected — e.g. a bare address passed to pay (details.reason).
ROLE_PERMISSION_DENIEDAn 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_FAILEDA money movement failed to execute (details.operation).
RATE_LIMITEDToo many attempts; details.retryAfterMs when known.
NETWORK_ERRORA network failure interrupted the operation.
UNKNOWNUnclassified failure — the cause chain has more.
OTP_EXPIREDThe verification code expired; request a new one.
SIGNER_REJECTEDThe signing step was rejected or unavailable.
CANCELLEDThe operation was aborted (e.g. an AbortSignal fired).
WRONG_STATEThe 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";
ClassWhat happenedWho acts
inputThe caller's request or environment is wrongThe caller: the person or your app
stateThe request is valid; the current state refuses itThe person or another party, after a re-read, or waits
provider-unavailableNo usable answer arrived (timeout, 5xx, rate limit)Nobody now: retry later
provider-refusedThe provider understood the request and refused itCapxul operations: account, permission or policy
provider-answerThe provider answered; Capxul could not use itCapxul engineering
defectA Capxul bug or misconfiguration, or no known causeCapxul 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 on details for code-specific data.
  • Log correlationId with 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/sdk builds 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:

CodeMeaning
STALE_EPOCHWork belongs to an older runtime epoch.
SUPERSEDEDNewer work replaced this operation.
WORK_DIEDRuntime work ended before it returned a result.
ACTOR_STOPPEDThe 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.

On this page