Skip to content

API usage

This page teaches you how to actually call the SDK end to end — the request and response of every route, in order, with copy-pasteable curl and TypeScript. It is the narrative companion to the generated API reference, which carries the exhaustive schema for each field.

The contract

Every route lives under {origin}{basePath}. basePath is whatever you passed to createFiat({ basePath }) — this page uses /v1/fiat. So if your service is deployed at https://fiat.example.com, the full base for these examples is:

text
https://fiat.example.com/v1/fiat

The route table is fixed:

MethodPathPurposeNeeds state store
GET/configDiscover live capabilitiesNo
POST/quotePrice a purchase, get a sealed quoteIdNo
POST/orderTurn a quote into an orderYes
GET/order/:orderIdRead current order statusYes
POST/webhook/:providerProvider-pushed status updatesYes
GET/widget/:orderIdSDK-hosted checkout wrapper pageYes

There are three ways to call the SDK:

  1. @tokenflight/fiat-client — the typed browser/edge client for the HTTP contract. A few KB, zero runtime dependencies, and its types are the server's own wire types, so it cannot drift. Use this from frontends. See The typed client.
  2. Over raw HTTP — from any frontend or service, using the routes above. This is what the client wraps (fiat.fetch).
  3. Programmatically — call the Fiat methods directly inside your backend process (fiat.quote, fiat.createOrder, fiat.getOrder, fiat.handleWebhook). Same engine, no HTTP hop. Covered in Call it programmatically.

The typed client — @tokenflight/fiat-client

Everything the rest of this page does with curl, the client does with methods:

ts
import { createFiatClient, isFiatClientError, TERMINAL_FIAT_ORDER_STATUSES } from "@tokenflight/fiat-client";

const client = createFiatClient({
  baseUrl: "https://fiat.example.com", // default: https://fiat.hyperstream.dev
  // basePath: "/v1/fiat",             // must match the server's createFiat({ basePath })
  // headers: { authorization: "…" },  // extra headers, once integrator keys ship
});

// One quote per provider; providers that couldn't price land in `errors`, never a throw.
const { quotes, errors } = await client.quotes({
  fiatCurrency: "USD",
  fiatAmount: "100.00",
  outputAsset: { chainId: 8453, address: "0x8335…2913" },
  recipient: wallet,
});

// Or streamed — render each provider's card the moment it settles:
for await (const event of client.quotesStream(request)) {
  if ("quote" in event)
    addCard(event.quote); // has providerName / providerLogoUrl
  else markUnavailable(event.error.provider);
}

// Popup blockers only allow window.open in the SYNCHRONOUS part of a click handler —
// pre-open the window on click, then navigate it once the order lands.
const popup = window.open("about:blank", "_blank", "popup,width=480,height=720");
const order = await client.createOrder({ quoteId: quotes[0].quoteId });
if (order.action.type === "wrapper" && popup)
  popup.location = order.action.url; // popup, not iframe
else popup?.close();

// Poll to terminal; `view.transferTxHash` powers the completion page's explorer link.
const view = await client.waitForOrder(order.orderId, { onOrder: renderProgress });

Every failure throws a FiatClientError carrying the wire code (ORDER_NOT_FOUND, VALIDATION_ERROR, …) plus retryable and the HTTP status; transport-level failures use the client-only codes NETWORK_ERROR, TIMEOUT, and INVALID_RESPONSE. All wire types (OrderView, FiatQuoteResult, FiatConfigResponse, …) are re-exported, so the client is the only package a frontend needs.

Browser callers must send an allowed Origin

The browser-facing routes (/config, /quote, /order, /order/:orderId) are fail-closed CORS. A request carrying an Origin header that is not in your cors.origins allowlist is rejected with ORIGIN_NOT_ALLOWED (HTTP 403). Requests with no Origin header (server-to-server) pass CORS — but Transak requires an origin and an end-user IP in the request context regardless (CONTEXT_REQUIRED otherwise), which is why the curl examples below send an Origin explicitly; the IP is read from the platform (CF-Connecting-IP by default — see Security). The allowed origin is echoed back exactly — never *. The /webhook/:provider and /widget/:orderId routes are not CORS-gated; they are reached by the provider and the user's browser respectively.

