Skip to content
Guides

Cancel and 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.

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 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.

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.

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.
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.