Appearance
Configuration
This is the complete reference for configuring a production fiat-to-crypto backend on your own infrastructure, with your own provider credentials — every createFiat field, every default, the fail-closed guards, and copy-pasteable wiring for Cloudflare Workers and Next.js.
New here? Start with the Quickstart
The Quickstart gets a service running and takes your first order in a few minutes. Come back here when you need the full option surface.
1. What you will build
A single service that exposes the standard Fiat HTTP contract (/config, /quote, /order, /order/{orderId}, /webhook/{provider}, /widget/{orderId}, all under your basePath) and a background driver that advances orders to completion. The frontend talks only to your origin; provider credentials never leave your server.
Prerequisites
| You need | Why | Notes |
|---|---|---|
| A provider account | To actually buy crypto. Transak is the live adapter today — you need an API key + API secret. | Use Transak staging keys for local dev. |
| A quote secret | Seals quote terms so price can't be tampered with between /quote and /order. | A single high-entropy string is fine; it is HKDF-stretched. |
| A durable state store | Required for orders, status, webhooks, and cron. | @tokenflight/fiat-storage-d1 (Cloudflare D1), or bring your own FiatStateStore. |
| A CORS allowlist | Fail-closed list of frontend origins allowed to call the browser-facing routes. | Empty is legal (deny-all browser) but you almost certainly want your app origin. |
| A Hyperstream endpoint | Optional. Enables jump routing to assets no provider sells directly. | Configure hyperstream.apiUrl (or pass a client) to auto-enable. |
The six methods you expose
createFiat(config) returns a Fiat object. These are its complete surface — quoted verbatim:
ts
export interface Fiat {
/** Standard integration point: WHATWG Request -> Response. */
fetch(request: Request, context?: FiatFetchContext): Promise<Response>;
/** Advance all due background work (provider polling, settlement reconcile, expiry). */
cron(options?: FiatCronOptions, context?: FiatFetchContext): Promise<FiatCronResult>;
quote(input: QuoteInput, request?: FiatRequestContext): Promise<FiatQuoteResult>;
createOrder(input: CreateOrderInput, request?: FiatRequestContext): Promise<CreateOrderResult>;
getOrder(orderId: string): Promise<FiatOrderRecord | null>;
handleWebhook(provider: string, request: Request, context?: FiatFetchContext): Promise<WebhookResult>;
}fetchis the one you almost always mount — it routes the whole HTTP contract internally. The other five are the programmatic equivalents if you'd rather call them directly (e.g. from a non-HTTP context, a test, or a custom router).cronis the background driver. It is the only thing that moves orders forward when a provider webhook is missed.handleWebhookis invoked for you byfetchonPOST /webhook/{provider}; call it directly only if you terminate webhooks outside the contract router.
2. Install
The SDK is published as @tokenflight/fiat with provider adapters behind subpath exports and storage as a sibling package. The four importable entry points:
| Import specifier | What it gives you |
|---|---|
@tokenflight/fiat | createFiat, the HTTP contract, programmatic APIs, createMemoryStateStore, the FiatStateStore / FiatCache interfaces, sealed-quote helpers. |
@tokenflight/fiat/providers/transak | The transak(options) adapter. |
@tokenflight/fiat/providers/coinbase-onramp | The coinbaseOnramp(options) adapter — a typed placeholder (see §4). |
@tokenflight/fiat-storage-d1 | createD1FiatState — a Cloudflare D1 FiatStateStore with shipped migrations. |
The SDK is ESM-only. Its only runtime dependency is zod. @cloudflare/workers-types is an optional peer dependency — install it only when targeting Workers.
Install from npm:
bash
npm install @tokenflight/fiat @tokenflight/fiat-storage-d13. The minimal service
The smallest createFiat that passes constructor validation needs exactly three things:
- a non-empty
providersarray, cors.origins(an array — may be empty, but the key must be present),- exactly one of
quoteSecretorquoteState.
ts
import { createFiat } from "@tokenflight/fiat";
import { transak } from "@tokenflight/fiat/providers/transak";
const fiat = createFiat({
providers: [transak({ apiKey: process.env.TRANSAK_API_KEY!, apiSecret: process.env.TRANSAK_API_SECRET! })],
cors: { origins: ["https://app.integrator.example"] },
quoteSecret: process.env.TOKENFLIGHT_FIAT_SECRET!,
});This boots with no state store, which is legal — but only quote() (POST /quote) and the read-only GET /config work. The moment you take an order, you need a store: createOrder / getOrder / handleWebhook and fiat.cron() all call requireState(runtime), which throws CONFIGURATION_ERROR ("this operation requires a state store") when state is absent. Add a store (§6) to go end to end.
Mount the handler by feeding it a WHATWG Request and your runtime context:
ts
const response = await fiat.fetch(request, { env, ctx });The { env, ctx } shape is the FiatFetchContext — env and ctx are passed through for runtime symmetry (Workers scheduled/fetch, etc.). On a platform without them (e.g. a plain Node server or a Next.js Route Handler) you can omit the second argument entirely.
4. Bring your own provider keys
Provider credentials are server-side only. They are injected from your environment, used only inside the adapter's HTTP client, and never sent to the browser. Transak's apiSecret, in particular, is used solely server-side to mint short-lived access tokens — it must never reach a frontend bundle.
The canonical pattern is a buildProviders(env) function that pushes an adapter only when its credentials are present, so a misconfigured deploy fails closed:
ts
import { transak } from "@tokenflight/fiat/providers/transak";
import type { FiatProviderAdapter } from "@tokenflight/fiat";
function buildProviders(env: Env): FiatProviderAdapter[] {
const providers: FiatProviderAdapter[] = [];
if (env.TRANSAK_API_KEY && env.TRANSAK_API_SECRET) {
providers.push(
transak({
apiKey: env.TRANSAK_API_KEY,
apiSecret: env.TRANSAK_API_SECRET,
environment: env.TRANSAK_ENVIRONMENT === "production" ? "production" : "staging",
}),
);
}
return providers;
}If no credentials are set, buildProviders returns [], and createFiat refuses to boot. This is the load-bearing fail-closed guard:
ts
if (config.providers.length === 0) throw new FiatError("CONFIGURATION_ERROR", "at least one provider is required");A deploy with no provider keys therefore crashes at construction rather than silently serving a backend that can't sell anything.
The Transak adapter config
transak(options) takes a TransakConfig:
ts
export interface TransakConfig {
apiKey: string;
apiSecret: string;
environment?: "production" | "staging";
fetch?: typeof fetch;
/** Override the (Hyperstream chainId → Transak network name) map for non-EVM chains. */
networkMap?: Record<number, string>;
}apiKey/apiSecret— your partner credentials.environment— selects the API + widget hosts. Staging isapi-stg.transak.com/global-stg.transak.com; production isapi.transak.com/global.transak.com.fetch— inject a customfetch(testing, proxying); defaults to the platformfetch.networkMap— only needed for chains whose numericchainIdTransak's currency list doesn't expose (e.g. non-EVM Hyperstream SystemChainIds). EVM chains resolve dynamically with no table.
Footgun: staging vs production default
The Transak client defaults to production when environment is omitted (ENVIRONMENTS[config.environment ?? "production"]). But the example wirings in this guide default to staging — they only select production when TRANSAK_ENVIRONMENT === "production". These two defaults differ. If you copy the example wiring, you must set TRANSAK_ENVIRONMENT=production to go live; if you call transak({...}) directly without an environment, you are on production by default. Be deliberate about which path you use.
Coinbase Onramp is a placeholder
@tokenflight/fiat/providers/coinbase-onramp exists so the subpath export and capability typing are real, but its methods reject/throw NOT_IMPLEMENTED (getCapabilities, quote, and createOrder all fail; canBuyAsset returns false). Do not ship it as a live provider. Transak is the only production-ready adapter today.
Multiple providers
You can register several adapters. A request may name one via QuoteInput.provider; when it doesn't, the planner falls back to the first registered provider:
ts
const providerId = input.provider ?? runtime.providers.list()[0]?.id;So ordering in the providers array is meaningful: the first entry is the default. Duplicate provider ids throw CONFIGURATION_ERROR ("duplicate provider id") at construction.
5. Configure the engine
The full configuration surface is CreateFiatConfig, verbatim:
ts
export interface CreateFiatConfig {
providers: FiatProviderAdapter[];
/** Required for stateful routes (order/status/webhook/widget) and cron. */
state?: FiatStateStore;
/** Optional best-effort cache (provider metadata, tokens, access tokens). */
cache?: FiatCache;
/** Public URL prefix the routes mount under, e.g. "/v1/fiat". Default "". */
basePath?: string;
/** Hyperstream attribution id, also stored on orders. */
integratorId?: string;
hyperstream?: { apiUrl?: string; client?: HyperstreamClient };
/** Jump routing. `intermediateAsset` is the asset the provider buys before Hyperstream
* settles to the target (default `BASE_USDC`); `refundWallet` receives the intermediate
* asset if a jump settlement fails (required for jump unless the request supplies `refundTo`). */
jump?: { enabled?: boolean; intermediateAsset?: FiatAssetInput; refundWallet?: string };
/** Fail-closed policy for integrator frontend origins: an allowlist (exact origins +
* `https://*.example.com` wildcard-subdomain patterns) or a predicate. */
cors: { origins: string[] | ((origin: string) => boolean) };
trustProxy?: { userIpHeaders?: string[] };
/** Resolve a caller identity for HTTP requests (e.g. from an API-key header you manage).
* Return null for anonymous; THROW to reject — the hook doubles as an auth gate. The
* tenantId flows to the rate limiter and is stamped on created orders. */
identify?: (request: Request) => Promise<{ tenantId?: string } | null> | { tenantId?: string } | null;
/** Fan-out quote behavior: per-provider budget for provider-less quotes
* (default 10s; 0 disables). Pinned quotes are never bounded. */
quotes?: { providerTimeoutMs?: number };
/** Quote-state strategy. Defaults to sealed using `quoteSecret`. */
quoteState?: QuoteStateConfig;
quoteSecret?: AeadSecret;
/** Per-provider display overrides (keyed by provider id) merged over the adapter's own
* `display` defaults — e.g. point `logoUrl` at a self-hosted asset. */
providerDisplay?: Record<string, Partial<ProviderDisplay>>;
/** Kill-switch: provider ids rejected (fail-closed) at quote and order time. */
disabledProviders?: string[];
/** Hard fiat-amount bounds enforced at quote + order time, independent of the provider. */
limits?: FiatAmountLimits;
/** Optional per-request rate limiter, enforced before any order-time side effect. */
rateLimiter?: FiatRateLimiter;
/** Optional strict recipient validator; called with `outputAsset.chainId`. Falls back to the built-in structural check. */
validateRecipient?: (address: string, chainId: number) => boolean;
events?: FiatEvents;
logger?: FiatLogger;
clock?: () => number;
/** Override how order ids are generated. Default: `fiat_order_<ULID>`. Inject a custom
* factory to match a legacy id format (e.g. when sharing a database with an older system). */
generateOrderId?: () => string;
timings?: Partial<FiatTimings>;
/** `embedOrigins` controls which origins may IFRAME the widget wrapper (CSP
* frame-ancestors); defaults to `cors.origins` when that is a list. */
widget?: { enabled?: boolean; embedOrigins?: string[] };
}Every field
| Field | Required? | Default | Purpose |
|---|---|---|---|
providers | Yes | — | Provider adapters. Must be non-empty; first entry is the default provider. |
cors | Yes | — | { origins } policy: allowlist (exact + https://*.sub wildcards) or predicate. May be empty (deny-all) but the key must be present. |
quoteSecret / quoteState | One of | — | Quote-seal strategy. Supply exactly one (see §9). Neither → throws. |
state | No | unset (stateless) | FiatStateStore. Required for order/status/webhook/widget routes and cron. |
cache | No | unset | Best-effort FiatCache (provider metadata, currency lists, access tokens). |
basePath | No | "" | URL prefix the routes mount under, e.g. "/v1/fiat". |
integratorId | No | unset | Hyperstream attribution id; also stored on orders and passed to the provider context. |
hyperstream | No | unset | { apiUrl } or { client }. Configuring either auto-enables jump. |
jump | No | enabled: Boolean(hyperstream), intermediateAsset: BASE_USDC | Jump routing options; refundWallet is required for jump unless requests pass refundTo. |
trustProxy | No | userIpHeaders: ["cf-connecting-ip"] | Which request headers to trust for the end-user IP. |
quotes | No | providerTimeoutMs: 10000 | Per-provider budget in the quote fan-out; slow providers are dropped into the response errors. 0 disables. |
identify | No | unset | Per-request identity hook: null = anonymous, a throw rejects the request (auth gate). Resolved tenantId keys the rate limiter and is stamped on orders. |
providerDisplay | No | adapter defaults | Per-provider { name, logoUrl } overrides surfaced on quotes and /config. |
disabledProviders | No | unset | Kill-switch: provider ids rejected (fail-closed) at quote + order time. |
limits | No | unset | Hard { minFiatAmount, maxFiatAmount } bounds, enforced independent of the provider. |
rateLimiter | No | unset | FiatRateLimiter.check(...) invoked before any order-time side effect. |
validateRecipient | No | built-in structural check | Strict recipient validator (address, chainId) => boolean. |
events | No | unset | FiatEvents lifecycle hooks. |
logger | No | noopLogger | FiatLogger. The default discards logs — pass one in production. |
clock | No | systemClock | () => number epoch-ms clock. Injectable for tests. |
generateOrderId | No | fiat_order_<ULID> | Order-id factory. Override to match a legacy id format. |
timings | No | DEFAULT_TIMINGS | Partial<FiatTimings> merged over the defaults below. |
widget | No | { enabled: true } | { enabled: false } disables the wrapper route; embedOrigins sets CSP frame-ancestors independently of CORS; title/background/imageOrigins/renderWrapper re-skin the page (see Widget & wrapper). |
Surprising defaults to internalize
widget.enableddefaults totrue— the/widget/{orderId}wrapper route is on unless you opt out.jump.enableddefaults toBoolean(hyperstream)— jump turns on automatically when Hyperstream is configured.trustProxy.userIpHeadersdefaults to["cf-connecting-ip"]— correct for Cloudflare, but override it behind a different proxy.basePathdefaults to""(routes mount at the origin root).loggerdefaults to a no-op — you get no logs until you pass one.timingsis merged overDEFAULT_TIMINGS, so you only override the fields you care about.
DEFAULT_TIMINGS
config.timings is shallow-merged over these:
| Timing | Default | What it controls |
|---|---|---|
discoveryDelaySeconds | 180 | Wait before discovering a provider order id. |
noPaymentTimeoutSeconds | 1800 | Cancel an unpaid order after this long. |
pollInitialSeconds | 30 | Delay before the first provider status poll. |
settlementReconcileSeconds | 15 | Settlement reconcile cadence (jump orders). |
orderExpirySeconds | 86_400 | Overall order expiry (24h). |
quoteGraceSeconds | 5 | Clock-skew grace applied to quote expiry. |
jobLeaseSeconds | 90 | Job lease duration for cron (lease held while a worker processes a job). |
maxJobAttempts | 200 | Hard cap on cron attempts before a job force-fails its order. |
A fully-populated example
Every settable field, wired from environment:
ts
import { createFiat } from "@tokenflight/fiat";
import { transak } from "@tokenflight/fiat/providers/transak";
import { createD1FiatState } from "@tokenflight/fiat-storage-d1";
const fiat = createFiat({
providers: buildProviders(env), // first entry is the default provider
state: createD1FiatState({ database: env.DB }),
cache: kvCache(env.API_CACHE), // see §7
basePath: "/v1/fiat",
integratorId: env.HYPERSTREAM_INTEGRATOR_ID,
hyperstream: { apiUrl: env.HYPERSTREAM_API_URL },
jump: { refundWallet: env.REFUND_WALLET }, // intermediateAsset defaults to BASE_USDC
cors: { origins: ["https://app.integrator.example"] },
trustProxy: { userIpHeaders: ["cf-connecting-ip"] },
quoteSecret: env.TOKENFLIGHT_FIAT_SECRET, // or: quoteState: { strategy: "sealed", secret }
disabledProviders: [], // e.g. ["transak"] to kill-switch a provider
limits: { minFiatAmount: "10", maxFiatAmount: "5000" },
rateLimiter: {
async check({ origin, endUserIp, recipient }) {
return { allowed: true }; // back with KV / a Durable Object token bucket
},
},
validateRecipient: (address, chainId) => /^0x[a-fA-F0-9]{40}$/.test(address),
events: {
/* onOrderCreated, onOrderUpdated, onOrderTerminal, onProviderWebhook — your lifecycle hooks */
},
logger: console,
clock: () => Date.now(),
generateOrderId: () => `fiat_order_${crypto.randomUUID()}`,
timings: { orderExpirySeconds: 43_200 }, // override just this one; the rest stay default
widget: { enabled: true },
});6. State store
A FiatStateStore is the source of truth for orders, the webhook idempotency ledger, provider-reference uniqueness, and the cron job queue. It is required at runtime for every method except quote() — quoting is the only stateless operation.
Three choices:
| Store | Use it for |
|---|---|
createD1FiatState({ database }) (@tokenflight/fiat-storage-d1) | Cloudflare D1. Ships versioned migrations. The production default on Workers. |
createMemoryStateStore() (@tokenflight/fiat) | Dev/test only. Per-process, in-memory, non-durable — every restart loses all orders. |
A custom FiatStateStore | Bring your own (Postgres, Upstash, your DAO). Implement the interface. |
ts
import { createD1FiatState } from "@tokenflight/fiat-storage-d1";
// ...
state: createD1FiatState({ database: env.DB }),fiat.cron() needs more than the core store: it requires the optional job methods claimDueJobs, finishJob, and (for follow-on scheduling) enqueueJobs. Both the D1 adapter and the in-memory store implement all three. A webhook-only deployment that never runs cron may omit them; a cron-capable one must provide them, or cron() throws CONFIGURATION_ERROR ("fiat.cron() requires a state store with claimDueJobs/finishJob").
For the full interface, the D1 schema, and how to implement your own, see State & storage.
Verifying a custom store or provider — @tokenflight/fiat-testkit
The state-store semantics are the most dangerous part to hand-roll: createOrderFromQuote must be transaction-equivalently idempotent and claimDueJobs must enforce leases, or you get duplicate orders and double settlement. The testkit package encodes those contracts as runnable conformance suites, plus the mock provider the SDK's own tests use (it doubles as the reference FiatProviderAdapter implementation):
ts
import { failedChecks, mockProvider, runProviderConformance, runStateStoreConformance } from "@tokenflight/fiat-testkit";
// Your store must pass every check before it goes anywhere near production.
const storeResults = await runStateStoreConformance(createMyPostgresStore());
console.table(storeResults);
expect(failedChecks(storeResults)).toEqual([]);
// Your adapter: RAW base-unit amounts, webhook verify-before-parse, status echo integrity.
const providerResults = await runProviderConformance(myProvider, {
supportedAsset: { chainId: 8453, address: "0x8335…" },
recipient: "0x1111…",
webhook: { valid: signedSample, tampered: tamperedSample },
});
expect(failedChecks(providerResults)).toEqual([]);Checks that can't run (a fixture you didn't supply, an optional store method you don't implement) report as skipped, never as silent passes. toRaw / fromRaw — the decimal ↔ RAW base-unit converters every adapter needs — are exported from @tokenflight/fiat itself.
7. Cache (optional)
A FiatCache is best-effort and never correctness-critical. It backs droppable data — provider metadata, currency lists, and provider access tokens — and every cache call is wrapped so a failure is swallowed and the code falls through to the live source. The interface:
ts
export interface FiatCache {
get(key: string): Promise<string | null>;
put(key: string, value: string, options?: { ttlSeconds?: number }): Promise<void>;
delete(key: string): Promise<void>;
}A KV-backed wrapper (from the Workers example):
ts
import type { FiatCache } from "@tokenflight/fiat";
function kvCache(kv: KVNamespace): FiatCache {
return {
get: (key) => kv.get(key),
async put(key, value, options) {
await kv.put(key, value, options?.ttlSeconds ? { expirationTtl: Math.max(60, Math.floor(options.ttlSeconds)) } : {});
},
delete: (key) => kv.delete(key),
};
}Pass it only when the binding exists, so a deploy without KV still boots:
ts
...(env.API_CACHE ? { cache: kvCache(env.API_CACHE) } : {}),Skipping the cache costs you extra provider round-trips (token refreshes, currency-list fetches) but never affects correctness.
8. CORS
cors.origins is a required, fail-closed policy for the frontend origins permitted to call the browser-facing routes. It takes one of two forms:
ts
type CorsOriginPolicy = string[] | ((origin: string) => boolean);List form — exact origins plus https://*.example.com wildcard-subdomain patterns:
ts
cors: { origins: ["https://app.integrator.example", "https://*.preview.integrator.example"] },- A bare wildcard
"*"is rejected at construction — it is unrepresentable on provider-backed routes. Wildcard patterns must put the star as the whole leftmost label with a real base domain (https://*.example.com;https://*.comandhttps://a.*.comare rejected). - A wildcard pattern matches any subdomain depth (
a.example.com,a.b.example.com) but never the apex (example.com), and the scheme/port must match exactly. - An empty list is valid and means deny-all for browsers: cross-origin browser requests are rejected with a 403 whose message says the allowlist is unconfigured. Requests with no
Originheader (server-to-server) still pass.createFiatalso logs a warning through yourloggerso a misconfigured deploy is visible at boot. - An allowed origin is echoed back exactly (never
"*") withVary: OriginandAccess-Control-Allow-Credentials: true.
Predicate form — for dynamic allowlists (e.g. per-customer domains looked up from your own config). A predicate that throws fails closed:
ts
cors: { origins: (origin) => myCustomerDomains.has(origin) },A common pattern is to drive the list from a comma-separated env var, defaulting to an empty list (fail-closed):
ts
cors: { origins: (env.ALLOWED_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean) },Iframe embedding is a separate knob. widget.embedOrigins controls which origins may IFRAME the hosted widget wrapper (CSP frame-ancestors). It defaults to cors.origins when that is a list, but the two are independent trust decisions — you can allow a marketing site to embed the wrapper without granting it API CORS, and vice versa. With a predicate CORS policy there is no list to fall back on, so set widget.embedOrigins explicitly (unset renders frame-ancestors 'none').
ts
widget: { embedOrigins: ["https://embed.integrator.example"] },For the full security posture, see Security.
9. Quote secret
You must supply exactly one of quoteSecret or quoteState. The resolution, verbatim:
ts
let quoteStateConfig: QuoteStateConfig;
if (config.quoteState) quoteStateConfig = config.quoteState;
else if (config.quoteSecret !== undefined) quoteStateConfig = { strategy: "sealed", secret: config.quoteSecret };
else throw new FiatError("CONFIGURATION_ERROR", "quoteSecret or quoteState is required");quoteSecretis shorthand for the default sealed strategy — the quote terms are AEAD-encrypted into the opaquequoteIditself, so no server storage is needed.quoteStategives full control:{ strategy: "sealed", secret, kid?, maxTokenLength? }or{ strategy: "store", store }(you provide aFiatQuoteStateStore).
The secret type is AeadSecret = string | Uint8Array | CryptoKey. A string or raw byte material (Uint8Array) is HKDF-stretched into a uniform AES-256-GCM key (so a low-entropy or arbitrary-length input still yields a strong key); only a pre-derived CryptoKey is used directly. A single strong string from your secret manager is the simplest production choice.
ts
quoteSecret: env.TOKENFLIGHT_FIAT_SECRET, // string, Uint8Array, or CryptoKeyLarge sealed quotes → 413
A sealed token has a hard length cap (default 2048 chars). If a quote's sealed payload exceeds it, issue() throws QUOTE_TOO_LARGE, which maps to HTTP 413. This is rare, but jump quotes with large route payloads can hit it — switch that deployment to the store strategy (quoteState: { strategy: "store", store }), which keeps the payload server-side behind a short id.
The sealed token is an integrity mechanism, not an authorization boundary — POST /order re-validates everything (asset allowlist, amount limits, rate limits, recipient) after unsealing. See Security and State & storage.
10. Background work with cron
fiat.cron() claims a lease-protected batch of due jobs and advances each — provider status polling, settlement reconciliation (jump orders), order expiry, and stuck-order recovery. It is the safety net for any order that never received a provider webhook.
ts
const result = await fiat.cron({ limit: 50 }, { env, ctx });FiatCronOptions is { limit?, kinds? }. limit defaults to 50; kinds lets you run a subset of job kinds (omit it to run all). The return shape, verbatim:
ts
export interface FiatCronResult {
claimed: number; // jobs leased this run
completed: number; // outcome "done"
retried: number; // outcome "retry" (re-armed for a later run)
failed: number; // outcome "failed" (terminal)
}It requires a job-capable store (§6) and is safe under concurrent triggers: claims are leased (jobLeaseSeconds) and every order update is version-CAS, so two schedulers firing at once won't double-process a job or corrupt an order. The context argument is accepted for fetch/cron symmetry but currently reserved — the runtime closes over its dependencies at createFiat time.
There are two ways to drive it:
- Native scheduler — a platform cron (Cloudflare
scheduled, etc.) callsfiat.cron()directly inside the worker (§11). - External pinger — an HTTP cron (Vercel Cron, an uptime pinger) hits a route that calls
fiat.cron()and returns the result (§12).
Full operational recipes (cadence, batching, multiple regions) are in Deployment.
11. Deploy on Cloudflare Workers
The entry point exports fetch (the HTTP contract) and scheduled (the cron driver), sharing one lazily-built instance per isolate:
ts
import { createIntegratorFiat } from "./fiat.js";
let fiat: ReturnType<typeof createIntegratorFiat> | undefined;
function getFiat(env: Env): ReturnType<typeof createIntegratorFiat> {
fiat ??= createIntegratorFiat(env);
return fiat;
}
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return getFiat(env).fetch(request, { env, ctx });
},
scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): void {
ctx.waitUntil(getFiat(env).cron({ limit: 50 }, { env, ctx }));
},
};createIntegratorFiat(env) is the wiring from §4/§5 — buildProviders(env), D1 state, optional KV cache, and a fail-closed quoteSecret:
ts
export function createIntegratorFiat(env: Env): Fiat {
const quoteSecret = env.TOKENFLIGHT_FIAT_SECRET;
if (!quoteSecret) throw new Error("TOKENFLIGHT_FIAT_SECRET is required");
return createFiat({
basePath: "/v1/fiat",
providers: buildProviders(env),
state: createD1FiatState({ database: env.DB }),
cors: { origins: splitCsv(env.ALLOWED_ORIGINS, []) }, // fail-closed: unconfigured => deny-all
quoteSecret,
trustProxy: { userIpHeaders: ["cf-connecting-ip"] },
...(env.API_CACHE ? { cache: kvCache(env.API_CACHE) } : {}),
...(env.HYPERSTREAM_INTEGRATOR_ID ? { integratorId: env.HYPERSTREAM_INTEGRATOR_ID } : {}),
...(env.HYPERSTREAM_API_URL ? { hyperstream: { apiUrl: env.HYPERSTREAM_API_URL } } : {}),
...(env.REFUND_WALLET ? { jump: { refundWallet: env.REFUND_WALLET } } : {}),
});
}To make it yours:
bash
# 1. Point wrangler at your own D1 database and KV namespace ids (edit wrangler.jsonc).
# 2. Apply the D1 migrations shipped with @tokenflight/fiat-storage-d1.
# 3. Set secrets (never commit these):
wrangler secret put TOKENFLIGHT_FIAT_SECRET
wrangler secret put TRANSAK_API_KEY
wrangler secret put TRANSAK_API_SECRET
# 4. Set ALLOWED_ORIGINS, TRANSAK_ENVIRONMENT, and any Hyperstream vars; deploy.
wrangler deployThe full walkthrough — wrangler.jsonc bindings, the cron-trigger schedule, and migration commands — is in Deployment.
12. Deploy on Next.js / Vercel
Next.js Route Handlers already speak Web Request/Response, so no framework adapter is needed — one createFiat instance powers both the HTTP routes and the cron route.
Construct one instance per server process:
ts
import { createFiat, createMemoryStateStore } from "@tokenflight/fiat";
import { transak } from "@tokenflight/fiat/providers/transak";
let instance: ReturnType<typeof createFiat> | undefined;
export function getFiat(): ReturnType<typeof createFiat> {
if (instance) return instance;
instance = createFiat({
basePath: "/api/fiat",
integratorId: process.env.HYPERSTREAM_INTEGRATOR_ID,
quoteState: { strategy: "sealed", secret: required("TOKENFLIGHT_FIAT_SECRET") },
// NOTE: replace with a durable FiatStateStore in production. The in-memory store is dev-only.
state: createMemoryStateStore(),
providers: [
transak({
apiKey: required("TRANSAK_API_KEY"),
apiSecret: required("TRANSAK_API_SECRET"),
environment: process.env.TRANSAK_ENVIRONMENT === "production" ? "production" : "staging",
}),
],
hyperstream: { apiUrl: process.env.HYPERSTREAM_API_URL },
cors: { origins: (process.env.ALLOWED_ORIGINS ?? "").split(",").filter(Boolean) },
});
return instance;
}Mount the whole contract under /api/fiat with one optional catch-all Route Handler that forwards GET / POST / OPTIONS:
ts
// app/api/fiat/[[...route]]/route.ts
import { getFiat } from "../../../../fiat";
export const dynamic = "force-dynamic";
export const GET = (req: Request): Promise<Response> => getFiat().fetch(req);
export const POST = (req: Request): Promise<Response> => getFiat().fetch(req);
export const OPTIONS = (req: Request): Promise<Response> => getFiat().fetch(req);Run cron from a separate route — kept outside the /api/fiat catch-all on purpose. Vercel Cron triggers with GET, so GET is the primary handler; guard the publicly reachable endpoint with CRON_SECRET:
ts
// app/api/fiat-cron/route.ts
import { getFiat } from "../../../fiat";
export const dynamic = "force-dynamic";
async function runCron(req: Request): Promise<Response> {
const secret = process.env.CRON_SECRET;
if (secret && req.headers.get("authorization") !== `Bearer ${secret}`) {
return new Response("unauthorized", { status: 401 });
}
const result = await getFiat().cron({ limit: 50 });
return Response.json(result);
}
export const GET = (req: Request): Promise<Response> => runCron(req);
export const POST = (req: Request): Promise<Response> => runCron(req);Replace the in-memory store before production
createMemoryStateStore() is per-process and non-durable — fine for local development, unusable in production (and incoherent across serverless instances). Swap in a durable FiatStateStore (Postgres, Upstash, your own DAO) before you ship.
The full Vercel setup, including vercel.json cron configuration, is in Deployment.
13. Add jump routing (optional)
To let users buy assets no single provider sells directly, pass a Hyperstream endpoint. Configuring hyperstream.apiUrl (or passing a hyperstream.client) auto-enables jump, because jump.enabled defaults to Boolean(hyperstream):
ts
const fiat = createFiat({
providers: buildProviders(env),
cors: { origins: ["https://app.example.com"] },
quoteSecret: env.TOKENFLIGHT_FIAT_SECRET,
hyperstream: { apiUrl: env.HYPERSTREAM_API_URL },
integratorId: env.HYPERSTREAM_INTEGRATOR_ID,
jump: {
// intermediateAsset defaults to BASE_USDC
refundWallet: env.REFUND_WALLET, // required for jump unless every request supplies refundTo
},
});Every jump needs a refund address for the case where cross-chain settlement fails: set jump.refundWallet, or accept a per-request refundTo (the planner resolves request.refundTo ?? config.jump.refundWallet and throws if neither is present). For exactly when jump triggers, how trades are sized, and the settlement lifecycle, see Jump routes.
14. Next steps
- API usage — call
/quote,/order, and/order/{orderId}from a frontend. - Widget & wrapper — render the provider checkout via the
/widget/{orderId}wrapper. - Jump routes — buy an intermediate asset and settle to the target via Hyperstream.
- Lifecycle & webhooks — order states, provider webhooks, and cron progression.
- State & storage — the
FiatStateStore/FiatCacheinterfaces and the D1 adapter. - Deployment — full Workers and Vercel walkthroughs.
- Security — sealed quotes, CORS, kill-switches, and the fail-closed posture.
- API reference — the request/response schema for every route.