Contract version & request ids

The wire contract is versioned by the CONTRACT_VERSION constant (currently 1). It is surfaced to clients in the contractVersion field of GET /config. Every JSON response also carries an x-request-id header (e.g. req_…) for tracing a call through your logs.

Discover capabilities — GET /config

Start here. /config reports what this deployment actually supports, so your frontend can render against live capabilities instead of hard-coded assumptions. It needs no state store and no request body.

bash
BASE="https://fiat.example.com/v1/fiat"

curl -sS "$BASE/config"

Response:

jsonc
{
  "contractVersion": 1,
  "basePath": "/v1/fiat",
  "providers": [
    {
      "id": "transak",
      "capabilities": {
        "quoteKinds": ["estimate", "firm"],
        "orderActions": ["wrapper"],
        "supportsWebhook": true,
        "supportsStatusPolling": true,
        "supportedFiatCurrencies": ["USD", "EUR"],
        "supportedPaymentMethods": ["credit_debit_card"],
        "supportedAssets": {
          /* provider-reported asset support */
        },
        "requirements": {
          /* request-context fields the provider needs */
        },
      },
    },
  ],
  "jump": {
    "enabled": true,
    "intermediateAsset": { "chainId": 8453, "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" },
  },
  "features": { "quote": true, "order": true, "cron": true },
}

Read the top-level fields like this:

FieldMeaning
contractVersionThe wire contract version this deployment speaks (CONTRACT_VERSION).
basePathThe mount path — echoes your createFiat({ basePath }).
providers[]Each configured provider: its id plus the capabilities it reports for the caller's context.
jump{ enabled, intermediateAsset } — whether jump routing is on and the intermediate asset used.
features{ quote, order, cron } — which capability classes are live.

features.order / features.cron are false without a state store

features.quote is always true. But features.order and features.cron are Boolean(state) — they are only true when the deployment was built with a FiatStateStore. If they are false, POST /order, GET /order/:orderId, the webhook and widget routes, and fiat.cron() are unavailable. Gate your order UI on features.order. See State & storage.

Create a quote — POST /quote

A quote prices the purchase. The response carries one quote per configured provider (pass provider to pin a single one), each with an opaque, sealed quoteId. By default a quote is firm (orderable): you pass the chosen quote's quoteId straight to POST /order.

Request body

FieldTypeRequiredNotes
providerstringNoProvider id. When omitted, every configured provider is quoted.
kind"estimate" | "firm"NoDefaults to firm.
inputMode"fiat" | "crypto"NoIf omitted, inferred: crypto when cryptoAmount is present, otherwise fiat.
fiatCurrencystring (3–8 chars)NoDefaults to "USD".
fiatAmountstringOne of these twoDecimal string (e.g. "100"). Provide exactly one of fiatAmount / cryptoAmount.
cryptoAmountstringOne of these twoRAW base units of the target asset. Provide exactly one of the two.
outputAsset{ chainId, address }YesThe target asset. chainId is a positive integer; address is the on-chain address.
recipientstringrecipient OR walletAddressThe destination wallet address.
walletAddressstringrecipient OR walletAddressAlias for recipient; one of the two is required.
refundTostringNoRefund address for jump settlement (falls back to jump.refundWallet).
paymentMethodstringNoProvider payment-method id.
slippageBpsnumber (int)NoSlippage tolerance in basis points. Only applies to jump quotes.

Amount rule

You must send exactly one of fiatAmount or cryptoAmount. Sending both, or neither, fails with VALIDATION_ERROR. cryptoAmount is in RAW base units of outputAsset (e.g. wei-style integers), not a human decimal.

bash
BASE="https://fiat.example.com/v1/fiat"

