Appearance
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.type | What to do |
|---|---|
wrapper | Open or iframe url — the SDK-hosted page (CSP + event bridge included). This is what you get over HTTP. |
provider_widget | Open the provider's raw widget url yourself (no SDK wrapper). |
redirect | Send the user to url (provider-hosted checkout). |
none | No 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_failedtrack the order.widget_close— the provider widget closed (the wrapper auto-closes a popup ~300 ms later).order_failedandwidget_closeare suppressed untilwidget_readyhas 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.
Related
- Calling the API — the
POST /orderandGET /order/{orderId}shapes. - Lifecycle & webhooks — the authoritative status channels.
- Security — CORS origins and the wrapper's hardening in context.