Capxul Docs
Guides

Send money

Pay a typed recipient, understand Commitment escrow for unvalidated recipients, list and read payments, cancel, withdraw to an external wallet, and pay out to a saved destination.

This guide covers moving money out of a personal account: paying a typed recipient, what happens when that recipient is not validated yet, listing and reading payments, cancelling, cashing out to an external wallet, and paying out to a saved destination.

Before you start

Amounts everywhere are a PaymentMoney: currency, decimal string value, and decimals — for example { currency: "USD", value: "25.00", decimals: 6 }. Never minor units, never a token amount. The current alpha settles at six-decimal precision — pass decimals: 6; other values are rejected with INVALID_INPUT.

Pay a typed recipient

useCapxulPay sends money to a typed recipient reference — a handle, an email, an organization handle, or a saved payee id:

"use client";

import { useCapxulPay } from "@capxul/sdk-react";

export function PayButton() {
  const pay = useCapxulPay();

  const handlePay = async () => {
    const payment = await pay.mutateAsync({
      to: { kind: "email", email: "dana@example.com" },
      amount: { currency: "USD", value: "25.00", decimals: 6 },
    });
    // Check payment.status: an instant send to a validated recipient
    // settles; an unvalidated recipient escrows as "pending_claim".
  };

  return (
    <button type="button" onClick={() => void handlePay()} disabled={pay.isPending}>
      {pay.isPending ? "Sending…" : "Pay $25"}
    </button>
  );
}

The to reference is one of:

kindFieldsPays
"handle"handlea Capxul user by handle
"email"emailanyone by email — including people not on Capxul yet
"organization"handlean org by its handle
"payee"ida saved payee

Two constraints to design around (both by design, both graded on Capability status):

  • Bare addresses are rejected. pay never accepts a raw EVM address — that path fails with INVALID_RECIPIENT. Cash-out to an external wallet is its own lane (withdraw).
  • pay is personal-actor only today. Passing an organization actor fails with NOT_IMPLEMENTED — org treasury spending goes through the org surface, not payments.pay.

Optionally attach a paymentType ("invoice", "payroll", "reimbursement"), a payment document, or a timing clause. Omitting timing means an instant send; a scheduled or stream clause always creates a Commitment (below).

Unvalidated recipients become Commitments

One rule explains most "why is this payment not settled?" moments: an instant, irreversible send is only permitted to a validated recipient. Paying anyone not yet validated — a fresh email, an unclaimed handle — does not fail. It escrows the money in a recoverable Commitment instead:

  • The Payment comes back with status: "pending_claim".
  • The recipient claims it once they are on Capxul; availableToClaim on the payment tracks what they can take.
  • Until claimed, the sender can cancel (reclaiming the unvested remainder) or redirect it to another validated recipient.

The SDK enforces this; it is not a bug to work around. The full model is in Money & accounts.

List payments and read one

useCapxulPayments lists the payment ledger (most recent first); useCapxulPayment reads one by id with its live release state:

"use client";

import { useCapxulPayment, useCapxulPayments } from "@capxul/sdk-react";

function PaymentsList() {
  const payments = useCapxulPayments();

  if (payments.isLoading) return <p>Loading payments…</p>;
  if (payments.isError) return <p>{payments.error.message}</p>;

  return (
    <ul>
      {payments.data?.map((payment) => (
        <li key={payment.id}>
          {payment.recipient.label} · {payment.amount.value} {payment.amount.currency} ·{" "}
          {payment.status}
        </li>
      ))}
    </ul>
  );
}

function PaymentDetail({ paymentId }: { paymentId: string }) {
  const payment = useCapxulPayment(paymentId);
  if (payment.data == null) return null;
  return (
    <p>
      Released {payment.data.released.value} / {payment.data.amount.value} — claimable:{" "}
      {payment.data.availableToClaim.value}
    </p>
  );
}

A Payment is leak-safe by construction: recipient kind + label, amount, status (pending, submitted, pending_claim, scheduled, streaming, settled, cancelled, redirected, expired, failed), timing, released / availableToClaim, and document references. There is no transaction hash and no address on it.

Money mutations invalidate the payments list and detail automatically — see TanStack Query integration.

Cancel a payment

Commitments are cancellable by their creator before they are fully claimed: cancelling returns the unvested remainder to you, while anything already vested stays claimable by the recipient.

useCapxulCancelPayment is the mutation surface for this:

const cancelPayment = useCapxulCancelPayment();
await cancelPayment.mutateAsync(payment.id);

