Skip to content

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

bash
npm install @tokenflight/api
bash
pnpm add @tokenflight/api
bash
yarn add @tokenflight/api
bash
bun add @tokenflight/api

Peer requirements: none. The package ships typed exports, is ESM-first with a UMD fallback, and uses ky under the hood.

Quick start

ts
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

ts
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
});
OptionTypeNotes
baseUrlstringAPI base URL. Trailing slash is normalized.
timeoutnumberPer-request timeout in ms. Defaults to 15000.
fetchtypeof globalThis.fetchUseful for SSR, Cloudflare Workers, or test mocks. Also applies to streamQuotes.
headersRecord<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? }.

ts
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? }.

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

ts
const deposit = await api.buildDeposit({
  from: "0xYourWallet",
  quoteId,
  routeId: routes[0]!.routeId,
});
// deposit = { kind: "CONTRACT_CALL", approvals: Approval[] }

Approval shapes (approval.type discriminates):

ts
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 what submitDeposit needs.
  • 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.

ts
// 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

MethodEndpointPurpose
getOrder(orderId, opts?)GET /v1/orders/by-id/:orderIdGlobal lookup — no author filter
getOrdersByAddress(address, opts?)GET /v1/orders/:addressPaginated 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:

ts
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

MethodEndpoint
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

ts
import { chainIconUrl } from "@tokenflight/api";

const chains = await api.getChains();
const iconUrl = chainIconUrl(api.baseUrl, 8453); // plain URL, no fetch

chainIconUrl 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

ts
const config = await api.getArcadiaConfig();  // hub contract addresses
const vaults = await api.getVaults();          // yield vaults
const mTokens = await api.getMTokens();        // vault share tokens

Intents

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

bash
npm install @tokenflight/fiat-client
ts
import { 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.
MethodPurpose
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.

ts
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

ClassCodeWhen
ApiTimeoutErrorTF3002Request exceeded the configured timeout
ApiAbortedErrorTF3009An AbortController canceled the request

Backend exception mapping

The backend returns { name, message, details? }. Each named exception maps to a subclass:

Backend exceptionTyped subclass
ValidationException / ZodValidationExceptionApiValidationError
BadRequestExceptionBadRequestError
QuoteNotFoundExceptionQuoteNotFoundError
QuoteExpiredExceptionQuoteExpiredError
CannotFillExceptionCannotFillError
NotSupportedTokenExceptionNotSupportedTokenError
NotSupportedChainExceptionNotSupportedChainError
NotSupportedDepositMethodExceptionNotSupportedDepositMethodError
UnexpectedFromAddressExceptionUnexpectedFromAddressError
IntentNotFoundExceptionIntentNotFoundError
DuplicateRecordExceptionDuplicateRecordError
BroadcastExceptionBroadcastError
InternalErrorExceptionInternalServerError

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

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

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

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

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

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

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

ts
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.typeWhat to do
wrapperOpen action.url — the SDK-hosted wrapper page that sandboxes the provider iframe and relays FIAT_WIDGET_EVENT postMessages
redirectNavigate the user to action.url (provider-hosted page)
provider_widgetEmbed/open the provider's own widget at action.url
noneNothing 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.