Skip to content

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 needWhyNotes
A provider accountTo 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 secretSeals 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 storeRequired for orders, status, webhooks, and cron.@tokenflight/fiat-storage-d1 (Cloudflare D1), or bring your own FiatStateStore.
A CORS allowlistFail-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 endpointOptional. 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>;
}
  • fetch is 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).
  • cron is the background driver. It is the only thing that moves orders forward when a provider webhook is missed.
  • handleWebhook is invoked for you by fetch on POST /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 specifierWhat it gives you
@tokenflight/fiatcreateFiat, the HTTP contract, programmatic APIs, createMemoryStateStore, the FiatStateStore / FiatCache interfaces, sealed-quote helpers.
@tokenflight/fiat/providers/transakThe transak(options) adapter.
@tokenflight/fiat/providers/coinbase-onrampThe coinbaseOnramp(options) adapter — a typed placeholder (see §4).
@tokenflight/fiat-storage-d1createD1FiatState — 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-d1

3. The minimal service

The smallest createFiat that passes constructor validation needs exactly three things:

  1. a non-empty providers array,
  2. cors.origins (an array — may be empty, but the key must be present),
  3. exactly one of quoteSecret or quoteState.
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 FiatFetchContextenv 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 is api-stg.transak.com / global-stg.transak.com; production is api.transak.com / global.transak.com.
  • fetch — inject a custom fetch (testing, proxying); defaults to the platform fetch.
  • networkMap — only needed for chains whose numeric chainId Transak'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

FieldRequired?DefaultPurpose
providersYesProvider adapters. Must be non-empty; first entry is the default provider.
corsYes{ origins } policy: allowlist (exact + https://*.sub wildcards) or predicate. May be empty (deny-all) but the key must be present.
quoteSecret / quoteStateOne ofQuote-seal strategy. Supply exactly one (see §9). Neither → throws.
stateNounset (stateless)FiatStateStore. Required for order/status/webhook/widget routes and cron.
cacheNounsetBest-effort FiatCache (provider metadata, currency lists, access tokens).
basePathNo""URL prefix the routes mount under, e.g. "/v1/fiat".
integratorIdNounsetHyperstream attribution id; also stored on orders and passed to the provider context.
hyperstreamNounset{ apiUrl } or { client }. Configuring either auto-enables jump.
jumpNoenabled: Boolean(hyperstream), intermediateAsset: BASE_USDCJump routing options; refundWallet is required for jump unless requests pass refundTo.
trustProxyNouserIpHeaders: ["cf-connecting-ip"]Which request headers to trust for the end-user IP.
quotesNoproviderTimeoutMs: 10000Per-provider budget in the quote fan-out; slow providers are dropped into the response errors. 0 disables.
identifyNounsetPer-request identity hook: null = anonymous, a throw rejects the request (auth gate). Resolved tenantId keys the rate limiter and is stamped on orders.
providerDisplayNoadapter defaultsPer-provider { name, logoUrl } overrides surfaced on quotes and /config.
disabledProvidersNounsetKill-switch: provider ids rejected (fail-closed) at quote + order time.
limitsNounsetHard { minFiatAmount, maxFiatAmount } bounds, enforced independent of the provider.
rateLimiterNounsetFiatRateLimiter.check(...) invoked before any order-time side effect.
validateRecipientNobuilt-in structural checkStrict recipient validator (address, chainId) => boolean.
eventsNounsetFiatEvents lifecycle hooks.
loggerNonoopLoggerFiatLogger. The default discards logs — pass one in production.
clockNosystemClock() => number epoch-ms clock. Injectable for tests.
generateOrderIdNofiat_order_<ULID>Order-id factory. Override to match a legacy id format.
timingsNoDEFAULT_TIMINGSPartial<FiatTimings> merged over the defaults below.
widgetNo{ 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.enabled defaults to true — the /widget/{orderId} wrapper route is on unless you opt out.
  • jump.enabled defaults to Boolean(hyperstream) — jump turns on automatically when Hyperstream is configured.
  • trustProxy.userIpHeaders defaults to ["cf-connecting-ip"] — correct for Cloudflare, but override it behind a different proxy.
  • basePath defaults to "" (routes mount at the origin root).
  • logger defaults to a no-op — you get no logs until you pass one.
  • timings is merged over DEFAULT_TIMINGS, so you only override the fields you care about.

DEFAULT_TIMINGS

config.timings is shallow-merged over these:

TimingDefaultWhat it controls
discoveryDelaySeconds180Wait before discovering a provider order id.
noPaymentTimeoutSeconds1800Cancel an unpaid order after this long.
pollInitialSeconds30Delay before the first provider status poll.
settlementReconcileSeconds15Settlement reconcile cadence (jump orders).
orderExpirySeconds86_400Overall order expiry (24h).
quoteGraceSeconds5Clock-skew grace applied to quote expiry.
jobLeaseSeconds90Job lease duration for cron (lease held while a worker processes a job).
maxJobAttempts200Hard 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:

StoreUse 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 FiatStateStoreBring 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://*.com and https://a.*.com are 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 Origin header (server-to-server) still pass. createFiat also logs a warning through your logger so a misconfigured deploy is visible at boot.
  • An allowed origin is echoed back exactly (never "*") with Vary: Origin and Access-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");
  • quoteSecret is shorthand for the default sealed strategy — the quote terms are AEAD-encrypted into the opaque quoteId itself, so no server storage is needed.
  • quoteState gives full control: { strategy: "sealed", secret, kid?, maxTokenLength? } or { strategy: "store", store } (you provide a FiatQuoteStateStore).

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 CryptoKey

Large 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.) calls fiat.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 deploy

The 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 / FiatCache interfaces 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.