Skip to content

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.

TargetRecipeScheduler
Cloudflare Workersbelownative scheduled() + triggers.crons
Next.js / VercelbelowVercel 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.

VariableRequired whenPurpose
TOKENFLIGHT_FIAT_SECRETAlwaysSeals opaque quote tokens (quoteSecret / sealed quoteState). Rotate to revoke outstanding quotes. The SDK refuses to boot without it.
TRANSAK_API_KEYTo enable TransakTransak provider API key. With no provider credentials the SDK fails closed and refuses to boot.
TRANSAK_API_SECRETTo enable TransakSigns Transak API and webhook traffic.
TRANSAK_ENVIRONMENTOptionalstaging (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_ORIGINSAlways (in practice)Comma-separated, fail-closed CORS allowlist (exact origins or https://*.example.com). An unconfigured deploy allows no origins.
WIDGET_EMBED_ORIGINSOnly for iframe embedsComma-separated origins allowed to IFRAME the widget wrapper (CSP frame-ancestors). Unset falls back to ALLOWED_ORIGINS.
HYPERSTREAM_API_URLFor jump routesHyperstream API base URL; enables jump settlement.
HYPERSTREAM_INTEGRATOR_IDFor attribution / jumpIntegrator id passed to Hyperstream and stored on orders.
REFUND_WALLETFor jump routesFallback wallet for the intermediate asset when a jump settlement fails (per-request refundTo overrides it).
CRON_SECRETNext.js cronBearer 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_WALLET

Bindings

BindingTypeRole
DBD1Durable order/quote/job state. Required for /order, /order/{orderId}, /webhook/{provider}, /widget/{orderId}, and cron.
API_CACHEKVAn 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_SECRET

For 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:3000

D1 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 --remote

Wrap 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). Keep cron({ limit }) small enough to finish within it, or split work across schedules with kinds.

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's reconcile_oda job. Without cron a jump order sticks at processing forever.
  • 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/:id is 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 (Workers scheduled() driven by triggers.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:

OptionDefaultMeaning
limit50Max due jobs claimed per run.
kindsall kindsRestrict 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 (createD1FiatState or your own) — not createMemoryStateStore().
  • [ ] The schema is applied to the remote D1 with wrangler d1 execute … --remote --file …. migrations apply alone can't see the package's nested migrations — see State & storage.
  • [ ] GET /config reports features.order and features.cron as true.

Secrets & config

  • [ ] TOKENFLIGHT_FIAT_SECRET, TRANSAK_API_KEY, TRANSAK_API_SECRET set as secrets (never in vars).
  • [ ] ALLOWED_ORIGINS lists 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 a REFUND_WALLET (or per-request refundTo) 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_SECRET is 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.