This is the procedure version of The signing model. Read that one first if you want the why — this page is the how, with copyable code.
The pattern is the same for every state change: NFT buy/list/transfer, swap intent, LP quote create/cancel. Only the endpoint and the summary shape differ.
Prepare
Codeconst prep = await fetch("/api/wallet/cc-transfer/prepare", { method: "POST", headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json", }, body: JSON.stringify({ from: party, to: recipientParty, amount: "1.0", }), }).then((r) => r.json()); // prep === { // commandId: string, // hash: string, // hashingSchemeVersion: string, // transaction: string, // summary: { type, fields... } // }The backend builds the Canton transaction, including current contract IDs for any input. It returns the hash to sign, the hashing scheme version, and an opaque
transactionblob containing disclosed contracts (the wallet doesn't read these — it just verifies the hash).Sign
Codeconst { signature } = await provider.request({ method: "canton_signTransaction", params: { hash: prep.hash, commandId: prep.commandId, hashingSchemeVersion: prep.hashingSchemeVersion, summary: prep.summary, }, });The wallet renders
prep.summaryas a card and asks the user. On approve, it signsprep.hashwith the user's key and resolves with{ signature, signedBy, partyId }. On reject, it throws anErrorwithcode: 4001.Execute
Codeconst result = await fetch("/api/wallet/cc-transfer/execute", { method: "POST", headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json", }, body: JSON.stringify({ commandId: prep.commandId, transaction: prep.transaction, signature, }), }).then((r) => r.json()); // result === { commandId, status: "settled" | "pending" | "failed", txId? }Most actions return synchronously with
status: "settled". Some (notably swap intents) returnstatus: "pending"and require polling.Poll for async settlement when needed
Swap intents settle asynchronously. When
result.status === "pending", poll the status endpoint:Codeasync function waitForSettlement(intentCid: string): Promise<"settled" | "expired" | "cancelled"> { for (let i = 0; i < 60; i++) { const status = await fetch(`/api/swap/intent/status?intentCid=${intentCid}`, { headers: { Authorization: `Bearer ${jwt}` }, }).then((r) => r.json()); if (status.state === "settled") return "settled"; if (status.state === "expired" || status.state === "cancelled") return status.state; await new Promise((r) => setTimeout(r, 1000)); } throw new Error("Settlement polling timed out"); }Or skip polling entirely and listen for the
txChangedevent — see Handle events.
Errors at each stage
Prepare-time errors are HTTP errors from the backend. Standard HTTP status codes:
400— bad request (validation, missing fields).401— JWT missing or expired. Re-run SIWC.403— JWT belongs to a different party than the prepare claims.409— conflict (insufficient balance, listing already cancelled).
The body has the canonical error shape from Error codes.
Sign-time errors thrown from the wallet:
4001— user rejected the prompt. Don't retry automatically.4100— wallet locked or unauthorized. Surface a passive prompt.-32003— the wallet's policy rejected the transaction (typically because the displayed summary doesn't match the hash).
Execute-time errors are HTTP errors, similar shape to prepare:
409— race condition (someone else took the listing first). Refetch state and offer a retry.503— settlement timeout from Canton. Safe to retry the same execute call (it's idempotent oncommandId).
Idempotency
Each prepare returns a unique commandId. The execute endpoint dedupes
on it — retrying with the same body returns the existing result instead
of submitting again. This makes "retry on network error" safe.
It does not make "retry on sign-time error" safe. A new signature needs a new prepare.
What's next
- Handle events — react to txChanged so you don't have to poll.
- Provider methods reference — every method, parameters, return types.
- The signing model — the why behind this dance.