In the current published alpha, client.payments.cancel is a stub: it always rejects with a NOT_IMPLEMENTED CapxulError. The Commitment cancel lane exists on the platform, but it is not wired through the browser SDK client yet. Ship the button behind a feature gate or handle the error explicitly, and check Capability status before relying on it.

Withdraw to an external wallet

useCapxulWithdraw is the cash-out lane: it sends funds from the user's account to an external wallet. This is the one place the SDK accepts a raw 0x address, and it requires a signed Withdrawal document that carries the audit-visible destination:

"use client";

import { useCapxulWithdraw } from "@capxul/sdk-react";

export function WithdrawButton({ destination }: { readonly destination: string }) {
  const withdraw = useCapxulWithdraw();

  const handleWithdraw = async () => {
    const dest = destination.toLowerCase();
    const payment = await withdraw.mutateAsync({
      to: dest,
      amount: { currency: "USD", value: "5.00", decimals: 6 },
      document: {
        protocol: "capxul.payment-document",
        version: 1,
        domain: {
          name: "CapxulPayments",
          version: "1",
          chainId: 84532,
          verifyingContract: "0xe740ea65521edc9bef0ea75d30b5334711db99f4",
        },
        primaryType: "Withdrawal",
        message: {
          kind: 4,
          reference: "WD-001",
          amount: "5000000", // minor units as a string ($5.00 at 6 decimals)
          currency: "USD", // fiat code, not a token symbol
          decimals: 6,
          destChain: 84532,
          destAddress: dest, // must match `to`, lowercased
          settledAt: Date.now(),
          provider: "", // "" means a direct send
          note: "",
        },
      },
    });
    // payment.recipient.label is a redacted form like "0x1234…abcd" —
    // the full external address lives only on the withdrawal document.
  };

  return (
    <button type="button" onClick={() => void handleWithdraw()} disabled={withdraw.isPending}>
      {withdraw.isPending ? "Withdrawing…" : "Withdraw $5"}
    </button>
  );
}

Notes on the document:

  • The struct is strict — exactly these fields, no extras. provider and note are required strings ("" when absent).
  • The domain values (chainId, verifyingContract) are environment configuration from your Capxul handoff; the values above are the test environment's.
  • The returned Payment never exposes the full destination address — its recipient label is redacted.

Withdraw is graded SDK-ready on Capability status: published and tested, but not yet proven by a product surface end to end. Build against it with that expectation.

Pay out to a saved destination

Where withdraw takes a raw address every time, payout settles to a saved destination selected by opaque id — the user never re-enters wallet details.

First save a destination with useCapxulAddDestination, then list with useCapxulDestinations and pay out with useCapxulPayout:

"use client";

import {
  useCapxulAddDestination,
  useCapxulDestinations,
  useCapxulPayout,
} from "@capxul/sdk-react";

export function PayoutPanel() {
  const addDestination = useCapxulAddDestination();
  const destinations = useCapxulDestinations({ actor: { kind: "personal" } });
  const payout = useCapxulPayout();

  // 1. Save a wallet destination once.
  const saveWallet = async (address: string) => {
    await addDestination.mutateAsync({
      target: { kind: "email", email: "dana@example.com" },
      kind: "external_account",
      label: "Dana's wallet",
      payload: { network: "base-sepolia", address: address.toLowerCase() },
    });
  };

  // 2. Pay out to it by id — no address re-entry.
  const payOut = async (destinationId: string) => {
    await payout.mutateAsync({
      destinationId,
      amount: { currency: "USD", value: "10.00", decimals: 6 },
    });
  };

  return (
    <ul>
      {destinations.data?.map((destination) => (
        <li key={destination.id}>
          {destination.label ?? destination.id}
          <button type="button" onClick={() => void payOut(destination.id)}>
            Pay out $10
          </button>
        </li>
      ))}
    </ul>
  );
}

Payout settles wallet destinations only today. Bank and mobile-money destination metadata can be stored (kind: "bank_account" / "mobile_money"), which is useful for building the destination book ahead of time — but a payout to them is rejected. Bank and mobile-money settlement is roadmap, not capability. See Capability status.

What you end up with

  • A pay flow that sends to handles, emails, orgs, and payees — and degrades safely to escrowed Commitments for recipients who are not validated yet.
  • A payments screen listing the ledger and showing per-payment release state.
  • A cash-out path (withdraw) and a repeatable payout path over saved wallet destinations, each honest about today's grading.

On this page