curl -sS -X POST "$BASE/quote" \
  -H 'content-type: application/json' \
  -H 'origin: https://app.example.com' \
  -d '{
    "fiatAmount": "100",
    "fiatCurrency": "USD",
    "outputAsset": { "chainId": 8453, "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" },
    "recipient": "0x1111111111111111111111111111111111111111"
  }'

Response — { quotes: FiatQuoteResult[], errors?: QuoteProviderError[] }

One quotes entry per provider that could price the request. Providers that cannot (unsupported asset, amount out of range, provider outage, per-provider timeout, …) are dropped from quotes and reported in errors as { provider, code, message, retryable }, so a comparison UI can tell "provider offered nothing" from "provider failed — retry may help". The request only fails when no provider can quote it. Each fan-out provider gets a 10s budget by default (quotes.providerTimeoutMs; pinned-provider requests are never bounded).

Streaming: POST /quote?mode=stream returns application/x-ndjson — one {"quote": {…}} or {"error": {…}} line per provider as each settles, so a slow provider never delays the fast ones. Render quotes as the lines arrive.

jsonc
{
  "quotes": [
    {
      "quoteId": "fq1..3fkQ9…", // OPAQUE sealed token — do not parse or construct
      "kind": "firm",
      "provider": "transak",
      "providerName": "Transak", // display name for comparison UIs
      "providerLogoUrl": "https://…/transak.svg", // absolute logo URL, when configured
      "swapStrategy": "direct", // "direct" | "jump"
      "fiatCurrency": "USD",
      "fiatAmount": "100",
      "totalFee": "2.49",
      "outputAsset": { "chainId": 8453, "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" },
      "expectedOutputAmount": "97510000", // RAW base units expected
      "minOutputAmount": "97510000", // RAW base units guaranteed floor
      "expiresAt": 1751385600, // epoch SECONDS
      "token": { "chainId": 8453, "address": "0x8335…", "symbol": "USDC", "decimals": 6 },
      "slippageBps": 300,
      "paymentMethods": [{ "id": "credit_debit_card", "name": "Credit/Debit Card" }],
      "minFiatAmount": "10",
      "maxFiatAmount": "10000",
    },
  ],
}
FieldNotes
quoteIdOpaque sealed token. Display the quote and hand this back to POST /order verbatim.
kindestimate or firm.
providerThe provider that priced the quote.
providerNameHuman-facing provider name (falls back to the id).
providerLogoUrl?Absolute logo URL for the provider, when configured.
swapStrategydirect (provider sells the target) or jump (provider sells an intermediate; see Jump routes).
fiatAmount, totalFeeDecimal strings, in fiatCurrency.
outputAssetThe normalized target asset.
expectedOutputAmountExpected delivered amount, RAW base units.
minOutputAmountGuaranteed floor, RAW base units — settlement must deliver at least this.
expiresAtQuote expiry, epoch seconds. Re-quote after this (a small grace window applies at order time).
token?Display metadata for the output token (symbol/decimals/logo), when resolvable.
slippageBps?Effective slippage (jump only).
paymentMethods?Payment methods the provider offers for this quote.
minFiatAmount? / maxFiatAmount?Provider purchase bounds for the currency.

TypeScript fetch:

ts
const BASE = "https://fiat.example.com/v1/fiat";

const res = await fetch(`${BASE}/quote`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    fiatAmount: "100",
    fiatCurrency: "USD",
    outputAsset: { chainId: 8453, address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" },
    recipient: "0x1111111111111111111111111111111111111111",
  }),
});

if (!res.ok) throw new Error((await res.json()).error.code);
const { quotes } = await res.json(); // { quotes: FiatQuoteResult[] } — one per provider
const quote = quotes[0];

Create an order — POST /order

Turn a quote into an order. The SDK unseals and re-validates the quote terms, talks to the provider, and persists an order record. This route needs a state store.

Request body

