Appearance
Deployment
Take the @tokenflight/fiat SDK to production. This page covers the two canonical recipes, the full environment/binding matrix, secrets vs vars, D1 migrations, and cron shapes.
Deploy targets
Every deployment reduces to the same two entry points: fiat.fetch(request) for the HTTP contract and fiat.cron(options?) for background progression. Pick the runtime; the SDK stays the same.
| Target | Recipe | Scheduler |
|---|---|---|
| Cloudflare Workers | below | native scheduled() + triggers.crons |
| Next.js / Vercel | below | Vercel Cron hitting an HTTP route |
Both recipes build a single createFiat({ ... }) instance and reuse it for HTTP and cron. Every file they need is inlined on this page.
Environment variables & bindings
The SDK takes credentials from the host environment, never from the browser. This consolidated matrix covers both recipes.
| Variable | Required when | Purpose |
|---|---|---|
TOKENFLIGHT_FIAT_SECRET | Always | Seals opaque quote tokens (quoteSecret / sealed quoteState). Rotate to revoke outstanding quotes. The SDK refuses to boot without it. |
TRANSAK_API_KEY | To enable Transak | Transak provider API key. With no provider credentials the SDK fails closed and refuses to boot. |
TRANSAK_API_SECRET | To enable Transak | Signs Transak API and webhook traffic. |
TRANSAK_ENVIRONMENT | Optional | staging (the recipes' default) or production — anything other than production resolves to staging. Note: calling transak({...}) directly without an environment defaults to production; see Configuration. |
ALLOWED_ORIGINS | Always (in practice) | Comma-separated, fail-closed CORS allowlist (exact origins or https://*.example.com). An unconfigured deploy allows no origins. |
WIDGET_EMBED_ORIGINS | Only for iframe embeds | Comma-separated origins allowed to IFRAME the widget wrapper (CSP frame-ancestors). Unset falls back to ALLOWED_ORIGINS. |
HYPERSTREAM_API_URL | For jump routes | Hyperstream API base URL; enables jump settlement. |
HYPERSTREAM_INTEGRATOR_ID | For attribution / jump | Integrator id passed to Hyperstream and stored on orders. |
REFUND_WALLET | For jump routes | Fallback wallet for the intermediate asset when a jump settlement fails (per-request refundTo overrides it). |
CRON_SECRET | Next.js cron | Bearer token guarding the public cron route. |
Secrets vs vars
Split these by sensitivity. Secrets are injected at the platform level (wrangler secret put, Vercel encrypted env). Vars are plain configuration and can live in wrangler.jsonc vars or the dashboard.
text
secrets TOKENFLIGHT_FIAT_SECRET, TRANSAK_API_KEY, TRANSAK_API_SECRET,
CRON_SECRET (Next.js)
vars TRANSAK_ENVIRONMENT, ALLOWED_ORIGINS, WIDGET_EMBED_ORIGINS,
HYPERSTREAM_API_URL, HYPERSTREAM_INTEGRATOR_ID, REFUND_WALLETBindings
| Binding | Type | Role |
|---|---|---|
DB | D1 | Durable order/quote/job state. Required for /order, /order/{orderId}, /webhook/{provider}, /widget/{orderId}, and cron. |
API_CACHE | KV | An optional best-effort cache (provider metadata, tokens). Wired only when the binding is present; a miss never affects correctness. |
Cache is best-effort
API_CACHE is wired only when present (...(env.API_CACHE ? { cache: kvCache(env.API_CACHE) } : {})) and is pure best-effort cache — a missing or failing cache never affects correctness. See State and storage.
Cloudflare Workers
The entry exports fetch (the HTTP contract) and scheduled (background progression), sharing one lazily-built instance per isolate:
ts
// src/index.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 }));
},
};The instance is wired in src/fiat.ts: D1 state via createD1FiatState, the Transak provider built from secrets, fail-closed CORS, and an optional KV cache. basePath mounts the contract at /v1/fiat:
ts
// src/fiat.ts (excerpt)
return createFiat({
basePath: "/v1/fiat",
providers: buildProviders(env),
state: createD1FiatState({ database: env.DB }),
cors: { origins: splitCsv(env.ALLOWED_ORIGINS, []) },
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 } } : {}),
});Wrangler config
Create your own D1 database and KV namespace, then replace each REPLACE_ME:
jsonc
// wrangler.jsonc
{
"name": "fiat-next",
"main": "src/index.ts",
"compatibility_date": "2026-06-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"d1_databases": [
{
"binding": "DB",
"database_name": "fiat-next",
"database_id": "REPLACE_ME",
"migrations_dir": "migrations",
},
],
"kv_namespaces": [{ "binding": "API_CACHE", "id": "REPLACE_ME" }],
"triggers": { "crons": ["*/2 * * * *"] },
}Add non-secret configuration as a vars block:
jsonc
"vars": {
"TRANSAK_ENVIRONMENT": "production",
"ALLOWED_ORIGINS": "https://app.example.com",
"HYPERSTREAM_API_URL": "https://hyperstream.example/api",
"HYPERSTREAM_INTEGRATOR_ID": "your-integrator-id",
"REFUND_WALLET": "0x...",
}Secrets
Set the sensitive values with wrangler secret put (never commit them):
bash
wrangler secret put TOKENFLIGHT_FIAT_SECRET
wrangler secret put TRANSAK_API_KEY
wrangler secret put TRANSAK_API_SECRETFor local dev, put the same keys in a git-ignored .dev.vars — wrangler/vite loads it automatically. Use Transak staging keys locally; the recipe wires no mock provider, so a deploy with no provider credentials refuses to boot.
ini
# .dev.vars (never commit real secrets)
TOKENFLIGHT_FIAT_SECRET=dev-only-change-me
TRANSAK_API_KEY=your-transak-staging-key
TRANSAK_API_SECRET=your-transak-staging-secret
TRANSAK_ENVIRONMENT=staging
ALLOWED_ORIGINS=http://localhost:3000D1 migrations
@tokenflight/fiat-storage-d1 is the single source of truth for the schema. wrangler d1 migrations apply only reads the directory named by migrations_dir, so first sync the package's versioned .sql files into it, then apply:
bash
# Sync the package migrations into the wrangler-tracked ./migrations dir
mkdir -p migrations && cp node_modules/@tokenflight/fiat-storage-d1/migrations/*.sql migrations/
# Apply to the local D1 (dev)
wrangler d1 migrations apply fiat-next --local
# Apply to the remote (production) D1
wrangler d1 migrations apply fiat-next --remoteWrap the sync + apply pair in package scripts (e.g. db:migrate:local / db:migrate:remote) so each is one command, and re-run the sync whenever you upgrade @tokenflight/fiat-storage-d1.
Apply migrations before the first deploy
The stateful routes and cron require the schema to exist. Apply the migrations to your production D1 before serving traffic.
Build & deploy
bash
pnpm run deploy # e.g. a "deploy": "vite build && wrangler deploy" script(Use pnpm run deploy, not pnpm deploy — the bare form is shadowed by pnpm's built-in workspace deploy command.)
The native cron is declared by triggers.crons and handled by the scheduled() export — no external pinger required.
Next.js / Vercel
Next.js Route Handlers already speak the Web Request/Response the SDK is built on, so there is no framework-specific package — the recipe is two files in your App Router app: a catch-all route and a shared fiat.ts.
A single optional catch-all mounts the whole contract under basePath (/api/fiat). It must export GET, POST, and OPTIONS, and opt out of static optimization with force-dynamic:
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);The shared instance lives in fiat.ts with basePath: "/api/fiat" and a sealed quote strategy:
ts
// fiat.ts (excerpt)
instance = createFiat({
basePath: "/api/fiat",
integratorId: process.env.HYPERSTREAM_INTEGRATOR_ID,
quoteState: { strategy: "sealed", secret: required("TOKENFLIGHT_FIAT_SECRET") },
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) },
});Replace the in-memory store before production
createMemoryStateStore() is per-process and non-durable — dev only. Order creation, webhooks, and cron all require durable state. Supply your own FiatStateStore (Postgres, Upstash/Redis, your existing DAO, or @tokenflight/fiat-storage-d1 if you also run on Cloudflare). See State and storage for the contract.
Cron route
Vercel has no native in-process scheduler, so cron is a separate HTTP route kept outside the /api/fiat catch-all. Vercel Cron invokes the path with GET, so GET is the primary handler; POST is offered for manual or other schedulers. Because the endpoint is publicly reachable, guard it with a bearer token when CRON_SECRET is set (Vercel sends this header automatically):
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);Schedule it with vercel.json:
json
{
"crons": [{ "path": "/api/fiat-cron", "schedule": "*/2 * * * *" }]
}Vercel Cron cadence is plan-gated
The */2 * * * * schedule above requires a Pro (or Enterprise) plan. Granularity depends on the tier:
- Hobby — once per day only. A sub-daily expression like
*/2 * * * *is rejected at deploy time, and once-a-day is far too coarse to settle orders. - Pro / Enterprise — down to once per minute (no plan offers finer than 1-minute granularity).
Two more Vercel specifics to design around:
- Delivery is best-effort — runs can be missed or duplicated, and failures are not retried. That is safe here because the SDK's jobs are idempotent (lease + version-CAS + webhook dedup), but it makes Vercel Cron a weaker guarantee than a dedicated scheduler.
- A cron run is one Function invocation — capped by
maxDuration(Fluid compute default ~300s; older non-Fluid projects as low as 10–60s on Hobby). Keepcron({ limit })small enough to finish within it, or split work across schedules withkinds.
On Hobby, or when you need sub-minute or guaranteed delivery, drive the same guarded route from an external scheduler instead: Upstash QStash (retries + request signing), Cloudflare Workers Cron Triggers, or a service like cron-job.org — each just calls your /api/fiat-cron URL with the Authorization: Bearer <CRON_SECRET> header.
Background progression: cron shapes
Cron is required in production — webhooks are not enough
Run fiat.cron() on every production deployment. Webhooks push progress fast, but cron is the only thing that:
- Completes jump orders. A provider — via webhook or polling — only ever gets a jump order to
processing; the Hyperstream settlement leg runs exclusively inside cron'sreconcile_odajob. Without cron a jump order sticks atprocessingforever. - Fires the expiry/cancel backstops. Unpaid-order cancellation and stale-order expiry are time-based — there is no webhook for "nothing happened" — so they only ever fire from cron.
GET /order/:idis a pure read and never advances state. - Recovers a missed webhook. Provider webhook delivery is best-effort; cron's provider polling is the only fallback that re-reads status when a webhook is dropped or never sent.
A webhook-only deployment (omitting the job methods) safely handles only direct orders whose terminal webhook is actually delivered. A single dropped webhook, any jump order, or any unpaid/expired order will hang in a non-terminal state indefinitely. See State & storage for which store methods cron needs.
fiat.cron() advances provider polling, settlement reconciliation, expiry, and stuck-order recovery. Two deployment shapes:
- Native platform scheduler — call
fiat.cron()in-process from the runtime's scheduler (Workersscheduled()driven bytriggers.crons). - External HTTP pinger — point any scheduler (Vercel Cron, GitHub Actions, an uptime pinger) at an HTTP route that calls
fiat.cron().
Cron is concurrency-safe: jobs are claimed under a lease and every update is version-CAS, so it is safe to run from many overlapping triggers. It requires a state store that implements claimDueJobs/finishJob; without them the SDK throws CONFIGURATION_ERROR.
Tuning
fiat.cron(options?, context?) accepts:
| Option | Default | Meaning |
|---|---|---|
limit | 50 | Max due jobs claimed per run. |
kinds | all kinds | Restrict to a subset: "provider", "settlement", "expiry", "webhook". Omit to run all four. |
It returns { claimed, completed, retried, failed }. Example — run only provider polling and settlement, in larger batches:
ts
await fiat.cron({ limit: 100, kinds: ["provider", "settlement"] });Lease vs job runtime
Set the job lease comfortably above one job's runtime so a slow job's lease does not expire mid-flight and get re-claimed by a parallel run. The lease is timings.jobLeaseSeconds (default 90). Tune it via createFiat({ timings: { jobLeaseSeconds: ... } }).
Pre-launch checklist
Before serving real traffic:
State & migrations
- [ ] A durable state store is wired (
createD1FiatStateor your own) — notcreateMemoryStateStore(). - [ ] The schema is applied to the remote D1 with
wrangler d1 execute … --remote --file ….migrations applyalone can't see the package's nested migrations — see State & storage. - [ ]
GET /configreportsfeatures.orderandfeatures.cronastrue.
Secrets & config
- [ ]
TOKENFLIGHT_FIAT_SECRET,TRANSAK_API_KEY,TRANSAK_API_SECRETset as secrets (never invars). - [ ]
ALLOWED_ORIGINSlists your real browser origins — the CORS allowlist is fail-closed, and an empty list denies all browser callers. - [ ]
TRANSAK_ENVIRONMENT=production(staging keys won't process real orders). - [ ] Jump only:
HYPERSTREAM_API_URL,HYPERSTREAM_INTEGRATOR_ID, and aREFUND_WALLET(or per-requestrefundTo) are set.
Cron — required (see the warning above)
- [ ]
fiat.cron()runs on a schedule. Workers:triggers.crons+scheduled(). Vercel: a guarded cron route plus a Pro plan for*/2(Hobby is daily-only), or an external scheduler (QStash / Cloudflare Cron / cron-job.org). - [ ]
cron({ limit })fits inside the platform's function timeout. - [ ] Vercel:
CRON_SECRETis set and the route checks it.
Webhooks
- [ ] The provider dashboard webhook points at
{origin}{basePath}/webhook/transak. - [ ] No upstream middleware reads the webhook request body before
handleWebhook— it verifies the raw bytes.
Related
- Configuration — wiring
createFiatfrom scratch. - State and storage — the
FiatStateStoreandFiatCachecontracts. - Security — secrets, CORS, quote sealing, and webhook trust.