Appearance
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/fiatThe route table is fixed:
| Method | Path | Purpose | Needs state store |
|---|---|---|---|
GET | /config | Discover live capabilities | No |
POST | /quote | Price a purchase, get a sealed quoteId | No |
POST | /order | Turn a quote into an order | Yes |
GET | /order/:orderId | Read current order status | Yes |
POST | /webhook/:provider | Provider-pushed status updates | Yes |
GET | /widget/:orderId | SDK-hosted checkout wrapper page | Yes |
There are three ways to call the SDK:
@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.- Over raw HTTP — from any frontend or service, using the routes above. This is what the client wraps (
fiat.fetch). - Programmatically — call the
Fiatmethods 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:
| Field | Meaning |
|---|---|
contractVersion | The wire contract version this deployment speaks (CONTRACT_VERSION). |
basePath | The 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
| Field | Type | Required | Notes |
|---|---|---|---|
provider | string | No | Provider id. When omitted, every configured provider is quoted. |
kind | "estimate" | "firm" | No | Defaults to firm. |
inputMode | "fiat" | "crypto" | No | If omitted, inferred: crypto when cryptoAmount is present, otherwise fiat. |
fiatCurrency | string (3–8 chars) | No | Defaults to "USD". |
fiatAmount | string | One of these two | Decimal string (e.g. "100"). Provide exactly one of fiatAmount / cryptoAmount. |
cryptoAmount | string | One of these two | RAW base units of the target asset. Provide exactly one of the two. |
outputAsset | { chainId, address } | Yes | The target asset. chainId is a positive integer; address is the on-chain address. |
recipient | string | recipient OR walletAddress | The destination wallet address. |
walletAddress | string | recipient OR walletAddress | Alias for recipient; one of the two is required. |
refundTo | string | No | Refund address for jump settlement (falls back to jump.refundWallet). |
paymentMethod | string | No | Provider payment-method id. |
slippageBps | number (int) | No | Slippage 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",
},
],
}| Field | Notes |
|---|---|
quoteId | Opaque sealed token. Display the quote and hand this back to POST /order verbatim. |
kind | estimate or firm. |
provider | The provider that priced the quote. |
providerName | Human-facing provider name (falls back to the id). |
providerLogoUrl? | Absolute logo URL for the provider, when configured. |
swapStrategy | direct (provider sells the target) or jump (provider sells an intermediate; see Jump routes). |
fiatAmount, totalFee | Decimal strings, in fiatCurrency. |
outputAsset | The normalized target asset. |
expectedOutputAmount | Expected delivered amount, RAW base units. |
minOutputAmount | Guaranteed floor, RAW base units — settlement must deliver at least this. |
expiresAt | Quote 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
| Field | Type | Required | Notes |
|---|---|---|---|
quoteId | string | Yes | The opaque token from POST /quote. |
metadata | object | No | Integrator-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…" }
}| Field | Notes |
|---|---|
orderId | Stable id for the order. Use it for GET /order/:orderId. |
status | Initial canonical status — pending. |
provider | The provider handling the order. |
action | What 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 openFor 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",
}| Field | Notes |
|---|---|
orderId | The order id you requested. |
status | Canonical lifecycle status. Terminal values: completed, failed, canceled, expired. |
terminal | Server-authoritative terminality — poll on this, not on a hardcoded status list, so new statuses in future versions can't strand your poller. |
progress | Coarse label for embeds, derived from status + whether a provider order exists yet (hasProviderOrder). |
provider, swapStrategy | The provider and route strategy. |
fiatCurrency, fiatAmount | What the user is paying. |
outputAsset | Target 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, updatedAt | ISO 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/transakThat 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:
| Outcome | HTTP status | Meaning |
|---|---|---|
accepted | 200 | Verified and applied (or already terminal). |
duplicate | 200 | Already seen this event — deduped, no-op. |
unknown_order | 404 | Verified but no matching order yet (provider should retry). |
rejected | 400 | Unknown 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:
| Method | Returns | Notes |
|---|---|---|
fiat.quote(input, request?) | FiatQuoteResult | One 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?) | CreateOrderResult | Richer than the HTTP route: { order, orderWidget, existing }. |
fiat.getOrder(orderId) | FiatOrderRecord | null | The raw record — not the OrderView projection the HTTP route returns. |
fiat.handleWebhook(provider, request, context?) | WebhookResult | request 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
}
}| Field | Notes |
|---|---|
code | A stable code from the closed error vocabulary. Branch on this, not on message. |
message | Human-readable description. May change; do not parse it. |
retryable | true 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:
| Code | HTTP | Retryable |
|---|---|---|
VALIDATION_ERROR | 400 | no |
ORIGIN_NOT_ALLOWED | 403 | no |
CONTEXT_REQUIRED | 400 | no |
QUOTE_INVALID | 400 | no |
QUOTE_EXPIRED | 409 | no |
QUOTE_TOO_LARGE | 413 | no |
QUOTE_REVOKED | 401 | no |
UNSUPPORTED_ASSET | 422 | no |
NO_ROUTE | 422 | no |
PROVIDER_DISABLED | 403 | no |
PROVIDER_UNAVAILABLE | 502 | yes |
PROVIDER_ERROR | 502 | no |
ORDER_NOT_FOUND | 404 | no |
STATE_CONFLICT | 409 | yes |
RATE_LIMITED | 429 | yes |
WEBHOOK_REJECTED | 400 | no |
CONFIGURATION_ERROR | 500 | no |
NOT_IMPLEMENTED | 501 | no |
INTERNAL_ERROR | 500 | yes |
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.
Related
- Widget and wrapper — what to do with the
actionfromPOST /order. - Lifecycle and webhooks — order states, the webhook handler, cron progression, and the full error/failure table.
- API reference — the exhaustive request/response schema for every route.