FieldTypeRequiredNotes
quoteIdstringYesThe opaque token from POST /quote.
metadataobjectNoIntegrator-local key/values stored with the order (max 4KB JSON).

Use the sealed quoteId as-is

quoteId must be the exact opaque string returned by /quote. Do not construct, decode, or modify it — the seal is what proves the price terms weren't tampered with. A missing, altered, expired, or already-consumed quote fails with QUOTE_INVALID or QUOTE_EXPIRED.

bash
BASE="https://fiat.example.com/v1/fiat"

curl -sS -X POST "$BASE/order" \
  -H 'content-type: application/json' \
  -H 'origin: https://app.example.com' \
  -d '{
    "quoteId": "fq1..3fkQ9…",
    "metadata": { "integratorOrderId": "ord_42" }
  }'

Response

json
{
  "orderId": "fiat_order_01J…",
  "status": "pending",
  "provider": "transak",
  "action": { "type": "wrapper", "url": "https://fiat.example.com/v1/fiat/widget/fiat_order_01J…" }
}
FieldNotes
orderIdStable id for the order. Use it for GET /order/:orderId.
statusInitial canonical status — pending.
providerThe provider handling the order.
actionWhat the frontend does next to hand the user into checkout.

action is one of:

ts
type FiatOrderAction =
  | { type: "wrapper"; url: string } // open/iframe the SDK-hosted wrapper
  | { type: "redirect"; url: string } // navigate the user to this URL
  | { type: "provider_widget"; url: string; provider: string } // embed the provider widget directly
  | { type: "none" }; // nothing to open

For a wrapper action, open or iframe action.url (the /widget/:orderId page). See Widget and wrapper for exactly what to do with each action type and how the wrapper isolates the provider iframe.

TypeScript fetch:

ts
const res = await fetch(`${BASE}/order`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ quoteId: quote.quoteId, metadata: { integratorOrderId: "ord_42" } }),
});

if (!res.ok) throw new Error((await res.json()).error.code);
const { orderId, status, provider, action } = await res.json();

Check order status — GET /order/:orderId

Read the current state of an order at any time. Returns the OrderView projection.

bash
BASE="https://fiat.example.com/v1/fiat"

curl -sS "$BASE/order/fiat_order_01J…"

Response — OrderView

The block below shows the full field set for reference. Several fields are status-dependent, so a single real response never contains all of them at once — outputAmount appears only once the order is delivered, and failure only on a failed order.

jsonc
{
  "orderId": "fiat_order_01J…",
  "status": "processing", // created|pending|processing|completed|failed|canceled|expired
  "terminal": false, // authoritative "will never change again" — stop polling when true
  "progress": "settling", // awaiting_payment|payment_processing|settling|completed|failed|canceled|expired
  "provider": "transak",
  "swapStrategy": "direct",
  "fiatCurrency": "USD",
  "fiatAmount": "100",
  "outputAsset": { "chainId": 8453, "address": "0x8335…" },
  "expectedOutputAmount": "97510000", // RAW base units, when known
  "outputAmount": "97480000", // RAW base units actually delivered, when terminal
  "transferTxHash": "0x9e2f…", // delivery tx on outputAsset.chainId, once settled
  "action": { "type": "wrapper", "url": "https://fiat.example.com/v1/fiat/widget/fiat_order_01J…" },
  "failure": { "reason": "oda_slippage_exceeded", "source": "hyperstream_intent" }, // only on a failed order
  "createdAt": "2026-07-01T12:00:00.000Z",
  "updatedAt": "2026-07-01T12:03:11.000Z",
}
FieldNotes
orderIdThe order id you requested.
statusCanonical lifecycle status. Terminal values: completed, failed, canceled, expired.
terminalServer-authoritative terminality — poll on this, not on a hardcoded status list, so new statuses in future versions can't strand your poller.
progressCoarse label for embeds, derived from status + whether a provider order exists yet (hasProviderOrder).
provider, swapStrategyThe provider and route strategy.
fiatCurrency, fiatAmountWhat the user is paying.
outputAssetTarget asset.
expectedOutputAmount?Expected delivery, RAW base units.
outputAmount?Actual delivered amount, RAW base units — present once delivered.
transferTxHash?On-chain delivery transaction on outputAsset.chainId — link it to a block explorer on the completion page.
action?Current checkout action (same shape as POST /order), if the order still has one.
failure?{ reason, source, message? } on a failed order. source is one of hyperstream_oda, hyperstream_intent, fiat_provider, fiat_api, fiat_widget, unknown.
createdAt, updatedAtISO 8601 timestamps.

