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. 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.
Fiat client (@tokenflight/fiat-client)
The fiat on-ramp uses the official typed client for the standard @tokenflight/fiat HTTP contract — a separate, zero-runtime-dependency package. The widget constructs it internally; install it directly for headless use:
npm install @tokenflight/fiat-clientimport { createFiatClient } from "@tokenflight/fiat-client";
const fiat = createFiatClient(); // defaults to https://fiat.hyperstream.dev
const { quotes } = await fiat.quotes({
fiatCurrency: "USD",
fiatAmount: "100",
outputAsset: { chainId: 8453, address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" },
recipient: "0xYourWallet",
refundTo: "0xYourWallet",
});
const order = await fiat.createOrder({
quoteId: quotes[0]!.quoteId,
metadata: { author: "0xYourWallet" },
});
// order.action tells the frontend what to do — usually { type: "wrapper", url } to open in a popup.| Method | Purpose |
|---|---|
config(opts?) | GET /v1/fiat/config — configured providers (display + capabilities), jump support, enabled features |
quotes(request, opts?) | POST /v1/fiat/quote — one quote per provider (server-side fan-out); pin provider for a single quote. Providers that can't price the request land in errors |
quotesStream(request, opts?) | POST /v1/fiat/quote?mode=stream — async generator yielding each provider's { quote } / { error } as it settles |
createOrder(request, opts?) | POST /v1/fiat/order — commit an opaque quoteId; returns { orderId, status, provider, action } |
getOrder(orderId, opts?) | GET /v1/fiat/order/:orderId — canonical order projection; poll until a terminal status |
waitForOrder(orderId, opts?) | Polls getOrder until the order is terminal; onOrder callback drives progress UI |
The quoteId is an opaque handle — the quote fully commits provider, amount, recipient, and route plan, so the order body carries nothing else (optional metadata is stored locally and never forwarded to providers). Failures throw FiatClientError with the wire error code (QUOTE_EXPIRED, NO_ROUTE, …) plus client-side codes NETWORK_ERROR / TIMEOUT / INVALID_RESPONSE and a retryable flag.
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`;
}Fiat types live in @tokenflight/fiat-client, which re-exports the full wire vocabulary from the @tokenflight/fiat contract: FiatQuoteRequest, FiatQuoteResult, OrderView, FiatConfigResponse, 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. Quote, commit the opaque quoteId, open the returned action.url in a popup, then wait until the order settles.
import { createFiatClient } from "@tokenflight/fiat-client";
const fiat = createFiatClient();
const recipient = "0xYourUserWallet";
const refundTo = "0xYourUserWallet";
// 1. Quote — one item per provider (server-side fan-out). Present them to the user.
const { quotes, errors } = await fiat.quotes({
fiatCurrency: "USD",
fiatAmount: "100",
outputAsset: { chainId: 8453, address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" },
recipient,
refundTo,
});
if (quotes.length === 0) {
throw new Error(errors[0]?.message ?? "No providers available");
}
// Quotes carry display metadata: providerName, providerLogoUrl, totalFee,
// expectedOutputAmount / minOutputAmount (raw token units), expiresAt (epoch seconds).
const picked = quotes[0]!;
// 2. Create the order — the quoteId fully commits provider, amount, recipient,
// and route plan. `metadata` is integrator-local (never sent to providers).
const order = await fiat.createOrder({
quoteId: picked.quoteId,
metadata: { author: recipient },
});
// 3. Act on `action` — usually the SDK-hosted wrapper page, opened as a popup.
// `none` is also valid: there is nothing to open, but the order should still be polled.
const popup = order.action.type === "none"
? null
: window.open(order.action.url, "tf-payment", "width=500,height=700");
// 4. Wait until terminal. The wrapper also posts FIAT_WIDGET_EVENT postMessages
// (widget_ready / order_completed / order_failed / widget_close) you can use
// to drive UX; the backend order status stays authoritative.
const view = await fiat.waitForOrder(order.orderId, {
pollIntervalMs: 3000,
onOrder: (o) => console.log("status:", o.status),
});
popup?.close();
if (view.status === "completed") {
console.log("Delivered:", view.outputAmount, "tx:", view.transferTxHash);
} else {
console.error("Order", view.status, view.failure?.message ?? view.failure?.reason);
}Browser-only parts. window.open and postMessage need a browser. In SSR / Workers, run only the HTTP calls (quotes, createOrder, getOrder) and handle the popup on the client.
Order action variants:
action.type | What to do |
|---|---|
wrapper | Open action.url — the SDK-hosted wrapper page that sandboxes the provider iframe and relays FIAT_WIDGET_EVENT postMessages |
redirect | Navigate the user to action.url (provider-hosted page) |
provider_widget | Embed/open the provider's own widget at action.url |
none | Nothing to open — the order progresses out-of-band |
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. The route plan is sealed into the quoteId — both strategies use the exact same order/poll flow; only the quote presentation differs.
Wrapper events: the wrapper page posts { type: "FIAT_WIDGET_EVENT", event, orderId, provider, data } to window.opener where event is one of widget_ready, order_created, order_processing, order_completed, order_failed, widget_close. Validate event.origin against your fiat API base URL before trusting a message.
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