API Client
The @tokenflight/api package is the HTTP client that powers the widget. You can consume it directly — build a headless bot, a custom UI, an SSR backend — without pulling in the Web Component or Solid.js.
The widget itself depends on this package; anything the widget can do, you can do with the raw client.
Installation
npm install @tokenflight/apipnpm add @tokenflight/apiyarn add @tokenflight/apibun add @tokenflight/apiPeer requirements: none. The package ships typed exports, is ESM-first with a UMD fallback, and uses ky under the hood.
Quick start
import { HyperstreamApi, DEFAULT_API_ENDPOINT } from "@tokenflight/api";
const api = new HyperstreamApi({ baseUrl: DEFAULT_API_ENDPOINT });
const { quoteId, routes } = await api.getQuotes({
tradeType: "EXACT_INPUT",
fromChainId: 1,
fromToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC mainnet
toChainId: 8453,
toToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC base
amount: "1000000",
fromAddress: "0xYourWallet",
});
console.log(`Quote ${quoteId} — ${routes.length} route(s) available`);→ Continue with Crypto swap example for the full lifecycle (build, submit, track), or Fiat on-ramp example for the card path.
HyperstreamApi
The main client. All methods return Promise<T> and accept an optional trailing AbortSignal.
Constructor
new HyperstreamApi({
baseUrl, // required — e.g. DEFAULT_API_ENDPOINT
timeout, // optional — per-request timeout in ms (default 15000)
fetch, // optional — custom fetch (SSR, Workers, mocks)
headers, // optional — extra request headers
});| Option | Type | Notes |
|---|---|---|
baseUrl | string | API base URL. Trailing slash is normalized. |
timeout | number | Per-request timeout in ms. Defaults to 15000. |
fetch | typeof globalThis.fetch | Useful for SSR, Cloudflare Workers, or test mocks. Also applies to streamQuotes. |
headers | Record<string, string> | Merged on top of the built-in X-TF-SDK-Version. Overrides are allowed. |
Quotes
getQuotes(request, opts?)
POST /v1/quotes — returns routes for a cross-chain swap. opts accepts { signal? }.
const { quoteId, routes } = await api.getQuotes({
tradeType: "EXACT_INPUT" | "EXACT_OUTPUT",
fromChainId: 1,
fromToken: "0x...",
toChainId: 8453,
toToken: "0x...",
amount: "1000000", // raw units of fromToken (EXACT_INPUT) or toToken (EXACT_OUTPUT)
fromAddress: "0x...", // wallet that signs the deposit
recipient: "0x...", // optional — defaults to fromAddress
refundTo: "0x...", // optional — where the filler refunds if fill fails
slippageTolerance: 0.005, // optional fraction (0.005 = 0.5%)
});Throws CannotFillError, NotSupportedTokenError, NotSupportedChainError.
streamQuotes(request, opts?)
POST /v1/quotes?mode=stream — yields each route as its filler responds. Same request shape as getQuotes, but returns an AsyncIterable. opts accepts { signal? }.
for await (const route of api.streamQuotes(request)) {
console.log(route.routeId, route.quote.amountOut);
}Pick the first route that satisfies your UX, or collect them all with Array.fromAsync.
Deposit
buildDeposit({ from, quoteId, routeId }, opts?)
Translates a quoted route into a sequence of wallet actions to execute.
const deposit = await api.buildDeposit({
from: "0xYourWallet",
quoteId,
routeId: routes[0]!.routeId,
});
// deposit = { kind: "CONTRACT_CALL", approvals: Approval[] }Approval shapes (approval.type discriminates):
type Approval =
| {
type: "eip1193_request";
request: { method: string; params: unknown[] }; // pass to wallet.request(...)
waitForReceipt?: boolean; // wait for confirmation before next step
deposit?: boolean; // this one's tx hash is the depositTxHash
}
| {
type: "solana_sendTransaction";
transaction: string; // base64-encoded tx — sign + broadcast
deposit?: boolean;
};Rules:
- Execute approvals in order — earlier ones (token approvals, swaps to a routing token) gate the deposit.
- Exactly one approval has
deposit: true. Its tx hash (or signed tx, for Solana) is whatsubmitDepositneeds. - Honor
waitForReceipt: true— pause until the chain confirms before moving to the next approval. Skipping it can cause the next call to fail with stale state.
Throws QuoteExpiredError, QuoteNotFoundError, NotSupportedDepositMethodError, UnexpectedFromAddressError.
submitDeposit(params, opts?)
PUT /v1/deposit/submit — notify the backend that the deposit tx was broadcast, or hand off a pre-signed Solana tx.
// EVM path — pass the tx hash after sending
const { orderId, txHash } = await api.submitDeposit({
quoteId,
routeId,
txHash: "0xabc...",
});
// Solana path — hand off the signed transaction
await api.submitDeposit({ quoteId, routeId, signedTransaction: signedTxBase64 });Throws BroadcastError, DuplicateRecordError.
Orders
| Method | Endpoint | Purpose |
|---|---|---|
getOrder(orderId, opts?) | GET /v1/orders/by-id/:orderId | Global lookup — no author filter |
getOrdersByAddress(address, opts?) | GET /v1/orders/:address | Paginated list by author. opts: { orderIds?, limit?, cursor?, signal? } |
getOrderById(address, orderId, opts?) | GET /v1/orders/:address?orderIds= | Author-scoped single lookup — returns null if not found |
Poll an order until it reaches a terminal state — use trackOrder, which wraps the getOrder + setTimeout loop and surfaces progress via onStatus:
const finalOrder = await api.trackOrder(orderId, {
intervalMs: 2000, // optional — defaults to 2000
onStatus: (o) => console.log(o.status),
});
console.log("Done:", finalOrder.status, finalOrder.transactions?.fill?.txHash);trackOrder resolves once the order reaches TERMINAL_ORDER_STATUSES (filled / refunded / failed), retries transient errors with a 5s backoff, and rejects with ApiAbortedError when a passed AbortSignal fires. Order endpoints don't expose a polling hint (unlike FiatStatusResponse.polling) — pick intervalMs yourself; 2s is what the widget uses.
Tokens
| Method | Endpoint |
|---|---|
getTokens(opts?) | GET /v1/tokens — paginated catalog. opts: { q?, chainIds?, limit?, cursor?, signal? } |
searchTokens(query, opts?) | GET /v1/tokens/search — fuzzy search. opts: { chainIds?, signal? } |
getTopTokens(opts?) | GET /v1/tokens/top — curated defaults. opts: { chainIds?, signal? } |
getTokenBalances(address, opts?) | GET /v1/tokens/balances/:address — on-chain balances. opts: { chainIds?, signal? } |
getTokenAutocomplete(keyword, opts?) | GET /v1/tokens/autocomplete/:keyword — natural-language suggestions. opts: { chainIds?, limit?, signal? } |
getTokenAutocompleteSuggestions(opts?) | GET /v1/tokens/autocomplete/suggestions — seed prompts |
All token-list endpoints accept chainIds?: number[] to scope results.
Chains
import { chainIconUrl } from "@tokenflight/api";
const chains = await api.getChains();
const iconUrl = chainIconUrl(api.baseUrl, 8453); // plain URL, no fetchchainIconUrl is a standalone helper (not a method on the client) — it's pure string construction, so it doesn't need network, auth, or instance state. Returns undefined if baseUrl is missing so you can pass the result straight into optional <img src> props.
Arcadia hub
const config = await api.getArcadiaConfig(); // hub contract addresses
const vaults = await api.getVaults(); // yield vaults
const mTokens = await api.getMTokens(); // vault share tokensIntents
| Method | Endpoint |
|---|---|
getIntent(intentId, opts?) | GET /v1/intent/:intentId |
getIntentByDeposit({ chainId, txHash }, opts?) | GET /v1/intent/deposit — lookup by deposit tx |
getIntentsByAuthor(author, opts?) | GET /v1/intents/:author — paginated. opts: { limit?, cursor?, fromChainId?, toChainId?, signal? } |
Throws IntentNotFoundError on getIntent.
FiatApi
Fiat on-ramp client — separate base URL, same construction pattern.
import { FiatApi, DEFAULT_FIAT_API_ENDPOINT } from "@tokenflight/api";
const fiat = new FiatApi({ baseUrl: DEFAULT_FIAT_API_ENDPOINT });
const { quotes } = await fiat.getQuote({
fiatCurrency: "USD",
fiatAmount: "100",
toChainId: 8453,
toToken: "0x...",
recipient: "0xYourWallet",
});
const order = await fiat.createOrder({ quoteId: quotes[0]!.quoteId, author: "0xYourWallet" });
const { outcome, finalStatus } = await fiat.startCheckout(order);| Method | Purpose |
|---|---|
getProviders(opts?) | List supported providers + their chains, currencies, and payment methods |
getQuote(request, opts?) | One quote per provider for a fiat → crypto purchase |
createOrder(request, opts?) | Bind a quote to the user; returns widgetUrl / wrapperUrl |
startCheckout(order, opts?) | Browser-only. Opens the popup, listens for events, polls until terminal — see example |
getStatus(orderId, opts?) | Poll a single order. opts: { signal?, force? }. force: true bypasses the backend cache (debug only) |
getOrdersByAddress(address, opts?) | Paginated history for a wallet. opts: { status?, limit?, cursor?, signal? } |
FiatStatusResponse.polling.suggestedIntervalMs tells you how long to wait before the next poll; polling.isTerminal flips true once the order is done.
Errors
Every error the client throws extends TokenFlightError. Pattern-match with instanceof — no string parsing required.
import {
TokenFlightError,
ApiTimeoutError,
ApiAbortedError,
QuoteExpiredError,
CannotFillError,
NotSupportedTokenError,
NotSupportedChainError,
} from "@tokenflight/api";
try {
await api.getQuotes(request);
} catch (err) {
if (err instanceof QuoteExpiredError) {
// retry with a fresh quote
} else if (err instanceof CannotFillError) {
// tell the user no filler is available
} else if (err instanceof ApiAbortedError) {
// user canceled — silently ignore
} else if (err instanceof ApiTimeoutError) {
// network slow — retry with backoff
} else if (err instanceof TokenFlightError) {
console.error(`[${err.code}] ${err.message}`, err.details);
}
}Transport errors
| Class | Code | When |
|---|---|---|
ApiTimeoutError | TF3002 | Request exceeded the configured timeout |
ApiAbortedError | TF3009 | An AbortController canceled the request |
Backend exception mapping
The backend returns { name, message, details? }. Each named exception maps to a subclass:
| Backend exception | Typed subclass |
|---|---|
ValidationException / ZodValidationException | ApiValidationError |
BadRequestException | BadRequestError |
QuoteNotFoundException | QuoteNotFoundError |
QuoteExpiredException | QuoteExpiredError |
CannotFillException | CannotFillError |
NotSupportedTokenException | NotSupportedTokenError |
NotSupportedChainException | NotSupportedChainError |
NotSupportedDepositMethodException | NotSupportedDepositMethodError |
UnexpectedFromAddressException | UnexpectedFromAddressError |
IntentNotFoundException | IntentNotFoundError |
DuplicateRecordException | DuplicateRecordError |
BroadcastException | BroadcastError |
InternalErrorException | InternalServerError |
Unknown exception names fall back to a plain TokenFlightError with code TF3001 (API_REQUEST_FAILED).
All subclasses carry the backend's message verbatim and keep the raw { status, body } on error.details.
Cancellation
Every public method accepts an optional trailing opts bag with a signal?: AbortSignal field. When the signal aborts, the in-flight request rejects with ApiAbortedError (TF3009).
const ac = new AbortController();
setTimeout(() => ac.abort(), 3000);
try {
await api.getTokens({ q: "USDC", signal: ac.signal });
} catch (err) {
if (err instanceof ApiAbortedError) {
// expected — consumer canceled
}
}With TanStack Query, pass the signal from the query function:
useQuery({
queryKey: ["tokens", q],
queryFn: ({ signal }) => api.searchTokens(q, { signal }),
});Server-side / Workers
Inject a custom fetch to run inside Cloudflare Workers, Deno, or Node with experimental fetch. Everything else (timeouts, headers, errors) works the same:
const api = new HyperstreamApi({
baseUrl: DEFAULT_API_ENDPOINT,
fetch: globalThis.fetch,
headers: { "X-Tenant": "acme" },
timeout: 30000,
});Types
All request and response types are exported from the HyperstreamApi namespace, so IDE autocomplete works without a second import:
import { HyperstreamApi } from "@tokenflight/api";
function summarize(quote: HyperstreamApi.Quote): string {
return `${quote.amountIn} → ${quote.amountOut} in ~${quote.expectedDurationSeconds}s`;
}The Fiat* types are exported as named types directly (no namespace): FiatQuoteRequest, FiatQuoteItem, FiatStatusResponse, etc.
Examples
Two end-to-end flows covering both payment paths the widget supports.
Crypto swap: quote → build → submit → track
The full lifecycle for a user paying with crypto. This is the exact sequence the widget runs — reproduce it directly when you want a headless bot, a server-side integration, or a custom UI.
import {
HyperstreamApi,
DEFAULT_API_ENDPOINT,
QuoteExpiredError,
CannotFillError,
} from "@tokenflight/api";
const api = new HyperstreamApi({ baseUrl: DEFAULT_API_ENDPOINT });
// Replace with your user's wallet.
const wallet = window.ethereum!;
const fromAddress = "0xYourUserWallet";
// 1. Quote
const { quoteId, routes } = await api.getQuotes({
tradeType: "EXACT_INPUT",
fromChainId: 1,
fromToken: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC mainnet
toChainId: 8453,
toToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC Base
amount: "1000000", // 1 USDC (6 decimals)
fromAddress,
recipient: fromAddress,
});
if (routes.length === 0) throw new Error("No routes");
// Pick whichever route fits your UX (best amountOut, fastest, 1-click-only, etc.).
const route = routes[0]!;
// 2. Build — lowers the quote into concrete wallet actions.
const deposit = await api.buildDeposit({
from: fromAddress,
quoteId,
routeId: route.routeId,
});
// 3. Execute approvals in order. Exactly one has `deposit: true` —
// its tx hash (or signed Solana tx) is what step 4 needs.
let depositTxHash: string | undefined;
let depositSignedTx: string | undefined;
for (const approval of deposit.approvals ?? []) {
if (approval.type === "eip1193_request") {
const txHash = (await wallet.request({
method: approval.request.method,
params: approval.request.params,
})) as string;
if (approval.deposit) depositTxHash = txHash;
// If approval.waitForReceipt is true, wait for the chain to confirm
// before continuing — see the Solana note for the equivalent.
} else if (approval.type === "solana_sendTransaction") {
// Sign + broadcast approval.transaction with your Solana wallet.
const signed = await yourSolanaWallet.signTransaction(approval.transaction);
if (approval.deposit) depositSignedTx = signed;
}
}
// 4. Submit — register the deposit with the backend; returns the order id.
const { orderId } = await api.submitDeposit({
quoteId,
routeId: route.routeId,
...(depositTxHash ? { txHash: depositTxHash } : { signedTransaction: depositSignedTx! }),
});
// 5. Track — poll until the order reaches a terminal state.
const finalOrder = await api.trackOrder(orderId, {
onStatus: (o) => console.log("status:", o.status),
});
console.log("Done:", finalOrder.status, finalOrder.transactions?.fill?.txHash);Common failure modes:
try {
await api.buildDeposit({ from, quoteId, routeId });
} catch (err) {
if (err instanceof QuoteExpiredError) {
// Re-quote and retry — quotes have short TTLs.
} else if (err instanceof CannotFillError) {
// No filler can take this route; ask the user to pick another or re-quote.
} else {
throw err;
}
}Solana route: approval.transaction is the base64-encoded tx — deserialize, sign with the user's wallet (e.g. Phantom's signTransaction), and the result goes into submitDeposit({ ..., signedTransaction }). The backend broadcasts it; you don't need an RPC of your own.
Fiat on-ramp: quote → create order → checkout
The card path. FiatApi.startCheckout opens the provider widget in a popup, listens for its postMessage events, polls the backend, and watches the popup for user-initiated close — all you do is await the result.
import { FiatApi, DEFAULT_FIAT_API_ENDPOINT } from "@tokenflight/api";
const fiat = new FiatApi({ baseUrl: DEFAULT_FIAT_API_ENDPOINT });
const recipient = "0xYourUserWallet";
// 1. Quote — returns one item per provider. Present them to the user.
const { quotes } = await fiat.getQuote({
fiatCurrency: "USD",
fiatAmount: "100",
toChainId: 8453,
toToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
recipient,
});
if (quotes.length === 0) throw new Error("No providers available");
const picked = quotes[0]!;
// For jump-strategy quotes (USDC → target via bridge), each route is an
// option the user chooses between. Direct-strategy quotes omit `routes`.
const routeId = picked.swapStrategy === "jump" ? picked.routes?.[0]?.routeId : undefined;
// 2. Create the order — binds the quote to the user and returns a popup URL.
const order = await fiat.createOrder({
quoteId: picked.quoteId,
author: recipient,
routeId,
});
// 3. Open the popup, listen for events, poll the backend, watch for close.
const { outcome, finalStatus } = await fiat.startCheckout(order, {
onStatus: (status) => console.log("progress:", status.progress),
});
if (outcome === "completed") {
console.log("Fill tx:", finalStatus.fillTxHash);
} else if (outcome === "failed") {
console.error("Failed:", finalStatus.failedReason ?? finalStatus.errorReason);
} else {
console.log("User canceled the checkout");
}startCheckout resolves once any of these settle the order: a provider order_completed / order_failed event (after widget_ready), a backend status with polling.isTerminal = true, the user closing the popup, or your own AbortSignal firing. It always returns the latest getStatus payload as finalStatus so you don't have to refetch.
Browser-only. The helper depends on window.open and postMessage. In SSR / Workers, run only the HTTP calls (getQuote, createOrder, getStatus) and handle the popup on the client.
Failure modes:
| When | What |
|---|---|
| Popup blocked | rejects with TokenFlightError({ code: "TF6006" }) |
| Outside a browser | rejects with TokenFlightError({ code: "TF6007" }) |
getStatus fails on settle | rejects with the underlying typed error (ApiTimeoutError, etc.) |
Customizing the popup:
await fiat.startCheckout(order, {
popup: { width: 600, height: 800 },
minPollIntervalMs: 3000, // floor — backend's suggestion still wins if higher
signal: controller.signal, // abort programmatically
onEvent: (e) => console.log(e.event), // every FIAT_WIDGET_EVENT postMessage
});Strategies: swapStrategy: "direct" means the provider lands the target token directly. "jump" means the provider settles a stablecoin on a hub chain, then Hyperstream bridges it to the requested token — that's why jump quotes carry a routes[] and the target token metadata may arrive lazily. startCheckout and finalStatus work the same for both; only the quote presentation differs.
Driving checkout manually
If you need a different host surface (full redirect, iframe embed, custom popup window manager), skip startCheckout and orchestrate the four primitives yourself:
import { TERMINAL_FIAT_STATUSES } from "@tokenflight/api";
// a) Full redirect — the user lands on the provider's hosted page.
window.location.href = order.widgetUrl;
// b) Iframe embed — the wrapper page relays provider events to the parent
// via postMessage as { type: "FIAT_WIDGET_EVENT", event, orderId, ... }.
// Validate the message origin against your fiat API base URL.
// In any case, poll the backend on the side:
while (true) {
const status = await fiat.getStatus(order.orderId);
if (status.polling.isTerminal || TERMINAL_FIAT_STATUSES.has(status.status)) break;
await new Promise((r) => setTimeout(r, status.polling.suggestedIntervalMs));
}Related
- Error Handling — widget-level error handling (uses this client under the hood)
- Transaction Lifecycle — quote → build → submit → track explained
- Tokens & Chains — token identifier formats and supported chains