Capxul Docs
Guides

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

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
messagestringHuman-readable, safe to show to users
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
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">{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.
  • mutateAsyncrejects 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(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:

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.
PROVIDER_ERRORAn upstream provider call failed; details.provider / details.operation name it, and details.reason: "timeout" marks timeouts.
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. 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.

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 on details for code-specific data.
  • Log correlationId with any error you report — it is the join key to backend telemetry.
  • Let queries retry, re-drive mutations yourself. The defaults already encode this.

On this page