Appearance
Order Lifecycle & Webhooks
This page documents the order state machine, how orders progress to a terminal state via webhooks and cron, the idempotency guarantees that make order creation once-only, the lifecycle hooks you can subscribe to, and the full error-code reference.
The SDK API surface used here is the standard one: /config, /quote, /order, /order/{orderId}, /webhook/{provider}, and /widget/{orderId}.
Order lifecycle & statuses
An order moves through a small, closed set of canonical statuses. These are the only values surfaced over the wire (the provider's own raw status is stored separately on the record).
ts
export const FIAT_ORDER_STATUSES = ["created", "pending", "processing", "completed", "failed", "canceled", "expired"] as const;
export type FiatOrderStatus = (typeof FIAT_ORDER_STATUSES)[number];| Status | Meaning | Terminal |
|---|---|---|
created | Reserved status; a fresh order is persisted as pending | No |
pending | Order created, awaiting payment / provider pickup | No |
processing | Provider is processing payment, or (jump) settlement is in flight | No |
completed | Funds delivered to the recipient | Yes |
failed | Order failed (provider failure, refund, settlement timeout, max attempts) | Yes |
canceled | Canceled before payment (e.g. no payment within the timeout) | Yes |
expired | Expired unpaid past the overall expiry bound | Yes |
The terminal set is fixed, and isTerminalStatus is the single source of truth used throughout the engine:
ts
export const TERMINAL_STATUSES: ReadonlySet<FiatOrderStatus> = new Set<FiatOrderStatus>(["completed", "failed", "canceled", "expired"]);processing maps to settling for jump
Providers report a coarse lifecycle state (pending, processing, delivered, failed, canceled, expired, refunded). The engine maps each to a canonical status. The key subtlety: for a jump order, provider delivered only means the intermediate asset landed — Hyperstream settlement to the target asset is still running — so the canonical status stays processing, not completed.
ts
export function providerStateToCanonical(state: ProviderOrderState, strategy: SwapStrategy): FiatOrderStatus {
switch (state) {
case "pending":
return "pending";
case "processing":
return "processing";
case "delivered":
return strategy === "jump" ? "processing" : "completed";
case "failed":
case "refunded":
return "failed";
case "canceled":
return "canceled";
case "expired":
return "expired";
}
}For embeds, the canonical status plus whether a provider order exists yet (hasProviderOrder) is reduced to a coarse progress label. Note that processing surfaces as settling:
ts
export type FiatProgress = "awaiting_payment" | "payment_processing" | "settling" | "completed" | "failed" | "canceled" | "expired";
export function deriveProgress(status: FiatOrderStatus, hasProviderOrder: boolean): FiatProgress {
switch (status) {
case "created":
case "pending":
return hasProviderOrder ? "payment_processing" : "awaiting_payment";
case "processing":
return "settling";
// completed / failed / canceled / expired map to themselves
}
}getOrder vs the GET /order/:id projection
fiat.getOrder(orderId) returns the full persisted record (FiatOrderRecord | null) — every internal field including provider references, ODA addresses, raw provider status, and metadata. Use it for server-side logic.
ts
const order = await fiat.getOrder("fiat_order_01J...");
// FiatOrderRecord | null — full internal shapeGET /order/:orderId returns a deliberately narrower OrderView projection: the canonical status, the coarse progress, the next-step action, a failure classification when present, and the display amounts. It never leaks internal references or ODA addresses.
ts
export interface OrderView {
orderId: string;
status: FiatOrderStatus;
progress: FiatProgress;
provider: string;
swapStrategy: SwapStrategy;
fiatCurrency: string;
fiatAmount: string;
outputAsset: FiatAssetRef;
expectedOutputAmount?: string;
outputAmount?: string;
action?: FiatOrderAction;
failure?: FiatOrderFailure;
createdAt: string;
updatedAt: string;
}expectedOutputAmount is the quoted target amount; outputAmount is the raw base units actually delivered (populated once the order completes).
Order actions
The action tells the frontend what to do after POST /order. There are four shapes:
ts
export type FiatOrderAction =
| { type: "wrapper"; url: string }
| { type: "redirect"; url: string }
| { type: "provider_widget"; url: string; provider: string }
| { type: "none" };| Action | What to do |
|---|---|
wrapper | Open or iframe url — the SDK-hosted /widget/:orderId wrapper page |
provider_widget | Open or iframe the provider's widget url directly (no SDK wrapper) |
redirect | Navigate the browser to url (full-page provider checkout) |
none | No frontend hand-off needed |
When the provider returns a wrapper action and the request carries a wrapper base URL, the engine rewrites it to the SDK wrapper for that order:
ts
if (wrapperBaseUrl) return { type: "wrapper", url: `${stripTrailingSlashes(wrapperBaseUrl)}/widget/${orderId}` };
if (providerResult.widgetUrl) return { type: "provider_widget", url: providerResult.widgetUrl, provider };
return { type: "none" };For wrapper (and provider_widget) actions, open or iframe the returned URL in your frontend. See Jump routes for how direct vs jump settlement differs.
Quote → order → settlement flow
The flow is three stages: get a quote, turn it into an order, then let it progress to terminal.
1. Quote. fiat.quote(input) plans the route and seals the result into an opaque quoteId. The quote is firm and orderable; the quoteId is the only thing you carry forward.
ts
const quote = await fiat.quote({
inputMode: "fiat",
fiatCurrency: "USD",
fiatAmount: "100.00",
outputAsset: { chainId: 8453, address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" },
recipient: "0xc974ae1d5353068641fc713a35f991fa3ff162a9",
});
// quote.quoteId is opaque — by default a sealed AEAD token (no server state)2. Order. fiat.createOrder({ quoteId, metadata }) unseals the token, independently re-validates every security-relevant fact (provider enabled, asset allowlist, amount bounds, recipient address, rate limit), prepares settlement (for jump, builds the ODA deposit), calls the provider with the deterministic orderId as the partner order id, then persists the order, widget, and initial jobs atomically.
ts
const result = await fiat.createOrder({
quoteId: quote.quoteId,
metadata: { integratorOrderId: "cart_123" },
});
// CreateOrderResult { order, orderWidget, existing }ts
export interface CreateOrderResult {
order: FiatOrderRecord;
orderWidget: FiatOrderWidget;
existing: boolean;
}External side effects (the provider call) use the deterministic orderId, so a retry or crash after the provider call cannot create a second provider order. The order is persisted as version: 0, status: "pending".
existing on the SDK result
CreateOrderResult.existing is true when the quote was already consumed and the original order is being returned (see Idempotency). The standard POST /order HTTP response returns { orderId, status, provider, action } and does not echo existing — the dedup is observable as the same orderId coming back.
3. Settlement. After creation, the order progresses toward terminal via two complementary mechanisms — provider webhooks and background cron — both converging on the same state machine. Either can advance the order; both are idempotent.
Idempotency
Order creation is once-only per quote. Two fields sealed into the quote at quote time make this durable:
ts
// sealed into the quote payload
const payload: SealedQuotePayload = {
version: 1,
quoteNonce: newQuoteNonce(),
reservedOrderId: runtime.generateOrderId(),
// ...
};quoteNonce— a once-only key. The atomic create rejects a second order for the same nonce.reservedOrderId— the stable order id reserved at quote time. It doubles as the provider'spartnerOrderIdand the idempotency key, so all external side effects are keyed to it.
The atomic persistence step receives both, and returns the existing order (with existing: true) on a nonce conflict instead of creating — and charging — a second time:
ts
const result = await state.createOrderFromQuote({
quoteNonce: payload.quoteNonce,
reservedOrderId: orderId,
build: () => Promise.resolve({ order, orderWidget, jobs }),
});ts
export interface CreateOrderFromQuoteInput {
/** Once-only key sealed into the quote; guarantees one order per quote. */
quoteNonce: string;
/** Stable orderId reserved at quote time (== orderId == partnerOrderId == idempotencyKey). */
reservedOrderId: string;
build: () => Promise<CreateOrderFromQuoteBuildResult>;
}So a duplicate POST /order carrying the same quote returns the original order rather than double-charging the user. onOrderCreated and quote consumption only run when existing is false.
idempotency-key is an allowed request header
The CORS preflight explicitly allows idempotency-key (alongside content-type and x-integrator-id) so browser clients may send it:
ts
"access-control-allow-headers": "content-type,idempotency-key,x-integrator-id",Webhooks
Providers notify the SDK of payment and delivery events. Point each provider's webhook at:
text
{origin}{basePath}/webhook/transak(For example, with a basePath of /v1/fiat, that is {origin}/v1/fiat/webhook/transak.)
fiat.handleWebhook(provider, request) reads the raw request body itself and verifies before it parses — the provider adapter checks the signature against the raw bytes first, and only a verified event is decoded and applied.
ts
// handleWebhook reads the body for you
async handleWebhook(provider, request, context) {
const rawBody = await request.text();
// ...verify-before-parse happens inside handleWebhook
}Do not read the webhook request body upstream
handleWebhook consumes the raw body to verify the signature. If any upstream middleware reads request.body / request.text() / request.json() first, the stream is already drained and signature verification breaks. Pass the untouched Request straight through.
Verification: Transak
Transak webhook bodies are a single HS256 JWT signed with your Transak access token. The adapter splits the token, recomputes the HMAC over header.payload, compares it in constant time, and only then decodes the payload:
ts
export async function verifyJwtHs256(token: string, secret: string): Promise<Record<string, unknown> | null> {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
const expectedHex = await hmacSha256Hex(secret, `${headerB64}.${payloadB64}`);
// ...constant-time compare; returns null on any mismatch
}A verification failure (or an unknown provider) yields no event, which the engine turns into a rejected outcome.
Outcome → HTTP status mapping
handleWebhook returns a WebhookOutcome, which the HTTP layer maps to a status code chosen so the provider retries exactly when it should:
ts
export type WebhookOutcome = "accepted" | "duplicate" | "unknown_order" | "rejected";ts
const status = result.outcome === "rejected" ? 400 : result.outcome === "unknown_order" ? 404 : 200;| Outcome | HTTP | When | Provider retries? |
|---|---|---|---|
accepted | 200 | Event applied (or order already terminal) | No |
duplicate | 200 | Already-seen event for a live order (deduped) | No |
unknown_order | 404 | Verified event, but no matching order found yet | Yes |
rejected | 400 | Signature failed, or the provider id is unknown | No |
| (thrown error) | 5xx | Unexpected failure (e.g. state store error) → INTERNAL_ERROR | Yes |
unknown_order is intentionally a retryable 404
A webhook can race the create-order commit. If the order is not found yet, the engine does not record a dedup receipt — so when the provider retries after the order exists, the lookup succeeds and the event is applied. Recording a receipt early would drop that retry as a duplicate.
Destination-wallet guard
Before dedup, a verified event that names a delivery wallet is checked against where the order actually delivers — the recipient for direct, the ODA deposit address for jump. On a mismatch the event is acknowledged (accepted, HTTP 200, so the provider does not retry) but not applied, and no dedup receipt is recorded — a corrected re-delivery of the same event can still apply later.
Dedup is deferred until a live, non-terminal order resolves
Deduplication only happens once there is a live order to apply the event to. If the order is terminal, the event is accepted as a no-op; if the order can't be resolved, no receipt is written. Only a live, non-terminal order records a receipt — and a second delivery of the same event then returns duplicate:
ts
if (isTerminalStatus(order.status)) return { outcome: "accepted", order };
const rawBodyHash = await sha256Hex(input.rawBody);
const dedupeKey = `${input.provider}:${event.eventId ?? rawBodyHash}`;
const receipt = await state.recordWebhookReceipt({ dedupeKey /* ... */ });
if (!receipt.inserted) return { outcome: "duplicate" };The event is then applied with an optimistic version-CAS update; a lost race is treated as already-applied (accepted). For a jump order that reaches processing, a reconcile_oda settlement job is enqueued.
Background progression with cron
fiat.cron(options?) claims a lease-protected batch of due jobs and advances each. It is safe to run from many concurrent triggers — claims are leased and every update is version-CAS. Wire it to a Cloudflare cron trigger (or any scheduler):
ts
// Cloudflare Worker scheduled handler
export default {
async scheduled(_event, _env, _ctx) {
const result = await fiat.cron();
// FiatCronResult { claimed, completed, retried, failed }
},
};You may scope a run to specific public cron categories. Each expands to one or more internal job kinds:
ts
export type FiatCronKind = "provider" | "settlement" | "expiry" | "webhook";
export const KIND_MAP: Record<FiatCronKind, FiatJobKind[]> = {
provider: ["discover_provider_order", "poll_provider_status"],
settlement: ["reconcile_oda", "reconcile_hyperstream_order", "recover_stuck_settlement"],
expiry: ["expire_unpaid_order", "expire_stale_order"],
webhook: ["retry_integrator_webhook"],
};fiat.cron({ kinds: ["provider"], limit: 100 }) runs only provider jobs; limit defaults to 50.
What each internal job advances
| Job kind | Group | Advances |
|---|---|---|
discover_provider_order | provider | Finds the provider's order id by partner order id; attaches it and schedules polling |
poll_provider_status | provider | Polls provider status, maps to canonical, commits; jump processing enqueues reconcile_oda |
reconcile_oda | settlement | Runs the ODA settlement reconciler and commits the resulting patch |
reconcile_hyperstream_order | settlement | Same reconcile path (Hyperstream order variant) |
recover_stuck_settlement | settlement | Reserved — not self-scheduled; re-arms a reconcile for a jump order stuck in processing |
expire_unpaid_order | expiry | Cancels an order still unpaid (pending/created, no provider order) → canceled |
expire_stale_order | expiry | Universal backstop: forces any non-terminal order terminal past the expiry bound |
retry_integrator_webhook | webhook | Placeholder no-op (outbound integrator-webhook retries are not yet spec'd); returns done |
When an order is created, the engine enqueues its initial jobs — a poll (or a discovery step if there's no provider order yet), an unpaid-expiry backstop, a stale-expiry backstop, and (for jump) a reconcile:
ts
const jobs: FiatNewJob[] = [
hasProviderOrder
? { kind: "poll_provider_status", runAt: now + t.pollInitialSeconds * 1000 }
: { kind: "discover_provider_order", runAt: now + t.discoveryDelaySeconds * 1000 },
{ kind: "expire_unpaid_order", runAt: now + t.noPaymentTimeoutSeconds * 1000 },
{ kind: "expire_stale_order", runAt: now + t.orderExpirySeconds * 1000 },
];
if (strategy === "jump") jobs.push({ kind: "reconcile_oda", runAt: now + t.pollInitialSeconds * 1000 });Polling and webhooks are complementary and idempotent
Both webhooks and polling write the order through the same version-CAS updateOrder. If a poll and a webhook race, one wins the CAS and the other treats the conflict as already-applied — there is no double-application and no lost update. Webhooks make progression fast; cron guarantees it completes even if a webhook is never delivered.
Backstops
Three independent backstops guarantee every order reaches terminal:
| Backstop | Trigger | Result |
|---|---|---|
| Unpaid expiry | Still unpaid after noPaymentTimeoutSeconds | canceled (no_payment) |
| Stale-order expiry | Any non-terminal order older than orderExpirySeconds | Paid → failed; unpaid → expired (order_expired) |
| Max job attempts | A job retries past maxJobAttempts | Force order failed (max_attempts) and fail the job |
ts
// max-attempts hard backstop
if (outcome.type === "retry" && claim.attempt + 1 >= runtime.timings.maxJobAttempts) {
const fresh = await state.getOrder(claim.orderId);
if (fresh && !isTerminalStatus(fresh.status)) {
await commit(runtime, fresh, { status: "failed", failure: failure("max_attempts", "fiat_api") });
}
return { type: "failed", reason: `max job attempts (${runtime.timings.maxJobAttempts}) exceeded` };
}Timing constants are configurable via createFiat({ timings }); these are the defaults:
ts
export const DEFAULT_TIMINGS: FiatTimings = {
discoveryDelaySeconds: 180,
noPaymentTimeoutSeconds: 1800,
pollInitialSeconds: 30,
settlementReconcileSeconds: 15,
orderExpirySeconds: 86_400,
quoteGraceSeconds: 5,
jobLeaseSeconds: 90,
maxJobAttempts: 200,
};| Timing | Default | Role |
|---|---|---|
discoveryDelaySeconds | 180 | Wait before discovering a provider order id |
noPaymentTimeoutSeconds | 1800 | Cancel an unpaid order after this long |
pollInitialSeconds | 30 | First provider poll delay |
settlementReconcileSeconds | 15 | Settlement reconcile cadence |
orderExpirySeconds | 86_400 | Overall order expiry (stale-order backstop) |
quoteGraceSeconds | 5 | Clock-skew grace applied to quote expiry |
jobLeaseSeconds | 90 | Job lease duration for cron |
maxJobAttempts | 200 | Hard cap before a job force-fails its order |
Lifecycle events (hooks)
Subscribe to state changes via createFiat({ events }). Hooks consume state changes — they never replace or roll back state.
ts
export interface FiatEvents {
onOrderCreated?(event: OrderCreatedEvent): Promise<void> | void;
onOrderUpdated?(event: OrderUpdatedEvent): Promise<void> | void;
onOrderTerminal?(event: OrderTerminalEvent): Promise<void> | void;
onProviderWebhook?(event: ProviderWebhookEvent): Promise<void> | void;
}| Event | Payload | Fires when |
|---|---|---|
onOrderCreated | { order } | A new order is persisted (not on an idempotent retry) |
onOrderUpdated | { before, after } | An order is updated by a webhook or a cron job |
onOrderTerminal | { order } | An update moves the order into a terminal status |
onProviderWebhook | { provider, eventType, order? } | A verified webhook is applied, or arrives before its order exists (order omitted); not fired for duplicates or already-terminal orders |
The exact payload shapes:
ts
export interface OrderCreatedEvent {
order: FiatOrderRecord;
}
export interface OrderUpdatedEvent {
before: FiatOrderRecord;
after: FiatOrderRecord;
}
export interface OrderTerminalEvent {
order: FiatOrderRecord;
}
export interface ProviderWebhookEvent {
provider: string;
eventType: string;
order?: FiatOrderRecord;
}Hooks are not correctness-critical
Hooks fire after the state write. A hook failure never rolls back the order — errors are swallowed and logged:
ts
export async function runHook(fn, onError) {
if (!fn) return;
try {
await fn();
} catch (error) {
onError?.(error);
}
}Use hooks for analytics, fulfillment, and notifications — never for state your correctness depends on. The order record (and GET /order/:id) is the source of truth.
ts
const fiat = createFiat({
// ...providers, cors, state...
events: {
onOrderTerminal: ({ order }) => {
if (order.status === "completed") notifyFulfillment(order.orderId);
},
},
});Error reference
Every failure the SDK surfaces maps to one closed-vocabulary code. The HTTP layer maps each code to a status and wraps it in the WireError envelope.
ts
export const CONTRACT_VERSION = 1 as const;
export interface WireError {
error: {
code: string;
message: string;
details?: unknown;
retryable: boolean;
};
}Every JSON response carries an x-request-id header. CONTRACT_VERSION is the frozen wire-contract version; it is surfaced as contractVersion in the /config response body (it is not attached to every response). An example error body:
json
{
"error": {
"code": "QUOTE_EXPIRED",
"message": "quote has expired; request a new one",
"retryable": false
}
}The retryable flag below is the default; some codes are constructed with an explicit override (for example, a Transak 5xx is raised as a retryable PROVIDER_UNAVAILABLE).
| Code | HTTP | Retryable | When it fires |
|---|---|---|---|
VALIDATION_ERROR | 400 | No | Invalid input — bad JSON, bad fields, invalid recipient, or amount-mode conflict |
ORIGIN_NOT_ALLOWED | 403 | No | Browser Origin not in the cors.origins allowlist |
CONTEXT_REQUIRED | 400 | No | A provider requires an end-user context field that was not supplied |
QUOTE_INVALID | 400 | No | Quote token is not a sealed token, or not found / already consumed |
QUOTE_EXPIRED | 409 | No | Quote is past expiresAt + quoteGraceSeconds |
QUOTE_TOO_LARGE | 413 | No | Sealed quote token exceeds the max length (switch to a store strategy) |
QUOTE_REVOKED | 401 | No | The quote's signing key is unknown or revoked |
UNSUPPORTED_ASSET | 422 | No | The provider cannot service the requested asset |
NO_ROUTE | 422 | No | No jump route to the target asset (or jump disabled / degenerate route) |
PROVIDER_DISABLED | 403 | No | Provider id is in the disabledProviders kill-switch |
PROVIDER_UNAVAILABLE | 502 | Yes | Provider or Hyperstream API unreachable / returned 5xx |
PROVIDER_ERROR | 502 | No | Reserved provider-side error code: defined and mapped to 502, but not currently emitted by the engine |
ORDER_NOT_FOUND | 404 | No | Unknown orderId, or no matching route |
STATE_CONFLICT | 409 | Yes | Optimistic version-CAS update lost a race |
RATE_LIMITED | 429 | Yes | Per-request rate limit exceeded |
WEBHOOK_REJECTED | 400 | No | Reserved: defined and mapped to 400, but a failed webhook currently returns { outcome: "rejected" } (HTTP 400) instead of emitting this code |
CONFIGURATION_ERROR | 500 | No | Misconfiguration — no providers, missing state store, wildcard CORS, no quote key |
NOT_IMPLEMENTED | 501 | No | Feature/provider not implemented (e.g. the Coinbase Onramp stub) |
INTERNAL_ERROR | 500 | Yes | Uncaught / unknown error |
The retryable defaults come from a single set:
ts
const RETRYABLE_CODES: ReadonlySet<FiatErrorCode> = new Set(["PROVIDER_UNAVAILABLE", "RATE_LIMITED", "STATE_CONFLICT", "INTERNAL_ERROR"]);One nuance: when an uncaught exception (not a constructed FiatError) reaches the HTTP layer, the generic INTERNAL_ERROR envelope reports "retryable": false on the wire.
See also
- Jump routes — direct vs jump settlement and the ODA deposit flow
- State & storage — the state store, version-CAS, jobs, and dedup receipts
- Security — CORS, sealed quotes, webhook verification, and trusted IP headers
- API reference — the full request/response schema for every route