Skip to content

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];
StatusMeaningTerminal
createdReserved status; a fresh order is persisted as pendingNo
pendingOrder created, awaiting payment / provider pickupNo
processingProvider is processing payment, or (jump) settlement is in flightNo
completedFunds delivered to the recipientYes
failedOrder failed (provider failure, refund, settlement timeout, max attempts)Yes
canceledCanceled before payment (e.g. no payment within the timeout)Yes
expiredExpired unpaid past the overall expiry boundYes

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 shape

GET /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" };
ActionWhat to do
wrapperOpen or iframe url — the SDK-hosted /widget/:orderId wrapper page
provider_widgetOpen or iframe the provider's widget url directly (no SDK wrapper)
redirectNavigate the browser to url (full-page provider checkout)
noneNo 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's partnerOrderId and 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;
OutcomeHTTPWhenProvider retries?
accepted200Event applied (or order already terminal)No
duplicate200Already-seen event for a live order (deduped)No
unknown_order404Verified event, but no matching order found yetYes
rejected400Signature failed, or the provider id is unknownNo
(thrown error)5xxUnexpected failure (e.g. state store error) → INTERNAL_ERRORYes

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 kindGroupAdvances
discover_provider_orderproviderFinds the provider's order id by partner order id; attaches it and schedules polling
poll_provider_statusproviderPolls provider status, maps to canonical, commits; jump processing enqueues reconcile_oda
reconcile_odasettlementRuns the ODA settlement reconciler and commits the resulting patch
reconcile_hyperstream_ordersettlementSame reconcile path (Hyperstream order variant)
recover_stuck_settlementsettlementReserved — not self-scheduled; re-arms a reconcile for a jump order stuck in processing
expire_unpaid_orderexpiryCancels an order still unpaid (pending/created, no provider order) → canceled
expire_stale_orderexpiryUniversal backstop: forces any non-terminal order terminal past the expiry bound
retry_integrator_webhookwebhookPlaceholder 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:

BackstopTriggerResult
Unpaid expiryStill unpaid after noPaymentTimeoutSecondscanceled (no_payment)
Stale-order expiryAny non-terminal order older than orderExpirySecondsPaid → failed; unpaid → expired (order_expired)
Max job attemptsA job retries past maxJobAttemptsForce 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,
};
TimingDefaultRole
discoveryDelaySeconds180Wait before discovering a provider order id
noPaymentTimeoutSeconds1800Cancel an unpaid order after this long
pollInitialSeconds30First provider poll delay
settlementReconcileSeconds15Settlement reconcile cadence
orderExpirySeconds86_400Overall order expiry (stale-order backstop)
quoteGraceSeconds5Clock-skew grace applied to quote expiry
jobLeaseSeconds90Job lease duration for cron
maxJobAttempts200Hard 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;
}
EventPayloadFires 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).

CodeHTTPRetryableWhen it fires
VALIDATION_ERROR400NoInvalid input — bad JSON, bad fields, invalid recipient, or amount-mode conflict
ORIGIN_NOT_ALLOWED403NoBrowser Origin not in the cors.origins allowlist
CONTEXT_REQUIRED400NoA provider requires an end-user context field that was not supplied
QUOTE_INVALID400NoQuote token is not a sealed token, or not found / already consumed
QUOTE_EXPIRED409NoQuote is past expiresAt + quoteGraceSeconds
QUOTE_TOO_LARGE413NoSealed quote token exceeds the max length (switch to a store strategy)
QUOTE_REVOKED401NoThe quote's signing key is unknown or revoked
UNSUPPORTED_ASSET422NoThe provider cannot service the requested asset
NO_ROUTE422NoNo jump route to the target asset (or jump disabled / degenerate route)
PROVIDER_DISABLED403NoProvider id is in the disabledProviders kill-switch
PROVIDER_UNAVAILABLE502YesProvider or Hyperstream API unreachable / returned 5xx
PROVIDER_ERROR502NoReserved provider-side error code: defined and mapped to 502, but not currently emitted by the engine
ORDER_NOT_FOUND404NoUnknown orderId, or no matching route
STATE_CONFLICT409YesOptimistic version-CAS update lost a race
RATE_LIMITED429YesPer-request rate limit exceeded
WEBHOOK_REJECTED400NoReserved: defined and mapped to 400, but a failed webhook currently returns { outcome: "rejected" } (HTTP 400) instead of emitting this code
CONFIGURATION_ERROR500NoMisconfiguration — no providers, missing state store, wildcard CORS, no quote key
NOT_IMPLEMENTED501NoFeature/provider not implemented (e.g. the Coinbase Onramp stub)
INTERNAL_ERROR500YesUncaught / 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