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. Order endpoints don't expose a polling hint (unlike FiatStatusResponse.polling) — 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.

FiatApi

Fiat on-ramp client — separate base URL, same construction pattern.

ts
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);
MethodPurpose
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.

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`;
}

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.

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

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

WhenWhat
Popup blockedrejects with TokenFlightError({ code: "TF6006" })
Outside a browserrejects with TokenFlightError({ code: "TF6007" })
getStatus fails on settlerejects with the underlying typed error (ApiTimeoutError, etc.)

Customizing the popup:

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

ts
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));
}