# Quickstart

> Quote, build, deposit and track one real order, end to end, in one TypeScript file.

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.

:::caution[This uses real funds]
This example calls the production API and the deposit moves real tokens. Use small amounts while you integrate.
:::

## Before you start

- An API key. See [API access](/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

Create a new directory, save the code below as `quickstart.ts`, and run:

```bash
npm init -y
npm pkg set type=module
npm install viem@2.57.7
KHALANI_API_KEY=... DEPOSITOR_PRIVATE_KEY=0x... node quickstart.ts
```

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

```ts title="quickstart.ts"
// 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.ts
import { 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^6
const 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

1. **Quote.** `GET /v2/quote` prices 10 USDC. A `200` means that size is fillable now, not that a fill is reserved. Format `spend.amount` and `receive.amount` using their respective `decimals`: `10000000` with six decimals is `10`, not ten million USDC. These amounts reflect the token fees; source-chain transaction gas and `depositTx.value` (the ETH relay fee) are additional. If quoting rejects the amount, inspect `detail.minSrcAmount` or `detail.maxSrcAmount` before choosing another amount.
2. **Build.** `POST /v2/build` takes the quote's `build` object unchanged, including `terms`, plus the depositor's address. Editing signed fields makes signature verification fail. The response holds the `orderId` and transactions to send. Save it before handing transactions to the wallet, and deposit promptly: the order's deadline runs from the build.
3. **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.
4. **Track.** The script polls every 10 seconds until a terminal delivery state: `delivered` or `delivery_failed`. It keeps polling through `withdrawn_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

After a polling error or timeout, use the saved order ID:

```bash
ORDER_ID=0x... node quickstart.ts
```

This 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](/guides/errors/) before retrying a build or deposit.

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

```bash
set -euo pipefail
: "${KHALANI_API_KEY:?Set KHALANI_API_KEY}"
: "${DEPOSITOR:?Set DEPOSITOR to your wallet address}"
BASE=https://api.hyperstream.dev
ARB_USDC=0xaf88d065e77c8cC2239327C5EDb3A432268e5831
BASE_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 included
BUILD_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.

## Next

- [Order lifecycle](/guides/order-lifecycle/): every state an order passes through, and when to stop polling.
- [Build and deposit](/guides/build-and-deposit/): wallet rules, recipients, and non-EVM sources.
- [Errors and retries](/guides/errors/): what to retry and what to fix.
