Quickstart (v1)
This page runs one real v1 order: 10 USDC from Arbitrum to Base, from a browser page with an EVM wallet. Most of the time goes to waiting for the order to fill.
Before you start
Section titled “Before you start”- A browser with an EIP-1193 wallet that injects
window.ethereum, such as MetaMask. - 10 USDC on Arbitrum in that wallet, and a little ETH on Arbitrum for gas.
- Node.js 22 or later to run the TypeScript compiler, and Python 3 for the local web server below.
- No API key: v1 does not use one.
Run it
Section titled “Run it”Save the following as v1-quickstart.ts in a new directory. It has no runtime package dependencies, but browsers cannot execute TypeScript directly.
// API v1 (deprecated): one order end to end. Quote, build the wallet actions, send them from the// user's wallet, submit the deposit hash, and track the order until it is final.// Compile to JavaScript first, then load as an ES module in a browser page with an injected// EIP-1193 wallet (window.ethereum). See the guide for the compiler command and HTML page.// v1 takes no API key.export {};
type Eip1193Provider = { request(args: { method: string; params?: readonly unknown[] }): Promise<unknown>;};
declare global { interface Window { ethereum?: Eip1193Provider; }}
const API = "https://api.hyperstream.dev";
// 10 USDC on Arbitrum → USDC on Base.const TRADE = { tradeType: "EXACT_INPUT", fromChainId: 42161, fromToken: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", toChainId: 8453, toToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", amount: "10000000", // base units: 10 × 10^6};
// Stop polling on any of these. refund_pending is not final: it moves on to refunded.const FINAL_STATUSES = new Set(["filled", "refunded", "failed"]);
type Route = { routeId: string; type: string; depositMethods: string[]; quote: { amountIn: string; amountOut: string; validBefore: number; quoteExpiresAt: number };};type QuoteResponse = { quoteId: string; routes: Route[] };type WalletAction = | { type: "eip1193_request"; request: { method: string; params?: readonly unknown[] }; waitForReceipt?: boolean; deposit?: boolean; } | { type: "solana_sendTransaction"; transaction: string; deposit?: boolean };type DepositPlan = | { kind: "CONTRACT_CALL"; approvals?: WalletAction[]; expiresAt?: number; buildId?: string } | { kind: "TRANSFER" | "PERMIT2" };type Order = { id: string; status: string; routeId: string; srcAmount: string; destAmount: string };type Receipt = { status: string };
const wallet = connectedWallet();const [from] = (await wallet.request({ method: "eth_requestAccounts" })) as string[];if (!from) throw new Error("The wallet returned no account");
// 1. Quote. When the native route quotes, it is the only route returned.const quote = await api<QuoteResponse>("/v1/quotes", { method: "POST", body: JSON.stringify({ ...TRADE, fromAddress: from }),});const route = quote.routes[0];if (!route) throw new Error("No route serves this pair and amount right now");if (Date.now() / 1000 >= route.quote.quoteExpiresAt) throw new Error("The quote expired: request a new one");console.log(`${route.routeId}: you send ${route.quote.amountIn}, you receive ${route.quote.amountOut}`);
// 2. Build the wallet actions for the chosen route, for the wallet that quoted.const plan = await api<DepositPlan>("/v1/deposit/build", { method: "POST", body: JSON.stringify({ from, quoteId: quote.quoteId, routeId: route.routeId }),});if (plan.kind !== "CONTRACT_CALL") throw new Error(`This example covers wallet deposits only, not ${plan.kind}`);
// 3. Run the actions in order, unchanged: switch chain, approve if needed, deposit. Wait for a// receipt wherever the plan asks for one, and keep the hash of the action marked deposit.let depositTxHash: string | undefined;for (const action of plan.approvals ?? []) { if (action.type !== "eip1193_request") throw new Error("Expected EVM wallet actions"); const result = await wallet.request(action.request); if (action.request.method !== "eth_sendTransaction") continue; if (typeof result !== "string") throw new Error("The wallet returned no transaction hash"); if (action.waitForReceipt) await waitForReceipt(result); if (action.deposit) depositTxHash = result;}if (!depositTxHash) throw new Error("The plan had no deposit action");
// 4. Submit the deposit hash once. A build returns buildId only for a quote made without// fromAddress; when it does, submit must send it back.const submitted = await api<{ orderId: string; txHash: string }>("/v1/deposit/submit", { method: "PUT", body: JSON.stringify({ txHash: depositTxHash, quoteId: quote.quoteId, routeId: route.routeId, ...(plan.buildId ? { buildId: plan.buildId } : {}), }),});console.log(`Order ${submitted.orderId}`);
// 5. Track until the order is final.let order = await api<Order>(`/v1/orders/by-id/${submitted.orderId}`);while (!FINAL_STATUSES.has(order.status)) { await sleep(5_000); order = await api<Order>(`/v1/orders/by-id/${submitted.orderId}`); console.log(order.status);}console.log(order);
function connectedWallet(): Eip1193Provider { if (!window.ethereum) throw new Error("No EIP-1193 wallet found: window.ethereum is missing"); return window.ethereum;}
async function waitForReceipt(hash: string): Promise<void> { for (;;) { const receipt = (await wallet.request({ method: "eth_getTransactionReceipt", params: [hash] })) as Receipt | null; if (receipt) { if (receipt.status !== "0x1") throw new Error(`Transaction ${hash} reverted`); return; } await sleep(2_000); }}
// Error bodies vary (JSON exceptions, a rate-limit object, plain text), so read text first.async function api<T>(path: string, init: RequestInit = {}): Promise<T> { const res = await fetch(`${API}${path}`, { ...init, headers: init.body ? { "Content-Type": "application/json" } : {}, }); const text = await res.text(); if (!res.ok) throw new Error(`${res.status} ${path}: ${text} (x-request-id ${res.headers.get("x-request-id")})`); return JSON.parse(text) as T;}
function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms));}Compile it to a browser ES module:
npx --yes --package typescript@5.9.3 tsc v1-quickstart.ts \ --target ES2022 --module ES2022 --lib ES2022,DOM --strict \ --outDir public --noEmitOnErrorSave this page as public/index.html. The button starts the order and asks the wallet to connect:
<!doctype html><html lang="en"> <meta charset="utf-8"> <title>Khalani v1 quickstart</title> <button>Start order</button> <p id="status">Open the browser console to follow progress.</p> <script type="module"> document.querySelector("button").addEventListener("click", async (event) => { event.currentTarget.disabled = true; try { await import("./v1-quickstart.js"); document.querySelector("#status").textContent = "Order finished. See the console for its final status."; } catch (error) { document.querySelector("#status").textContent = String(error); } }); </script></html>Serve the compiled files and open http://localhost:8000 in your wallet’s browser:
python3 -m http.server 8000 --directory public --bind 127.0.0.1Click Start order once and approve the wallet requests. The script logs the order id and its final status in the console. Reloading the page and clicking again starts a new order.
What each step does
Section titled “What each step does”- Quote.
POST /v1/quotesasks every route for a price for the connected wallet. When the native route quotes, it is the only route inroutes[]. Otherwise you get the external routes that quoted. An emptyroutes[]means nothing serves that pair and amount right now. - Build.
POST /v1/deposit/buildtakes thequoteId, the chosenrouteIdand the wallet infrom. It returnskind: "CONTRACT_CALL"with an ordered list of wallet actions inapprovals[]. Build beforequoteExpiresAt. After it, the build fails with400(or404once the stored quote is gone), and you must quote again. - Execute. The script forwards each action to the wallet unchanged: switch chain, approve if needed, then deposit. Where an action has
waitForReceipt: true, it waits for the receipt before going on, so the deposit never runs ahead of its approval. It keeps the hash of the action markeddeposit: true. - Submit.
PUT /v1/deposit/submitregisters that hash and creates the order. Submit once: a second submit for the same quote returns404, because the quote is consumed. When the build returned abuildId, send it with the submit. - Track. The script polls
GET /v1/orders/by-id/{orderId}every 5 seconds until the status isfilled,refundedorfailed.refund_pendingis not final. It means the order is waiting for its refund to arrive.
With curl
Section titled “With curl”The quote and the build from a shell, without a wallet. Replace the example address with the wallet that will deposit.
BASE=https://api.hyperstream.devWALLET=0x5b38da6a701c568545dcfcb03fcb875f56beddc4
QUOTE=$(curl -s -X POST "$BASE/v1/quotes" -H "Content-Type: application/json" -d '{ "tradeType": "EXACT_INPUT", "fromChainId": 42161, "fromToken": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", "toChainId": 8453, "toToken": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "10000000", "fromAddress": "'"$WALLET"'"}')echo "$QUOTE" | jq '.routes[] | {routeId, amountIn: .quote.amountIn, amountOut: .quote.amountOut}'
curl -s -X POST "$BASE/v1/deposit/build" -H "Content-Type: application/json" \ -d "$(echo "$QUOTE" | jq --arg from "$WALLET" '{from: $from, quoteId: .quoteId, routeId: .routes[0].routeId}')"The build reads the wallet’s current allowance on-chain, so it includes an approval only when one is needed.
- Integration guide: routes, deposit methods, and what happens to the user’s funds.
- Wallet execution: EVM and Solana wallets, deposit addresses, and server-side broadcast.
- Handling exceptions: every error shape, and what to retry.