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
- Auth and onboarding are done (Build the auth flow)
and the account lifecycle is
readywithcanTransact: true. - The account holds test funds (see test keys and the faucet).
- Skim Money & accounts for the model: Money, Account, Payment, Commitment.
- Check Capability status — the payments surface is graded Partial/SDK-ready, and this guide flags each constraint where it bites.
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:
kind | Fields | Pays |
|---|---|---|
"handle" | handle | a Capxul user by handle |
"email" | email | anyone by email — including people not on Capxul yet |
"organization" | handle | an org by its handle |
"payee" | id | a saved payee |
Two constraints to design around (both by design, both graded on Capability status):
- Bare addresses are rejected.
paynever accepts a raw EVM address — that path fails withINVALID_RECIPIENT. Cash-out to an external wallet is its own lane (withdraw). payis personal-actor only today. Passing an organizationactorfails withNOT_IMPLEMENTED— org treasury spending goes through the org surface, notpayments.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
Paymentcomes back withstatus: "pending_claim". - The recipient claims it once they are on Capxul;
availableToClaimon 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.
providerandnoteare required strings (""when absent). - The
domainvalues (chainId,verifyingContract) are environment configuration from your Capxul handoff; the values above are the test environment's. - The returned
Paymentnever 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.
Work with organizations
List the orgs a user belongs to, create one, switch the acting entity, read treasury/members/roles, and invite a member with a role.
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.