Poll until terminal, but prefer webhooks for the push

Poll GET /order/:orderId until terminal is true. Webhooks (next section) are the push counterpart — the provider notifies your deployment as soon as state changes, and fiat.cron() reconciles anything that didn't get a webhook. Polling is the simple client-side fallback. See Lifecycle and webhooks.

TypeScript fetch with a poll loop:

ts
async function waitForOrder(orderId: string) {
  for (;;) {
    const res = await fetch(`${BASE}/order/${orderId}`);
    if (!res.ok) throw new Error((await res.json()).error.code);
    const view = await res.json(); // OrderView
    if (view.terminal) return view;
    await new Promise((r) => setTimeout(r, 4000));
  }
}

Receive webhooks — POST /webhook/:provider

You do not call this route — the provider does. Point the provider's dashboard webhook at your deployment:

text
https://fiat.example.com/v1/fiat/webhook/transak

That is {origin}{basePath}/webhook/{provider}, where {provider} is the provider id (e.g. transak). The SDK reads the raw request body, hands it to the provider adapter to verify the signature before parsing, dedups, finds the matching order, and applies the canonical status update.

The HTTP status the route returns is meaningful — it drives the provider's retry behavior:

OutcomeHTTP statusMeaning
accepted200Verified and applied (or already terminal).
duplicate200Already seen this event — deduped, no-op.
unknown_order404Verified but no matching order yet (provider should retry).
rejected400Unknown provider or signature verification failed.

The response body is { "outcome": "accepted" } (or the matching outcome). Full detail — verification, dedup, and how each provider state maps to a canonical status — is in Lifecycle and webhooks.

Call it programmatically (no HTTP)

If your service runs in the same process as the SDK, call the Fiat methods directly. Same engine, same validation, no network hop — useful for server-side flows and tests.

ts
import { createFiat } from "@tokenflight/fiat";

const fiat = createFiat({
  /* providers, state, cors, quoteSecret, … */
});

