Skip to content
API v1 is deprecated. It stays supported, but new integrations should use the Khalani API. See Migrate to v2.
API v1 · Deprecated

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.

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

Save the following as v1-quickstart.ts in a new directory. It has no runtime package dependencies, but browsers cannot execute TypeScript directly.

v1-quickstart.ts
// 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:

Terminal window
npx --yes --package typescript@5.9.3 tsc v1-quickstart.ts \
--target ES2022 --module ES2022 --lib ES2022,DOM --strict \
--outDir public --noEmitOnError

Save 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:

Terminal window
python3 -m http.server 8000 --directory public --bind 127.0.0.1

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

  1. Quote. POST /v1/quotes asks every route for a price for the connected wallet. When the native route quotes, it is the only route in routes[]. Otherwise you get the external routes that quoted. An empty routes[] means nothing serves that pair and amount right now.
  2. Build. POST /v1/deposit/build takes the quoteId, the chosen routeId and the wallet in from. It returns kind: "CONTRACT_CALL" with an ordered list of wallet actions in approvals[]. Build before quoteExpiresAt. After it, the build fails with 400 (or 404 once the stored quote is gone), and you must quote again.
  3. 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 marked deposit: true.
  4. Submit. PUT /v1/deposit/submit registers that hash and creates the order. Submit once: a second submit for the same quote returns 404, because the quote is consumed. When the build returned a buildId, send it with the submit.
  5. Track. The script polls GET /v1/orders/by-id/{orderId} every 5 seconds until the status is filled, refunded or failed. refund_pending is not final. It means the order is waiting for its refund to arrive.

The quote and the build from a shell, without a wallet. Replace the example address with the wallet that will deposit.

Terminal window
BASE=https://api.hyperstream.dev
WALLET=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.