# Cancel and refunds

> When orders refund, how a user cancels early, and how to sign the cancel.

## When an order refunds

Settlement pays the filled part to `payoutAddr` on the destination chain and refunds the rest of the deposit to `refundAddr` on the source chain. A refund happens when the deadline passes before the order fills completely, or when the user cancels a published order. When an order expires, Khalani settles it automatically and you don't need to do anything.

The flat fee is kept in every case, including an order that never fills. On a partly filled order, the percentage fees apply to the filled part only. See [Fees and commission](/guides/fees/).

## Cancel early

Once an order is `published`, an EOA depositor can end it early, including after a partial fill. The depositor signs an EIP-712 message in their wallet, and your backend relays it with `POST /v2/orders/{orderId}/cancel`. The filled part is paid out and the rest refunded.

**Contract-wallet depositors cannot use this self-service path.** The endpoint recovers an ECDSA signer and requires it to be the depositor or Khalani's operator; it does not validate ERC-1271 signatures. A Safe owner's signature does not match the depositing Safe. Wait for automatic settlement at expiry, or contact Khalani for help cancelling early.

- A `200` is an acceptance, not settlement. The hub transaction and the transfers follow within minutes. [Keep polling](/guides/track-orders/) until `delivered` or `delivery_failed`, with a local timeout.
- A cancel that races a fill is safe. Whichever lands first settles the order.
- Before `published` there is nothing to cancel, and the route returns `409`. An order the user never deposits stays at `pending_deposit`, and nothing needs refunding.
- You can cancel only your own orders. Another integrator's `orderId` returns `404`.

## Sign the cancel

The signature comes from the depositor's wallet, not your API key, so a cancel has two halves: your frontend asks the wallet to sign, and your backend relays the signature with its key. Build the typed data exactly as below. Any difference makes signature recovery fail.

```ts title="cancel.ts"
// Cancel an order in two halves: the user's wallet signs in your frontend, and your backend relays the
// signature with its API key. The API key never reaches the browser.
import { bytesToHex, type Hex, type WalletClient } from "viem";

const API = "https://api.hyperstream.dev";

// Production constants for the cancel's EIP-712 domain.
const HUB_CHAIN_ID = 4278608;
const ORDER_DELEGATE_V2 = "0xa83d18aa3F32270762147e0E0F2CC3396A8f157A";

export type SignedCancel = { payload: { nonce: string; rootIntentId: Hex }; signature: Hex };

// Frontend: the depositor's wallet signs. rootIntentId is the order's traces.rootIntentId, set once
// the order is published; GET /v2/orders/{orderId} is public, so the frontend can read it directly.
export async function signCancel(wallet: WalletClient, rootIntentId: Hex): Promise<SignedCancel> {
	const account = wallet.account ?? (await wallet.getAddresses())[0];
	if (!account) throw new Error("Connect the depositor's wallet first");

	// A fresh random nonce on every attempt. It is not the build nonce, and a reused one is rejected.
	const nonce = BigInt(bytesToHex(crypto.getRandomValues(new Uint8Array(32))));

	const signature = await wallet.signTypedData({
		account,
		domain: { name: "Arcadia", version: "1", chainId: HUB_CHAIN_ID, verifyingContract: ORDER_DELEGATE_V2 },
		types: {
			OrderDelegateV2Cancel: [
				{ name: "nonce", type: "uint256" },
				{ name: "rootIntentId", type: "bytes32" },
			],
		},
		primaryType: "OrderDelegateV2Cancel",
		message: { nonce, rootIntentId },
	});
	return { payload: { nonce: nonce.toString(), rootIntentId }, signature };
}

// Backend: relay the signed cancel with your API key.
export async function relayCancel(orderId: Hex, cancel: SignedCancel, apiKey: string) {
	const res = await fetch(`${API}/v2/orders/${orderId}/cancel`, {
		method: "POST",
		headers: { "Content-Type": "application/json", "X-API-Key": apiKey },
		body: JSON.stringify(cancel),
	});
	if (!res.ok) throw new Error(`${res.status}: ${((await res.json()) as { error?: string }).error}`);
	// Accepted, not settled: poll until delivered or delivery_failed, with a local timeout.
	return (await res.json()) as { medusaSubmissionId: string };
}
```

- **Domain.** `name` is `"Arcadia"` and `version` is `"1"`, exactly. `chainId` and `verifyingContract` are the production hub chain and its order contract.
- **Nonce.** A fresh random `uint256` on every attempt, retries included. It is not the build nonce and is not tied to the order, and a reused value is rejected.
- **No expiry.** The signature stays valid until its nonce is used, so don't collect cancel signatures in advance.

## Cancel errors

| Status | `error` | What to do |
|---|---|---|
| `400` | `Payload rootIntentId does not match the order's published root intent` | Sign against the order's `traces.rootIntentId`. |
| `404` | `Order not found` | Unknown `orderId`, or another integrator's order. |
| `409` | `Order has not been published yet …` | Wait for `published`. |
| `502` | The rejection message | Read the order and the rejection. If it has settled, keep tracking delivery. Fix a wrong signer or malformed signature before retrying; a fresh nonce cannot fix a signer mismatch. For a reused nonce or temporary failure, retry with a fresh nonce only while the order is still open. |