// 1. Quote — returns FiatQuoteResult
const quote = await fiat.quote(
  {
    inputMode: "fiat",
    fiatCurrency: "USD",
    fiatAmount: "100",
    outputAsset: { chainId: 8453, address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" },
    recipient: "0x1111111111111111111111111111111111111111",
  },
  { origin: "https://app.integrator.example", endUserIp: "203.0.113.7" }, // FiatRequestContext
);

// 2. Create order — returns CreateOrderResult { order, orderWidget, existing }
const created = await fiat.createOrder({ quoteId: quote.quoteId }, { origin: "https://app.integrator.example" });
console.log(created.order.orderId, created.orderWidget.action);

// 3. Read order — returns the full FiatOrderRecord | null
const record = await fiat.getOrder(created.order.orderId);

// 4. Handle a provider webhook — takes a WHATWG Request, returns WebhookResult
const result = await fiat.handleWebhook("transak", request /* Request */, { env, ctx });

The method signatures:

MethodReturnsNotes
fiat.quote(input, request?)FiatQuoteResultOne provider: input.provider when set, the first configured otherwise. request is an optional FiatRequestContext.
fiat.quotes(input, request?)FiatQuoteResult[]One quote per configured provider — what POST /quote serves.
fiat.createOrder(input, request?)CreateOrderResultRicher than the HTTP route: { order, orderWidget, existing }.
fiat.getOrder(orderId)FiatOrderRecord | nullThe raw record — not the OrderView projection the HTTP route returns.
fiat.handleWebhook(provider, request, context?)WebhookResultrequest is a WHATWG Request; context is a FiatFetchContext.

HTTP shapes vs programmatic shapes

The programmatic methods return richer objects than the HTTP routes, which project them onto the wire contract. fiat.createOrder gives you the full CreateOrderResult (the HTTP route returns just { orderId, status, provider, action }), and fiat.getOrder returns the raw FiatOrderRecord (the HTTP route returns the OrderView projection). The HTTP handler also derives FiatRequestContext from the request automatically; programmatically, you pass it yourself.

Pass request context where the provider needs it

Some providers require end-user context (origin, endUserIp, and friends) — the SDK enforces this with assertProviderRequirements and throws CONTEXT_REQUIRED when a required field is missing. Over HTTP, this context is derived from the request (and trusted proxy headers). When calling programmatically, pass a FiatRequestContext so those checks pass:

ts
interface FiatRequestContext {
  origin?: string;
  endUserIp?: string; // trusted IP only — never a client-forged header
  country?: string;
  email?: string;
  userAgent?: string;
  locale?: string;
}

See Security for how endUserIp is extracted from configured proxy headers and why it must be trusted.

Errors

Every error response is the same envelope, WireError:

json
{
  "error": {
    "code": "QUOTE_EXPIRED",
    "message": "quote has expired; request a new one",
    "retryable": false
  }
}
FieldNotes
codeA stable code from the closed error vocabulary. Branch on this, not on message.
messageHuman-readable description. May change; do not parse it.
retryabletrue when retrying the same request may succeed. Retry only when this is true.
details?Optional structured context (e.g. zod issues for VALIDATION_ERROR).

The HTTP status is chosen from the code:

CodeHTTPRetryable
VALIDATION_ERROR400no
ORIGIN_NOT_ALLOWED403no
CONTEXT_REQUIRED400no
QUOTE_INVALID400no
QUOTE_EXPIRED409no
QUOTE_TOO_LARGE413no
QUOTE_REVOKED401no
UNSUPPORTED_ASSET422no
NO_ROUTE422no
PROVIDER_DISABLED403no
PROVIDER_UNAVAILABLE502yes
PROVIDER_ERROR502no
ORDER_NOT_FOUND404no
STATE_CONFLICT409yes
RATE_LIMITED429yes
WEBHOOK_REJECTED400no
CONFIGURATION_ERROR500no
NOT_IMPLEMENTED501no
INTERNAL_ERROR500yes

The retryable set is PROVIDER_UNAVAILABLE, RATE_LIMITED, STATE_CONFLICT, and INTERNAL_ERROR. For everything else, fix the request rather than retrying it.

Two footnotes: WEBHOOK_REJECTED and PROVIDER_ERROR are reserved — defined and mapped, but not currently emitted by the engine (a failed webhook returns { "outcome": "rejected" } with HTTP 400 instead of a WireError). And when an uncaught exception (not a constructed FiatError) reaches the HTTP layer, the generic envelope reports "retryable": false even though the code is INTERNAL_ERROR.

Handle errors by code:

ts
const res = await fetch(`${BASE}/quote`, { method: "POST" /* … */ });
if (!res.ok) {
  const { error } = await res.json(); // WireError
  switch (error.code) {
    case "RATE_LIMITED":
    case "PROVIDER_UNAVAILABLE":
      // error.retryable === true — back off and retry
      break;
    case "QUOTE_EXPIRED":
      // re-quote and try again
      break;
    case "VALIDATION_ERROR":
      // inspect error.details.issues and fix the request
      break;
    default:
      throw new Error(`${error.code}: ${error.message}`);
  }
}

The full meaning of each code, including settlement failure reasons that appear on the order record (not as HTTP errors), is in Lifecycle and webhooks.