Skip to content

Widget & wrapper

After POST /order, the response carries an action telling your frontend how to put the user into the provider's checkout. For HTTP callers it's almost always a wrapper — an SDK-hosted, security-hardened page at GET {basePath}/widget/{orderId} that iframes the provider's widget and relays its events back to you.

The order action

POST /order returns orderId, status, provider, and an action:

json
{
  "orderId": "fiat_order_01J...",
  "status": "pending",
  "provider": "transak",
  "action": { "type": "wrapper", "url": "https://api.example.com/v1/fiat/widget/fiat_order_01J..." }
}

action has four shapes:

action.typeWhat to do
wrapperOpen or iframe url — the SDK-hosted page (CSP + event bridge included). This is what you get over HTTP.
provider_widgetOpen the provider's raw widget url yourself (no SDK wrapper).
redirectSend the user to url (provider-hosted checkout).
noneNo checkout surface to open.
ts
type FiatOrderAction =
  | { type: "wrapper"; url: string }
  | { type: "redirect"; url: string }
  | { type: "provider_widget"; url: string; provider: string }
  | { type: "none" };

Over HTTP the handler supplies a wrapper base URL, so a provider that emits a wrapper action (all shipped providers do) gives you a wrapper. A provider's native redirect or none action passes through unchanged; you'd also see provider_widget / none when calling the programmatic createOrder without a wrapper base. Always branch on action.type.

What the wrapper is

The wrapper is an SDK-hosted HTML page at GET {basePath}/widget/{orderId}: a single full-viewport iframe pointed at the provider's checkout, plus a small script that relays the provider's events to your app. It works with zero configuration — every deployment serves the same hardened page by default.

It ships a strict Content-Security-Policy and permissions headers, assembled automatically from the order's provider origins: only the provider checkout can be framed, only the provider's APIs can be reached, only the SDK's own script runs, and — importantly — only your configured embed origins may iframe it (frame-ancestors = widget.embedOrigins, which defaults to your cors.origins list). You get this hardening for free; there's nothing to set.

To turn the wrapper off, pass widget: { enabled: false } to createFiat — then GET /widget/{orderId} returns 404.

Re-skinning the wrapper

The page (not its security posture) is customizable through createFiat({ widget }):

ts
widget: {
  title: "Acme Checkout",              // page <title> (default "Checkout")
  background: "#0a0a0a",               // page background while the provider loads
  imageOrigins: ["https://cdn.acme.example"], // extra CSP img-src origins for your assets
  // Full control: replace the page HTML. Compose the two prebuilt fragments —
  // ctx.iframeHtml (the provider iframe, security attributes applied) and
  // ctx.bridgeHtml (the event bridge) — into your own chrome. Custom inline JS
  // must carry ctx.nonce or the CSP blocks it.
  renderWrapper: (ctx, defaultHtml) => `<!doctype html>
<html><head><title>Acme Checkout</title></head>
<body><header class="brand">Acme</header>${ctx.iframeHtml}${ctx.bridgeHtml}</body></html>`,
}

Whatever renderWrapper returns, the response headers (CSP, Referrer-Policy, permissions) stay SDK-computed — a custom page cannot weaken them. Keep ctx.iframeHtml verbatim: its referrerpolicy="strict-origin-when-cross-origin" is load-bearing (providers validate the page's Referer against the session's registered domain; stripping it renders errors like Transak's T-INF-103).

Rendering the wrapper

Two options.

Iframe it in your app

html
<iframe
  src="https://api.example.com/v1/fiat/widget/fiat_order_01J..."
  allow="camera; microphone; payment; publickey-credentials-get"
  style="border:0;width:100%;height:680px"
  title="Buy crypto"
></iframe>

The embedding page must be a configured embed origin

The wrapper's frame-ancestors is widget.embedOrigins (falling back to your cors.origins list). The page that iframes it must be served from one of those origins, or the browser refuses to render the frame. Set your iframe's allow to the same permission features (camera / payment / etc.) so they reach the provider. See Security.

Open it in a popup / new tab

ts
const { action } = await fetch("/v1/fiat/order", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ quoteId }),
}).then((r) => r.json());

if (action.type === "wrapper") {
  window.open(action.url, "tf-checkout", "width=480,height=720");
}

A popup sidesteps the frame-ancestors requirement (nothing is being framed), and the bridge still reaches you via window.opener.

The event bridge

The wrapper relays the provider widget's postMessage events to both parent and opener (so iframe and popup both work), in two forms.

1. Unified lifecycle events (recommended). The wrapper translates each provider's native events into a stable, provider-agnostic vocabulary and forwards them under the FIAT_WIDGET_EVENT envelope:

ts
interface FiatWidgetEvent {
  type: "FIAT_WIDGET_EVENT";
  event: "widget_ready" | "order_created" | "order_processing" | "order_completed" | "order_failed" | "widget_close";
  provider: string;
  orderId?: string;
  providerOrderId?: string;
  data?: Record<string, unknown>;
}
  • widget_ready — the provider widget initialized. order_created / order_processing / order_completed / order_failed track the order. widget_close — the provider widget closed (the wrapper auto-closes a popup ~300 ms later).
  • order_failed and widget_close are suppressed until widget_ready has fired, so a provider's init-time close/cancel (expired session, region mismatch) never cancels a fresh order.
ts
window.addEventListener("message", (event) => {
  const msg = event.data;
  if (!msg || msg.type !== "FIAT_WIDGET_EVENT") return;
  if (msg.event === "order_completed" || msg.event === "order_failed") {
    refreshOrderStatus(msg.orderId); // re-fetch GET /order/:id
  }
});

2. Raw provider relay (advanced). Every provider message is also relayed verbatim, tagged by source, for consumers that need the untranslated payload:

ts
type BridgeEnvelope =
  | { source: "tokenflight-fiat"; type: "wrapper_ready"; orderId: string; provider: string }
  | { source: "tokenflight-fiat"; type: "provider_event"; orderId: string; provider: string; data: unknown };

The bridge is for UX hints only

Bridge events (translated or raw) are untrusted relayed provider output — never treat one as proof an order paid, completed, or failed. Authoritative status comes only from GET /order/{orderId} and provider webhooks. Use bridge events to trigger a re-fetch or a spinner; gate fulfillment on the order record. See Lifecycle & webhooks.

provider_widget and redirect

If you get a non-wrapper action, you open the URL yourself — there's no SDK CSP or bridge, and you rely entirely on GET /order/{orderId} + webhooks for status:

ts
switch (action.type) {
  case "wrapper":
    openWrapper(action.url);
    break; // SDK page: CSP + bridge
  case "provider_widget":
    window.open(action.url, "_blank");
    break; // you own framing
  case "redirect":
    window.location.assign(action.url);
    break;
  case "none":
    break;
}

Recovering after a reload

The action is persisted, so a frontend that lost its in-memory state can resume. GET /order/{orderId} re-serves the action — re-open the same wrapper URL:

ts
const order = await fetch(`/v1/fiat/order/${orderId}`).then((r) => r.json());
if (order.action?.type === "wrapper") {
  reopenWrapper(order.action.url); // same /widget/:id URL — resume checkout
}

Branch on order.action?.type, and fall back to a plain status display for terminal orders.