Quickstart
This example quotes 10 USDC from Arbitrum to Base, builds an order, sends the deposit, and tracks it until delivery succeeds or fails. The quote checks whether that amount is currently fillable.
Before you start
Section titled “Before you start”- An API key. See API access.
- Node.js 22.18 or later, which runs this TypeScript file directly.
- A wallet with 10 USDC on Arbitrum and a little ETH on Arbitrum for gas and the relay fee. In this example the script signs with that wallet’s private key. In your product, the user’s own wallet signs the same transactions.
Run it
Section titled “Run it”Create a new directory, save the code below as quickstart.ts, and run:
npm init -ynpm pkg set type=modulenpm install viem@2.57.7KHALANI_API_KEY=... DEPOSITOR_PRIVATE_KEY=0x... node quickstart.tsReplace the placeholders with your API key and the funded wallet’s private key. Keep both out of source control. The script uses Arbitrum’s public RPC by default; set ARBITRUM_RPC_URL to use your own endpoint.
Each run without ORDER_ID creates a new order and sends real funds. The script saves the build response to order-<orderId>.json before sending transactions and adds the deposit hash before waiting for its receipt.
// Node.js 22.18+: set KHALANI_API_KEY and DEPOSITOR_PRIVATE_KEY, then node quickstart.ts.// To track an existing order without sending funds: ORDER_ID=0x... node quickstart.tsimport { writeFile } from "node:fs/promises";import { createPublicClient, createWalletClient, formatUnits, type Hex, http } from "viem";import { privateKeyToAccount } from "viem/accounts";import { arbitrum } from "viem/chains";
const API = "https://api.hyperstream.dev";const API_KEY = process.env["KHALANI_API_KEY"];
// 10 USDC on Arbitrum → USDC on Base. The quote checks current availability and size limits.const FROM = { chainId: 42161, token: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" };const TO = { chainId: 8453, token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" };const AMOUNT = "10000000"; // base units: 10 × 10^6const TERMINAL_STATES = new Set(["delivered", "delivery_failed"]);
type Quote = { spend: { amount: string; symbol: string; decimals: number }; receive: { amount: string; symbol: string; decimals: number }; build: Record<string, unknown>;};type Tx = { to: Hex; data: Hex; value: string; chainId: number };type Built = | { orderId: Hex; nonce: string; approveTxs?: Tx[]; depositTx: Tx & { from: Hex; gasLimit: Hex } } | { orderId: Hex; nonce: string; depositAddress: string; expectedAmount: string; deadline: number };type Delivery = { tx: string; amount: string | null };type Order = { orderId: Hex; state: string; deliver: { payout: Delivery | null; refund: Delivery | null } | null; deliveryFailed: unknown;};
const orderId = process.env["ORDER_ID"] ?? (await deposit());await track(orderId);
async function deposit(): Promise<Hex> { requireEnv("KHALANI_API_KEY"); // In your product, the user's own wallet signs these transactions. const account = privateKeyToAccount(requireEnv("DEPOSITOR_PRIVATE_KEY") as Hex); const transport = http(process.env["ARBITRUM_RPC_URL"]); const wallet = createWalletClient({ account, chain: arbitrum, transport }); const chain = createPublicClient({ chain: arbitrum, transport }); if ((await chain.getChainId()) !== FROM.chainId) throw new Error("RPC must connect to Arbitrum One");
// 1. Quote. Amounts are base-unit strings; format them using each token's decimals. const query = new URLSearchParams({ fromChainId: String(FROM.chainId), from: FROM.token, toChainId: String(TO.chainId), to: TO.token, srcAmount: AMOUNT, }); const quote = await api<Quote>(`/v2/quote?${query}`); for (const [label, token] of [ ["Spend", quote.spend], ["Quoted receive", quote.receive], ] as const) { console.log(`${label}: ${formatUnits(BigInt(token.amount), token.decimals)} ${token.symbol}`); }
// 2. Build once, with the quote's build object unchanged, including terms. // A successful build creates a new order. Never automatically retry an ambiguous response. const built = await api<Built>("/v2/build", { method: "POST", body: JSON.stringify({ ...quote.build, depositor: account.address }), }); console.log(`Order ${built.orderId}`); const record = `order-${built.orderId}.json`; await writeFile(record, JSON.stringify(built, null, 2), { flag: "wx" }); console.log(`Saved ${record}`); if ("depositAddress" in built) throw new Error("This example covers EVM sources only"); const { depositTx } = built; if ( depositTx.from.toLowerCase() !== account.address.toLowerCase() || [...(built.approveTxs ?? []), depositTx].some((tx) => tx.chainId !== FROM.chainId) ) { throw new Error("Build returned a different depositor or source chain; no transactions sent"); } console.log(`Relay fee: ${formatUnits(BigInt(depositTx.value), 18)} ETH, plus transaction gas`);
// 3. Send approvals in order. A mined transaction can have reverted: check its status. for (const tx of built.approveTxs ?? []) { const hash = await wallet.sendTransaction({ to: tx.to, data: tx.data, value: BigInt(tx.value) }); console.log(`Approval ${hash}`); await confirmed(hash); } // Send exactly once. Log and save the hash BEFORE waiting so a timeout is recoverable. const depositHash = await wallet.sendTransaction({ to: depositTx.to, data: depositTx.data, value: BigInt(depositTx.value), gas: BigInt(depositTx.gasLimit), }); console.log(`Deposit ${depositHash}`); await writeFile(record, JSON.stringify({ ...built, depositHash }, null, 2)); await confirmed(depositHash); return built.orderId;
async function confirmed(hash: Hex): Promise<void> { const receipt = await chain.waitForTransactionReceipt({ hash, timeout: 120_000 }); if (receipt.status !== "success") throw new Error(`Transaction reverted: ${hash}. Stop and inspect before retrying.`); }}
// 4. Track until delivery succeeds or fails. withdrawn_on_hub is still awaiting delivery.async function track(id: string): Promise<void> { if (!/^0x[0-9a-fA-F]{64}$/.test(id)) throw new Error("ORDER_ID must be a 32-byte hex order ID"); console.log(`Track again without sending funds: ORDER_ID=${id} node quickstart.ts`); const stopAt = Date.now() + 30 * 60_000; // Local polling limit; this does not cancel the order. while (Date.now() < stopAt) { const order = await api<Order>(`/v2/orders/${id}`); console.log(order.state); if (TERMINAL_STATES.has(order.state)) { console.log(JSON.stringify(order, null, 2)); if (order.state === "delivery_failed") throw new Error(`Delivery failed. Contact Khalani with orderId ${id}.`); // The API uses delivered for both payouts and refunds; label the recorded outcome. const outcome = order.deliver?.refund ? (order.deliver.payout ? "Payout and refund delivered" : "Refunded") : "Delivered"; console.log(`${outcome}. Check deliver for the amounts and transactions.`); return; } await new Promise((resolve) => setTimeout(resolve, 10_000)); } throw new Error(`Polling timed out for ${id}; delivery is unconfirmed. Resume with ORDER_ID; do not send another deposit.`);}
async function api<T>(path: string, init: RequestInit = {}): Promise<T> { const res = await fetch(`${API}${path}`, { ...init, signal: AbortSignal.timeout(30_000), headers: { "Content-Type": "application/json", ...(API_KEY ? { "X-API-Key": API_KEY } : {}) }, }); const body: unknown = await res.json().catch(() => null); if (!res.ok || body === null) { const requestId = res.headers.get("x-request-id"); throw new Error(`${res.status} ${path}: ${JSON.stringify(body) ?? res.statusText} (x-request-id ${requestId})`); } return body as T;}
function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`Set ${name}`); return value;}What each step does
Section titled “What each step does”- Quote.
GET /v2/quoteprices 10 USDC. A200means that size is fillable now, not that a fill is reserved. Formatspend.amountandreceive.amountusing their respectivedecimals:10000000with six decimals is10, not ten million USDC. These amounts reflect the token fees; source-chain transaction gas anddepositTx.value(the ETH relay fee) are additional. If quoting rejects the amount, inspectdetail.minSrcAmountordetail.maxSrcAmountbefore choosing another amount. - Build.
POST /v2/buildtakes the quote’sbuildobject unchanged, includingterms, plus the depositor’s address. Editing signed fields makes signature verification fail. The response holds theorderIdand transactions to send. Save it before handing transactions to the wallet, and deposit promptly: the order’s deadline runs from the build. - Deposit. The wallet sends each approval in order and checks that its receipt has
status: "success", then sends the deposit exactly once from the depositor’s address. A reverted approval stops the script before the deposit; a reverted deposit stops it before tracking. A deposit from another account can strand funds. - Track. The script polls every 10 seconds until a terminal delivery state:
deliveredordelivery_failed. It keeps polling throughwithdrawn_on_hub, since hub settlement precedes delivery. After 30 minutes it exits with delivery unconfirmed; that local timeout neither cancels the order nor means delivery failed.
The payout goes to the depositor’s address on Base; any unfilled remainder, after fees, is refunded to that address on Arbitrum. A quote does not guarantee a complete fill. The printed order includes traces.withdraw.settledOutcome in destination base units and refundSource in source base units. These can be null briefly while indexing catches up.
deliver.payout and deliver.refund record the transfers separately; delivered means every dispatched transfer was recorded. The script prints Refunded when only a refund was delivered, Delivered for a payout, or Payout and refund delivered for both. The API currently uses delivered for all three outcomes; refunded is not a v2 state.
For delivery_failed, inspect deliveryFailed and contact Khalani with the orderId. Delivery tracking can miss a result and leave the order at withdrawn_on_hub, so use the tracking-only command below to check again after a timeout.
Resume tracking
Section titled “Resume tracking”After a polling error or timeout, use the saved order ID:
ORDER_ID=0x... node quickstart.tsThis mode only reads the existing order. It needs neither the private key nor the API key. It does not build, approve, or deposit again.
If sending a transaction or waiting for its receipt fails, first check the transaction hash on Arbitrum. A timeout does not mean the transaction reverted. Use the saved order ID to check progress, and see Errors and retries before retrying a build or deposit.
With curl
Section titled “With curl”To try the quote and build without signing transactions, save this as quickstart.sh. Set KHALANI_API_KEY and DEPOSITOR (the EVM address of the wallet that will deposit), then run bash quickstart.sh. This requires jq and curl 7.76 or later. It creates an order but does not fund it; use it as an alternative to the TypeScript run.
set -euo pipefail: "${KHALANI_API_KEY:?Set KHALANI_API_KEY}": "${DEPOSITOR:?Set DEPOSITOR to your wallet address}"BASE=https://api.hyperstream.devARB_USDC=0xaf88d065e77c8cC2239327C5EDb3A432268e5831BASE_USDC=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
QUOTE=$(curl --fail-with-body -sS "$BASE/v2/quote?fromChainId=42161&from=$ARB_USDC&toChainId=8453&to=$BASE_USDC&srcAmount=10000000" \ -H "X-API-Key: $KHALANI_API_KEY")printf '%s\n' "$QUOTE" | jq '{spend, receive}' # amounts are base units; decimals are includedBUILD_BODY=$(printf '%s\n' "$QUOTE" | jq -e --arg d "$DEPOSITOR" '.build + {depositor: $d}')
curl --fail-with-body -sS -X POST "$BASE/v2/build" \ -H "X-API-Key: $KHALANI_API_KEY" -H "Content-Type: application/json" \ -d "$BUILD_BODY"Save the returned orderId before sending any transactions. HTTP errors stop the shell script, so a failed quote is never passed to build. A 409 from build means the signed terms have expired: request a fresh quote. Check build.terms.expiresAt (Unix seconds); quotes currently expire after five minutes.
- Order lifecycle: every state an order passes through, and when to stop polling.
- Build and deposit: wallet rules, recipients, and non-EVM sources.
- Errors and retries: what to retry and what to fix.