Quotes and orders
Buy and sell across the provider fan-out.
Jump to an operation:
Price a buy, sell or swap — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
orders.write· Test mode: unavailable
Runs the full provider fan-out for one route and returns the best executable offer, every fully priced provider / payment-method offer (offers[]), plus every provider's own terminal result (providers[], D-21) from the existing call, without additional provider requests. This route requires a business key or bearer with orders.write; anonymous quoting remains on the existing public widget transport. Quotes expire; re-quote rather than reusing a stale one, and treat quote_status: indicative as provisional (indicative_as_of). For Transak, include the crypto network (to_network for a buy, from_network for a sell); an unspecified network cannot authorize that checkout. A route with no executable offer is 409 capability_unavailable carrying the dead-end context — never an empty 200 (RESOURCE-MODEL §2.4). To re-quote a specific method a caller (e.g. a payment-options chooser) just selected, call this again with that exact payment_method — the fan-out is pinned to it (BL-46); if the method has no route, the 409 names the reason in message and, structured, as error.details.reason_code (BS-MKT-1) whenever the dead end's reason code is one of the allowlisted reason codes this route names (PAYMENT_METHOD_NOT_SUPPORTED, COUNTRY_NOT_SUPPORTED, LOCAL_CURRENCY_NOT_SUPPORTED, PROVIDER_UNAVAILABLE, PROVIDER_ERROR, PROVIDER_FILTER_MISMATCH, LIMIT_BELOW_MIN, QUOTE_NETWORK_REQUIRED, PROVIDER_PAUSED), with error.details.alternative_methods when the server has one to suggest. Paybis (BL-46, C4-D33): every caller of this route holds orders.write, and .claude/rules/money.md freezes Paybis's own execution path pending review — orders.create has no reviewed way to execute it, so this route never returns Paybis as a selectable offer: providers[] always reports it status: paused, reason: provider_paused, offers[] never includes it, and the winning route is promoted to the next-best offer instead (or 409 capability_unavailable with PROVIDER_PAUSED, when Paybis was the only offer) rather than ever naming it the best route. The legacy public widget transport is unaffected. Country (BSR-6, SEC-02): country, when sent, must be an uppercase ISO 3166-1 alpha-2 code (^[A-Z]{2}$) — a different shape is 400 invalid_request. A well-formed code this gateway does not support, or a comprehensively-sanctioned jurisdiction, is refused before the fan-out with 409 capability_unavailable (COUNTRY_NOT_SUPPORTED), the same vocabulary GET /v1/capabilities?product=buy_sell already uses for country. This is judged even when country is omitted from the body: a caller-forwarded x-vercel-ip-country (or, on the direct Supabase-function host, the Cloudflare-set cf-ipcountry) is the same geoip fallback the fan-out itself would otherwise price with. Both forwarded headers are judged independently and unconditionally — a well-formed value on EITHER one naming a sanctioned jurisdiction is refused even when country names an unrelated, allowed one; neither header can mask the other, and an explicit country cannot mask either header. A header is held to a narrower rule than an explicit country: it is checked only against the sanctioned-jurisdiction list, not the full supported-country catalog, so a header that cannot name a real country (Cloudflare's XX/Tor's T1) or names a real, simply not-yet-catalogued one never causes a refusal on its own. Amount scale (BSR-8): for side: 'buy' only, from_amount is refused with 422 amount_invalid when its fractional part carries more precision than from_asset's own fiat scale — read from packages/config/amountLimits.ts's CURRENCY_PRECISION (2 decimals for most currencies, 0 for the documented zero-decimal list, 3 for the Gulf dinars KWD/BHD/OMR/JOD/TND) — e.g. from_amount: "20.999" for from_asset: "EUR". This is independent of the contract-layer shape bound (up to 18 fractional digits, wide enough for a full-precision crypto amount priced by destination): a value inside that shape can still be finer than its OWN currency can represent, and nothing downstream ever re-rounds it — it would otherwise be sealed verbatim into the deposit instructions on POST /v1/orders, an amount the payer has no way to actually send. A value padded with harmless trailing zeros beyond the scale ("20.990") still quotes normally, as does a real 3-decimal amount for a Gulf dinar ("1.001" KWD). This check is scoped to side: 'buy' because that is the one direction whose from_asset is fiat and whose amount this gateway can seal into a live deposit instruction (a Bridge- native sell's own scale check, orders.create, is a separate crypto-aware one — L1-5) — sell/swap never run THIS check, so an unlisted CRYPTO from_asset (e.g. an ERC-20 not in any decimals table) is never misjudged against a fiat-oriented fallback scale. to_amount is NOT held to this check either: pricing by destination amount routinely uses full on-chain precision for what the caller wants to receive, and nothing seals a raw to_amount into a payer-facing instruction the way from_amount is. Rate (BSR-9): rate — on the winning offer, every offers[] entry, every providers[].offer echo and route_facts.best_price — is derived server-side from each offer's final_in/final_out DECIMAL values, computed BEFORE either is rounded into the published Money fields, the same basis for every offer regardless of provider or side; because the derivation runs pre-rounding, cross-checking rate against the published Money.amount fields can drift in the last digit(s) for an asset whose published decimals are coarser than the server's own internal precision. It is informational only: never rank or compare offers by rate. Compare by final_out (higher is better) when the request supplied from_amount — every offer then targets a DIFFERENT final_out for the SAME pay amount. When the request supplied to_amount instead, compare by final_in (lower is better) ONLY among offers whose final_out equals the requested to_amount — an offer whose final_out differs is not comparable this way, because not every provider honors an exact-output target (a still-open gap: _shared/adapters/coinbase.ts prices a fixed default amount and ignores to_amount entirely, so a Coinbase offer's final_out in this mode can be unrelated to the request).
Headers
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Price a buy, sell or swap — available › Request Body
sideDirection of the trade. swap is crypto-to-crypto; search exists in the underlying contract but is never a public input (RESOURCE-MODEL §2.4, R15 §3).
from_assetSource asset symbol or ISO 4217 fiat code (bestQuoteRequestSchema.fromAsset).
to_assetDestination asset symbol or ISO 4217 fiat code (bestQuoteRequestSchema.toAsset).
from_amount^[0-9]{1,15}(\.[0-9]…Amount to spend, as a decimal string in from_asset's major units — must be strictly positive; the router rejects zero and validates it against the route's resolved minimum (bestQuoteRequestSchema.fromAmount). Exactly one of from_amount / to_amount is given; the router quotes the other side. Bounded by digit count, not a flat character cap (SEC/BSR-5, buy/sell v-fix review 2026-09-17; widened fixer round 1, 2026-09-19) — this string previously had no length cap at all: from_amount "99999999999999999999" (20 digits) reached a live Bridge backend_native offer and echoed back as final_in: "9999999999999999999900" minor units. A flat maxLength: 18 closed that but was itself too narrow: prod quote_attempts (last 30 days, widget-originated but pricing the SAME shape an /v1 destination-amount caller sends) has real rows up to 20 characters, e.g. 0.03853376279604058 (19) and 0.004231894472103885 (20) — full-precision crypto amounts an ERC-20 caller pricing by destination amount routinely sends. The pattern instead bounds the INTEGER part to 15 digits — one above the widest documented per-currency ceiling (CURRENCY_MAX_AMOUNT_MAJOR_UNITS.COP, packages/config/amountLimits.ts, 14 integer digits at 0 decimals) — and the FRACTIONAL part to 18 digits, comfortably covering every real value above while still rejecting the original 20-digit integer abuse case. This is a contract-layer SHAPE bound only, not a value ceiling: the per-offer/per-route VALUE limit (resolved_limits.max, further clamped by a config-owned risk ceiling) is enforced separately in createBridgeNativeOrder (supabase/functions/api-v1/routes/orders.ts) — a 15-digit integer string can still spell an amount far above a route's own limit without tripping a pattern/length check alone. Separately, for side: 'buy' only, POST /v1/quotes also refuses (422 amount_invalid) a from_amount whose fractional part carries more precision than from_asset's OWN scale (BSR-8, getPrecision — packages/config/amountLimits.ts's CURRENCY_PRECISION, the canonical fiat-decimals table .claude/rules/money.md names for every amount limit in this codebase) — e.g. "20.999" for a 2-decimal fiat currency like EUR — a shape this pattern deliberately still admits (18 fractional digits, for a real 18-decimal crypto amount) but that no fiat rail can move: LIVE evidence (dev) showed a Bridge backend_native offer round that exact value to EUR 21.00 internally while sealing the raw "20.999" verbatim into the deposit instructions, instructing the payer to send an amount SEPA cannot carry. A value with harmless trailing zeros beyond the scale ("20.990") is unaffected, as is a real 3-decimal Gulf dinar ("1.001" KWD/BHD/OMR/JOD — CURRENCY_PRECISION lists these at 3, not the naive 2-decimal guess an earlier cut of this fix used). This scale check applies to from_amount only, never to_amount (see that field's own description for why), and to side: 'buy' only, never sell/swap: sell's from_asset is the crypto being sold and swap is crypto-to-crypto, neither a fiat amount CURRENCY_PRECISION has an opinion on — an unlisted ERC-20 (e.g. DAI, 18 decimals) checked against getPrecision's own 2-decimal fallback would be misjudged and refused for a perfectly real amount.
to_amount^[0-9]{1,15}(\.[0-9]…Amount to receive, as a decimal string in to_asset's major units — must be strictly positive, when the caller is pricing by destination amount instead of source amount (bestQuoteRequestSchema.toAmount). Bounded the same way as from_amount (SEC/BSR-5, widened fixer round 1) — see that field's description for why. Deliberately NOT held to from_amount's BSR-8 per-asset scale check: from_amount (or the offer's own final_in derived from it, when priced in receive-mode) is the one figure this gateway ever seals into a live payer-facing deposit instruction, and to_amount is not — no route this API exposes today seals a raw to_amount into anything a payer must send or a rail must move. A caller pricing BY destination amount routinely sends the full on-chain precision of what it wants to receive (prod quote_attempts: to_amount: "0.004231894472103885", 18 fractional digits, for to_asset: "USDC", a 6-decimal token) — the SmartRouter still prices that correctly into a from-leg the provider itself scales properly, so there is no verbatim-sealing hazard on this side to close.
country^[A-Z]{2}$ISO 3166-1 alpha-2 country of the payer, when known — narrows the payment-method fan-out (bestQuoteRequestSchema.country). A well-formed but sanctioned or unrecognized code is refused with 409 capability_unavailable, not accepted and quoted (BSR-6, SEC-02) — checked against the same catalog GET /v1/capabilities?product=buy_sell validates country against.
payment_methodPreferred payment method id, when known (bestQuoteRequestSchema.paymentMethod). Omit to let the router rank every connected method.
from_networkSource chain, for a crypto leg (bestQuoteRequestSchema.fromNetwork).
to_networkDestination chain, for a crypto leg (bestQuoteRequestSchema.toNetwork). Optional here, but a Bridge-native orders.create (handoff_mode: backend_native) requires the WINNING offer's own execution_context.to_network to be set — a quote priced without it cannot become a Bridge-native order (409 capability_unavailable). Set it explicitly for any asset with more than one public chain.
Price a buy, sell or swap — available › Responses
The best executable route, plus the full per-provider fan-out.
id^qt_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectsideThe winning route's provider (D-26).
rateDecimal string, from_asset per to_asset (BSR-9) — the server's own final_in's decimal value ÷ final_out's decimal value for the winning offer, computed BEFORE either is rounded into the published Money fields below (when final_in is absent it equals the request's from_amount), in the SAME two request currencies, computed identically for every offer in this response regardless of provider or side (never a provider's own internal pricing field — Bridge's and Coinbase's own rate math disagree on this basis by side, which is exactly the bug BSR-9 closed: docs/api/CHANGELOG.md). Because rate is derived pre-rounding, rate × final_out.amount (scaled by final_out.decimals) can differ from final_in.amount in the last significant digit(s) when an asset's published Money.decimals is smaller than the server's own internal precision — do not treat that identity as exact. Informational only — NEVER compare offers by rate (rounding/precision differ by asset). Compare by final_out for a from_amount request (every offer targets a different final_out for the same pay amount). For a to_amount request, compare by final_in only among offers whose final_out equals the requested to_amount (lower final_in is better there) — an offer whose final_out differs is not comparable this way; not every provider honors an exact-output target.
What the caller receives (bestQuoteResponseSchema.finalOut).
Fee breakdown, field names from quoteFeesSchema (packages/contracts-exchange/schemas.ts). Each leg converts to a minor-unit Money object at the /v1 boundary per RESOURCE-MODEL §0.3; network_currency (the zod schema's optional currency-of-the-network-fee field) is folded into network.currency rather than published twice.
expires_atQuotes expire — re-quote rather than reusing a stale one.
Field names from quoteOfferSchema.checkoutReadiness — tells the caller how the eventual order will complete.
quote_statusindicative came from a warm cache without running the live fan-out — re-quote live before acting on it once older than the safety window (bestQuoteResponseSchema.quoteStatus).
offer_idOpaque identifier of the winning offer; preserve it verbatim for selection.
payment_method^[a-z][a-z0-9_]*$Canonical method priced for the winning offer. This is requested funding intent, not proof of the eventual received settlement rail. Absent means unknown.
What the caller pays, when it differs from the request amount (bestQuoteResponseSchema.finalIn).
Field names from resolvedLimitsSchema. min/max share one currency rather than each carrying their own Money object, matching the zod source's flat shape verbatim — RESOURCE-MODEL §2.4 lists this field as resolved_limits{min,max,currency,source}, not as two nested Money objects. A1-4 — this schema is ALSO Capabilities.defaults.resolved_limits' shape (a producer outside this item's file ownership); min/max gained a pattern here rather than becoming Money objects, so that other producer's existing plain-decimal output is not broken by a shape change it was never part of fixing.
eta_secondskyc_levelquoteOfferSchema.kycLevel — the identity tier this route requires, when known.
Field names from routeFactsSchema — comparison context for a poor-value or no-KYC-eligible alternative.
indicative_as_ofWhen the indicative price was actually fetched — never "now" (bestQuoteResponseSchema.indicativeAsOf).
Every fully priced offer from the same call, including sibling payment methods and providers. This additive list is the priced method surface; providers[] remains the terminal one-row-per-provider diagnostic summary for backward compatibility. An offer omitted from this list was not safely priced and must not be inferred from capabilities — including when the reason is this gateway being unable to verify that offer's own currency/asset decimal scale (fixer round 1, 2026-09-22), a money reason, not a rate one. If payment_method is absent, the provider did not return a canonical method and the client must not present that entry as a selectable named method.
Terminal outcomes from this call's existing provider offers and diagnostics (D-21, BS-G2). No additional provider requests are made to populate these rows. Reasons are public codes; raw diagnostics and provider payloads are never exposed.
price_change_threshold_bpsThe basis-point move that triggers a re-quote prompt on the client (today a server constant, 50) — D-27.
Read a previously created quote — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
orders.read· Test mode: unavailable
Re-reads a quote by id rather than re-running the fan-out (BL-46) — the exact Quote quotes.create already returned, replayed verbatim from the account-scoped snapshot it took at create time, never re-priced. list is still proposed. 404 covers both an unknown id and one that belongs to a different account — a caller never learns which, RESOURCE-MODEL §0.7. This is the read leg the Review screen's re-read-before-convert relies on (C4-D33): it lets a client fetch the exact quote it is about to act on instead of trusting a value it cached client-side.
path Parameters
id^qt_ · requiredHeaders
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Read a previously created quote — available › Responses
The quote as it was created.
id^qt_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectsideThe winning route's provider (D-26).
rateDecimal string, from_asset per to_asset (BSR-9) — the server's own final_in's decimal value ÷ final_out's decimal value for the winning offer, computed BEFORE either is rounded into the published Money fields below (when final_in is absent it equals the request's from_amount), in the SAME two request currencies, computed identically for every offer in this response regardless of provider or side (never a provider's own internal pricing field — Bridge's and Coinbase's own rate math disagree on this basis by side, which is exactly the bug BSR-9 closed: docs/api/CHANGELOG.md). Because rate is derived pre-rounding, rate × final_out.amount (scaled by final_out.decimals) can differ from final_in.amount in the last significant digit(s) when an asset's published Money.decimals is smaller than the server's own internal precision — do not treat that identity as exact. Informational only — NEVER compare offers by rate (rounding/precision differ by asset). Compare by final_out for a from_amount request (every offer targets a different final_out for the same pay amount). For a to_amount request, compare by final_in only among offers whose final_out equals the requested to_amount (lower final_in is better there) — an offer whose final_out differs is not comparable this way; not every provider honors an exact-output target.
What the caller receives (bestQuoteResponseSchema.finalOut).
Fee breakdown, field names from quoteFeesSchema (packages/contracts-exchange/schemas.ts). Each leg converts to a minor-unit Money object at the /v1 boundary per RESOURCE-MODEL §0.3; network_currency (the zod schema's optional currency-of-the-network-fee field) is folded into network.currency rather than published twice.
expires_atQuotes expire — re-quote rather than reusing a stale one.
Field names from quoteOfferSchema.checkoutReadiness — tells the caller how the eventual order will complete.
quote_statusindicative came from a warm cache without running the live fan-out — re-quote live before acting on it once older than the safety window (bestQuoteResponseSchema.quoteStatus).
offer_idOpaque identifier of the winning offer; preserve it verbatim for selection.
payment_method^[a-z][a-z0-9_]*$Canonical method priced for the winning offer. This is requested funding intent, not proof of the eventual received settlement rail. Absent means unknown.
What the caller pays, when it differs from the request amount (bestQuoteResponseSchema.finalIn).
Field names from resolvedLimitsSchema. min/max share one currency rather than each carrying their own Money object, matching the zod source's flat shape verbatim — RESOURCE-MODEL §2.4 lists this field as resolved_limits{min,max,currency,source}, not as two nested Money objects. A1-4 — this schema is ALSO Capabilities.defaults.resolved_limits' shape (a producer outside this item's file ownership); min/max gained a pattern here rather than becoming Money objects, so that other producer's existing plain-decimal output is not broken by a shape change it was never part of fixing.
eta_secondskyc_levelquoteOfferSchema.kycLevel — the identity tier this route requires, when known.
Field names from routeFactsSchema — comparison context for a poor-value or no-KYC-eligible alternative.
indicative_as_ofWhen the indicative price was actually fetched — never "now" (bestQuoteResponseSchema.indicativeAsOf).
Every fully priced offer from the same call, including sibling payment methods and providers. This additive list is the priced method surface; providers[] remains the terminal one-row-per-provider diagnostic summary for backward compatibility. An offer omitted from this list was not safely priced and must not be inferred from capabilities — including when the reason is this gateway being unable to verify that offer's own currency/asset decimal scale (fixer round 1, 2026-09-22), a money reason, not a rate one. If payment_method is absent, the provider did not return a canonical method and the client must not present that entry as a selectable named method.
Terminal outcomes from this call's existing provider offers and diagnostics (D-21, BS-G2). No additional provider requests are made to populate these rows. Reasons are public codes; raw diagnostics and provider payloads are never exposed.
price_change_threshold_bpsThe basis-point move that triggers a re-quote prompt on the client (today a server constant, 50) — D-27.
List this holder's buy, sell and swap orders — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
orders.read· Test mode: unavailable
transactions.list behind a router (RESOURCE-MODEL §6). status_group is this resource's own bucket set (needs_you, in_progress, done), shared with payouts and payroll runs only — not a single cross-product enum.
query Parameters
limitDefault 25. Out of range is never rejected: a value outside 1..100, a fraction, or a non-number is not a 400 — 0, a negative number, a non-integer or anything non-numeric falls back to the default (25); anything above 100 is capped to 100. A documented clamp, not a silent one; minimum/maximum are deliberately absent from this schema so a generated client (the MCP tool included) does not reject a value the API itself accepts.
cursorOpaque cursor from a previous response's next_cursor.
status_groupHeaders
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
List this holder's buy, sell and swap orders — available › Responses
A page of orders, newest first.
has_morenext_cursorA1-2: required (still nullable) — every producer already emits null rather than omitting the field when there is no next page, so this only removes a third, unused wire state (absent) a strict client had to handle for nothing.
Create an order from a quote — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
orders.write· Test mode: unavailable · Money boundary: per-transaction human confirmation required
Collapses redirect_intent_create → redirect_init → redirect_execute into one idempotent create for an authenticated caller (D-20, BS-G1, BL-47/C4-D33). A Bridge-native order (checkout_readiness.handoff_mode: backend_native) completes in place, landing directly on an active status, for side: buy and side: sell (L1-5) — a swap offer answers 409 capability_unavailable, never created through this endpoint. For a buy, the selected offer's execution_context.to_network must also be set — to_network is optional on POST /v1/quotes, but a quote priced without it cannot become a Bridge-native order (409 capability_unavailable; request a new quote with to_network set).
Hosted provider (L1-6c): every other handoff_mode routes to a hosted-provider checkout — today only Transak (checkout_readiness.handoff_mode: signed_url/hosted_session), and BUY only (a hosted side: sell answers 409 capability_unavailable; a hosted-sell deposit flow does not exist yet). wallet_address is required (400 invalid_request otherwise). The account resolved for this caller must have a verified email on file (409 account_email_unavailable otherwise — email on the request body is never substituted). A 201 carries next_action {type: redirect, checkout_url, expires_at} (OrderSchema.checkout_stage was dropped as a producer-less field, BL-38 #3086 — that hand-off window is expressed through next_action's PRESENCE, not a separate enum) with status: awaiting_payment. A response replayed under the SAME Idempotency-Key is the stored body verbatim, so a replayed next_action.expires_at may already be in the past — check that field (or re-read GET /v1/orders/{id}, which resolves it fresh), do not assume a replay is fresh. Re-create under a NEW Idempotency-Key (L1-6d): a SEQUENTIAL second POST /v1/orders — one that starts after an earlier create for the SAME quote_id + offer_id has already returned or failed — never mints a second provider session for the active order that first create left behind. With a still-open checkout hand-off it returns 201 for that SAME order, its CURRENT next_action, and Idempotent-Replayed: true; with an expired/absent hand-off it answers 409 conflict (code: checkout_expired, error.details.order_id, sideEffectFree: true) — request a new quote and create a new order, there is no way to re-mint a checkout session for an order that already has one. Fixer round 1 (#3/#8) — two creates for the same quote_id + offer_id genuinely CONCURRENT with each other (in flight at once, under different Idempotency-Keys) are not serialized by this check and can each mint their own provider session; reconcile any duplicate via GET /v1/orders. The rolling 24-hour value cap (below) applies in the source fiat; a currency with no explicit ceiling row is refused (409 capability_unavailable) rather than falling back to a Bridge-sized default. On a 503 from this branch with no sideEffectFree marker, a checkout session or a pending order row may already exist — reconcile via GET /v1/orders (error.details.quote_id), do not blindly retry under a new Idempotency-Key.
Destination screening is fail-closed for a buy (both branches): a caller-supplied crypto wallet_address is refused (403) on a deny-list/velocity or sanctions hit, and on an unavailable screen under the default fail-closed policy (503). Paybis is refused (409 capability_unavailable, provider_paused) — .claude/rules/money.md still freezes handleRedirectExecute's Paybis branch, and this route never reaches it (BL-46 already excludes Paybis from every /v1/quotes response; this is defense in depth for a quote created before that shipped). The offer's sealed source amount is also re-checked against its own decimal scale (422 amount_invalid, BSR-8) — for side: 'buy', POST /v1/quotes already refuses a sub-scale from_amount before it can be priced, and this is defense in depth for a quote row minted before that shipped; for side: 'sell' (L1-5), POST /v1/quotes does NOT scale-check from_amount (see that operation's own description — the check is scoped to buy only), so this order-create check is the ONLY scale gate a sub-scale sell amount meets, not a second one. The public-token payer redirect stays a separate, browser-shaped surface and is unaffected by this operation.
Bridge-native sell (L1-5): external_account_id is required — the saved bank destination from GET /v1/wallet/external_accounts, which must belong to this account's own Bridge customer and match the quote's to_asset and the offer's payout rail; a foreign or nonexistent id is 404 (never 403 — RESOURCE-MODEL §0.7), a currency/rail mismatch is 409 capability_unavailable. The selected offer's execution_context. from_network must be set (409 capability_unavailable otherwise; request a new quote with from_network set). wallet_address is refused (400 invalid_request) — Bridge matches the incoming deposit from any sending wallet, so a declared source address would be a promise the server cannot keep. The 201 carries deposit_instructions — the crypto address to send from any wallet you control. The daily and per-order risk ceilings apply in the SOURCE asset — the crypto being sold, not the fiat payout. A sell is refused (409 capability_unavailable) for a payout rail outside the currently supported set (ACH, wire, SEPA, SPEI, Pix, Faster Payments); COP (bre_b) is not yet executable on this path even though POST /v1/quotes can price it. POST/GET /v1/wallet/external_accounts currently list and create only ach, wire, sepa and spei destinations (SPEC §13 D-5), so a Pix or Faster Payments sell needs an external_account_id created outside this API until that surface is widened — the sell itself validates against the ROUTE, not that narrower list, and accepts such an id.
A 400 from this operation can also carry code: 'bridge_execute_rejected' (BSR-12, 2026-09-20) — Bridge rejects the selected offer's execution_context.to_network + wallet address for a quote this operation already priced and sold, because this route's own destination-network resolution never reaches the identical check POST /v1/quotes ran at quote time; a drift between the two surfaces here rather than a caller mistake. The envelope type stays invalid_request (this operation's 400 has no other canonical type); request a new quote and retry — do not treat it as the caller's own malformed request.
Headers
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Create an order from a quote — available › Request Body
quote_id^qt_ · requiredA Quote.id from a prior POST /v1/quotes.
offer_idSelects a specific QuoteProviderResult offer instead of the winning route, when the caller reviewed the fan-out and picked a different provider.
external_account_id^ba_REQUIRED for a Bridge-native sell (execution_context.side: sell on a backend_native offer): the saved bank destination the fiat payout settles to, from GET /v1/wallet/external_accounts. It must belong to this account's own Bridge customer, and its currency and rail must match the quote's to_asset and the offer's payout rail — a mismatch is refused, never reinterpreted. Never accepted on a buy or swap.
wallet_addressCrypto destination for a buy. NOT accepted on a Bridge-native sell (400 invalid_request, param: wallet_address): Bridge matches the incoming deposit from ANY sending wallet, so a declared source address would be a promise the server cannot keep. Send from any wallet you control to the address in the created order's deposit_instructions.
emailF-5: ignored. No path reads this field — a hosted checkout's identity is built from this account's own verified email (public.users), never from the request body, and the internal action this route calls has no email parameter to forward one to. Ignore any value sent here; do not build client logic on it being used. Kept on the wire in case a future provider path needs a caller-suggested address, not because one exists today.
Create an order from a quote — available › Responses
The created order. Idempotent-Replayed is present (true) when this response reconciled onto an existing order — created by a prior attempt under a different Idempotency-Key, recovered by the underlying provider-side idempotency key, OR (hosted provider, L1-6d) resumed by the pre-flight check for an active order already bound to this exact quote_id + offer_id — instead of creating a new one; absent on a genuinely fresh create. A response replayed under the SAME Idempotency-Key is the stored body verbatim, so its next_action.expires_at (when present) may already be in the past — check that field, or re-read GET /v1/orders/{id} for the current answer, rather than assume a replay is fresh.
id^ord_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectstatusA1-2: open — a new transactions.status value is a real, growing set (see K6/BL-36 history below); an unrecognized member is an opaque string, never a deserialization failure. transactions.status verbatim (D-22) — transactions/recovery.ts's ACTIVE_STATUSES + TERMINAL_STATUSES + UNCLASSIFIED_STATUSES vocabulary (19 values), widened from the seven the approved R15 §3 screens draw on (K6 fix: the original enum only covered the seven approved terminal/active screens and rejected every other real transactions.status value as a contract violation). BL-36 (2026-09-16) adds initiated (3 prod rows) and abandoned (1293 prod rows) — the DB status_check constraint has allowed both since 20260708201001_transactions_status_add_refunded.sql, but the vocabulary here and in recovery.ts never caught up: orders.get 500ed on either status (respondValidated rejects a status the enum does not list) and a page of orders.list containing one failed the same way whenever that row also carried a quote_id. abandoned joins TERMINAL_STATUSES; initiated is admitted here for contract purposes only and is deliberately left in recovery.ts's UNCLASSIFIED_STATUSES — prod shows every live initiated row already carries an on-chain tx_hash (one is metadata.lifecycle_stage: 'completed'), so it is a stranded post-broadcast swap the DEX reconciler never picked up, not a start-of-lifecycle "no money moved" state; see recovery.ts's comment for the full writer/reconciler trace. Never conflate with failure_kind — the failed screen's "Status code" must read failure_kind, not this field (BS-G9).
sidequote_id^qt_An opaque reference to the quote this order came from, when one exists. OPTIONAL — ABSENT, never a fabricated id, for an order that has no producer for it (a live write-path gap on some provider-native orders, or a swap that never went through a quote at all; see the API changelog for current prod scope). NOT resolvable via GET /v1/quotes/{id} for a historical order: this value can reference quote_attempts.rid, a different table than the one that endpoint reads, while an order created through POST /v1/orders carries a GET /v1/quotes/{id}-resolvable id instead.
rateDecimal string, from_asset per to_asset, the SAME basis as QuoteOffer.rate (BL-28, BS-10; fixer round 2, P1, independent review 2026-09-20 — round 1 published this on Bridge's own native, opposite basis, which collided with sibling PR BSR-9/#3357's provider-agnostic wire contract). BSR-10 gives this a live producer for a Bridge-native buy order: read rate_basis, below, to know which of two figures this is — never assume one from this field's mere presence. The planned read-time source (transactions.quote_id -> quotes.id -> quotes.offers) is a dead join in production — transactions.quote_id has referenced quote_attempts.rid, not quotes.id, since 20260210100000_fix_transactions_quote_fk.sql, and quote_attempts carries no rate/offers column. BL-48 re-examined this for orders.create's Bridge-native path (BL-47), which prices via api_quotes (a genuinely joinable row, unlike quotes) — still ruled out as a read-time source, since api_quotes is quote-cache infrastructure with its own TTL, and reading it back from orders.get/.list days after settlement, for a row a future cleanup job may have already pruned, would make this field's presence flicker on a purge schedule having nothing to do with the order itself. BSR-10 uses a WRITE-time source instead: createBridgeNativeOrder's execute-transfer call opens a server-sealed route claim and bridge-core.ts derives the persisted rate from THAT claim, inverted onto this wire basis (never from a request-body value), onto the transactions row itself at handoff (baseMetadata) — durable transaction history, never a re-derived read, and never a caller-supplied figure. Once settlement writes the observed to_amount (bridge-webhook/index.ts's transfer.payment_processed handler) AND Bridge's own transfer receipt proves the actual settled source amount, this field is RECOMPUTED as that receipt-proven source amount divided by the observed to_amount — a provider can legitimately re-price between quote and arrival for some corridors, so the settled figure is never assumed equal to the quoted one even when they agree; rate_basis flips to at_arrival in the same response. A row whose receipt never proves that source amount, or that reaches a terminal status without ever settling, publishes neither rate nor rate_basis rather than a stale or unproven figure. Still absent for every other order — a Sell/Swap order, a non-Bridge provider, or a Bridge buy this fix's write-time source never reached (a row created before this deploy) — never guessed.
rate_basisQualifies rate (above; both regimes share the same from_asset per to_asset basis). quoted: the taken offer's quote-time price, published only while the order is still active — the persisted estimate, never the final word; a terminal row without a receipt-proven settled figure publishes neither rate nor rate_basis (fixer round 3, P2, independent review 2026-09-20 — this paragraph had drifted to say the opposite of what buildOrderRateProjection actually gates on; rate's own description above, and RESOURCE-MODEL.md §2.4, already stated the gate correctly). at_arrival: the REALIZED rate, Bridge's own receipt-proven settled source amount divided by the observed amounts.receive once its transfer receipt lands — never assumed equal to the quoted figure, since a provider can legitimately re-price between quote and arrival for some corridors (Bridge's own "rate fixed on arrival" settlement semantics this field exists to name structurally, replacing a copy caveat with a field). Published alongside rate for a Bridge-native buy order (BSR-10); still absent alongside it for every other order, for the identical reason.
A provider's identity and, where the caller is pricing or reviewing capabilities, its registry-published limits — reused verbatim on Quote.provider, Order.provider and QuoteProviderResult (D-26, D-56). Capabilities.providers[] (product=buy_sell) uses CapabilityProvider below — a flat schema that duplicates these fields rather than an allOf extension of this one — so its own status vocabulary never collides with QuoteProviderResult.status, and so composing a second closed schema on top of this closed one never produces an unsatisfiable combined schema (K6b — see CapabilityProvider's own description).
payment_methodThe payment method originally requested for this order (transactions.payment_method, e.g. sepa/card/ach_push), verbatim — BL-48. Not restricted to a fiat leg: a Bridge buy funded from a crypto balance can carry a crypto method here too (e.g. bitcoin/ethereum) — prod carries 184 such rows. Absent when the row predates this column or the value was never set. Distinct from payment_rail: this is the REQUEST, not necessarily what settled — see that field.
payment_railBridge's own OBSERVED settlement rail for a Bridge order (transactions.payment_rail, BL-48) — starts equal to payment_method at creation and is overwritten once bridge-webhook/index.ts's buildBridgeTransferUpdatePayload resolves one (from transfer.created onward, not only once settled), so the two can genuinely diverge. Published ONLY for provider.id: bridge orders (fix-pack, independent review): the same column carries Transak's own private rail vocabulary (order.paymentOptionId, e.g. credit_debit_card) for a Transak order, which is not Bridge's vocabulary and disagreed with payment_method on the method itself on 448 prod rows — publishing it under this Bridge-documented field for another provider would misrepresent what the customer paid with. Never inferred — published verbatim from the column, or absent when it was never set or the provider is not Bridge.
The checkout's own money lines (R15 §4: "You pay" / "You receive"). The exact sub-field set is not yet spelled out beyond this pairing in RESOURCE-MODEL — flagged for confirmation rather than invented further. Fixer round 1 (2026-09-22) — either leg is WITHHELD, never published at an unverified decimal scale, when this gateway cannot verify that leg's currency/asset — see docs/api/CHANGELOG.md's entry of the same date. This rule is identical on every order.* event's own amounts (GET /v1/events, the SSE stream, GET /v1/activity) — each leg withholds independently there too, and amounts is absent from the event only when NEITHER leg resolved, never when just one could not be verified — see docs/api/CHANGELOG.md's L5-FIX-event-money-parity fixer round 1 entry (2026-09-22).
money_stateOPTIONAL — absent when this row has never been classified (BL-38, #3086): the writer is the Paybis/Bridge failure-classification pipeline, gated on failed/cancelled Paybis rows only, so most rows never receive one. When present, read it together with money_status: money_state says whether funds were ever captured, money_status carries refund truth. A row can read status: completed while money_state reads never_authorized (D-10). Never infer a money state from status alone, and never treat its absence as never_authorized — that is exactly the fabricated-default bug this field's optionality fixes.
money_statusDrives the "Money:" line on the terminal screens (R15 §3). Every bank rail resolves to unknown; only a card rail is ever positively not_charged.
failure_kindThe seven normalized failure kinds (R15 BS-G9, prod-inventory §1.4). This is the field the failed screen's "Status code" must read from — never status.
failure_reasonSafe, human-readable copy for failure_kind — never a raw provider error string.
return_reasonWhy a settled leg was sent back, as the provider itself worded it (e.g. Receiver Name Mismatch, Too Many Recent Transactions) — the returned leg is a fiat pay-in on a buy, and either the crypto deposit or the fiat payout on a Bridge-native sell (L1-5). Present only on an order whose money actually arrived and was then returned — distinct from failure_reason, which is OUR safe copy for a failure_kind. Same field and same contract as payments.return_reason on a payment link, so one integration handles both. Provider vocabulary is open-ended and NOT an enum: branch on failure_kind/status, and treat this as display and support text.
What the caller must do next before this order can advance, when anything is required of them at all. Present ONLY while a hosted-provider checkout is open and reachable: the order is in an active status, a hand-off URL exists for it, that URL has not expired, and its host is one this API recognises. ABSENT — never null, never a stale URL — the moment any of those stops being true, including on a Bridge-native order (which completes in place and publishes bank_deposit_instructions or deposit_instructions instead). A caller that receives no next_action on a hosted order whose status is still active must request a new quote; there is no way to re-mint a checkout session for an order that already has one. L1-6b (contract only, no producer yet): every route stays silent on this field until L1-6c/d build the hosted-checkout branch and its resume. Published by orders.create's 201 and GET /v1/orders/{id} only — GET /v1/orders never carries it, for any row.
offer_idThe offer actually executed for this order (transactions.offer_id), verbatim. Normally the offer_id the caller sent, or the quote's winning offer when none was sent; it can differ when the selected offer was not executable at order time and the server fell through to the next-ranked one — see offer_selection. Absent on an order created before this field existed or by a path that records no offer.
Why THIS offer ran, when that is not simply "the one you asked for". Published on the 201 from orders.create only — it is a record of a creation-time decision, and a later GET of the same order republishes offer_id but not this object. Absent when the executed offer is the one the request selected.
Present on a self-custody sell that needs crypto sent to a provider-issued address before payout — the shared DepositInstructions component, same shape as wallet.deposit_intents.deposit_instructions (D-24, BS-G7).
Present on a Buy order awaiting a bank-rail payment — the deposit slip (IBAN/account number, beneficiary, reference) the payer sends fiat to (BL-19). Reuses the shared BankDepositInstructions component verbatim rather than a second same-named shape, so this never drifts from the identical projection payment_sessions/payroll funding already publish. UNLIKE payout_destination below, this is deliberately the full, unmasked provider payload (D-24): the payer must have the real IBAN to actually send funds to it. Bound by the same D-24 rule as every BankDepositInstructions consumer — never logged, never placed in a URL or query string.
Present on a buy, sell or swap once its settlement destination is known (the buy crypto branch, BL-48, added below). The bank branch is masked the same way as payouts.beneficiary's output projection — never the full account number, never a public link or token (D-24); a sell order never itself becomes a payout object. The crypto branch (chain/address/tx_hash, BL-19; buy, BL-48) is exclusive with the bank branch: a self-custody payout is on-chain and has no account number to mask, so address is published verbatim (BS-G7's own deposit_instructions.address precedent). tx_hash on the buy branch is read from the SAME Bridge transfer receipt (bridge_data's receipt.destination_tx_hash) the amounts.fee producer reads (fix-pack, independent review — 66 of 70 completed Bridge buys carry one); absent before the transfer settles or on an older payload shape that predates the field, never guessed.
Read one order's state and money truth — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
orders.read· Test mode: unavailable
status is the public lifecycle. money_state is OPTIONAL — published only when the Paybis/Bridge failure-classification pipeline actually classified this row (BL-38, #3086); when present, read it with money_status: money_state says whether funds were ever captured, money_status carries refund truth — a classified row can read status: completed while money_state reads never_authorized (D-10). An ABSENT money_state means never classified — never read absence as never_authorized or as any other value. Cross-account ids are not_found, never permission_error (D-17).
path Parameters
id^ord_ · requiredHeaders
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Read one order's state and money truth — available › Responses
The order, current as of this read.
id^ord_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectstatusA1-2: open — a new transactions.status value is a real, growing set (see K6/BL-36 history below); an unrecognized member is an opaque string, never a deserialization failure. transactions.status verbatim (D-22) — transactions/recovery.ts's ACTIVE_STATUSES + TERMINAL_STATUSES + UNCLASSIFIED_STATUSES vocabulary (19 values), widened from the seven the approved R15 §3 screens draw on (K6 fix: the original enum only covered the seven approved terminal/active screens and rejected every other real transactions.status value as a contract violation). BL-36 (2026-09-16) adds initiated (3 prod rows) and abandoned (1293 prod rows) — the DB status_check constraint has allowed both since 20260708201001_transactions_status_add_refunded.sql, but the vocabulary here and in recovery.ts never caught up: orders.get 500ed on either status (respondValidated rejects a status the enum does not list) and a page of orders.list containing one failed the same way whenever that row also carried a quote_id. abandoned joins TERMINAL_STATUSES; initiated is admitted here for contract purposes only and is deliberately left in recovery.ts's UNCLASSIFIED_STATUSES — prod shows every live initiated row already carries an on-chain tx_hash (one is metadata.lifecycle_stage: 'completed'), so it is a stranded post-broadcast swap the DEX reconciler never picked up, not a start-of-lifecycle "no money moved" state; see recovery.ts's comment for the full writer/reconciler trace. Never conflate with failure_kind — the failed screen's "Status code" must read failure_kind, not this field (BS-G9).
sidequote_id^qt_An opaque reference to the quote this order came from, when one exists. OPTIONAL — ABSENT, never a fabricated id, for an order that has no producer for it (a live write-path gap on some provider-native orders, or a swap that never went through a quote at all; see the API changelog for current prod scope). NOT resolvable via GET /v1/quotes/{id} for a historical order: this value can reference quote_attempts.rid, a different table than the one that endpoint reads, while an order created through POST /v1/orders carries a GET /v1/quotes/{id}-resolvable id instead.
rateDecimal string, from_asset per to_asset, the SAME basis as QuoteOffer.rate (BL-28, BS-10; fixer round 2, P1, independent review 2026-09-20 — round 1 published this on Bridge's own native, opposite basis, which collided with sibling PR BSR-9/#3357's provider-agnostic wire contract). BSR-10 gives this a live producer for a Bridge-native buy order: read rate_basis, below, to know which of two figures this is — never assume one from this field's mere presence. The planned read-time source (transactions.quote_id -> quotes.id -> quotes.offers) is a dead join in production — transactions.quote_id has referenced quote_attempts.rid, not quotes.id, since 20260210100000_fix_transactions_quote_fk.sql, and quote_attempts carries no rate/offers column. BL-48 re-examined this for orders.create's Bridge-native path (BL-47), which prices via api_quotes (a genuinely joinable row, unlike quotes) — still ruled out as a read-time source, since api_quotes is quote-cache infrastructure with its own TTL, and reading it back from orders.get/.list days after settlement, for a row a future cleanup job may have already pruned, would make this field's presence flicker on a purge schedule having nothing to do with the order itself. BSR-10 uses a WRITE-time source instead: createBridgeNativeOrder's execute-transfer call opens a server-sealed route claim and bridge-core.ts derives the persisted rate from THAT claim, inverted onto this wire basis (never from a request-body value), onto the transactions row itself at handoff (baseMetadata) — durable transaction history, never a re-derived read, and never a caller-supplied figure. Once settlement writes the observed to_amount (bridge-webhook/index.ts's transfer.payment_processed handler) AND Bridge's own transfer receipt proves the actual settled source amount, this field is RECOMPUTED as that receipt-proven source amount divided by the observed to_amount — a provider can legitimately re-price between quote and arrival for some corridors, so the settled figure is never assumed equal to the quoted one even when they agree; rate_basis flips to at_arrival in the same response. A row whose receipt never proves that source amount, or that reaches a terminal status without ever settling, publishes neither rate nor rate_basis rather than a stale or unproven figure. Still absent for every other order — a Sell/Swap order, a non-Bridge provider, or a Bridge buy this fix's write-time source never reached (a row created before this deploy) — never guessed.
rate_basisQualifies rate (above; both regimes share the same from_asset per to_asset basis). quoted: the taken offer's quote-time price, published only while the order is still active — the persisted estimate, never the final word; a terminal row without a receipt-proven settled figure publishes neither rate nor rate_basis (fixer round 3, P2, independent review 2026-09-20 — this paragraph had drifted to say the opposite of what buildOrderRateProjection actually gates on; rate's own description above, and RESOURCE-MODEL.md §2.4, already stated the gate correctly). at_arrival: the REALIZED rate, Bridge's own receipt-proven settled source amount divided by the observed amounts.receive once its transfer receipt lands — never assumed equal to the quoted figure, since a provider can legitimately re-price between quote and arrival for some corridors (Bridge's own "rate fixed on arrival" settlement semantics this field exists to name structurally, replacing a copy caveat with a field). Published alongside rate for a Bridge-native buy order (BSR-10); still absent alongside it for every other order, for the identical reason.
A provider's identity and, where the caller is pricing or reviewing capabilities, its registry-published limits — reused verbatim on Quote.provider, Order.provider and QuoteProviderResult (D-26, D-56). Capabilities.providers[] (product=buy_sell) uses CapabilityProvider below — a flat schema that duplicates these fields rather than an allOf extension of this one — so its own status vocabulary never collides with QuoteProviderResult.status, and so composing a second closed schema on top of this closed one never produces an unsatisfiable combined schema (K6b — see CapabilityProvider's own description).
payment_methodThe payment method originally requested for this order (transactions.payment_method, e.g. sepa/card/ach_push), verbatim — BL-48. Not restricted to a fiat leg: a Bridge buy funded from a crypto balance can carry a crypto method here too (e.g. bitcoin/ethereum) — prod carries 184 such rows. Absent when the row predates this column or the value was never set. Distinct from payment_rail: this is the REQUEST, not necessarily what settled — see that field.
payment_railBridge's own OBSERVED settlement rail for a Bridge order (transactions.payment_rail, BL-48) — starts equal to payment_method at creation and is overwritten once bridge-webhook/index.ts's buildBridgeTransferUpdatePayload resolves one (from transfer.created onward, not only once settled), so the two can genuinely diverge. Published ONLY for provider.id: bridge orders (fix-pack, independent review): the same column carries Transak's own private rail vocabulary (order.paymentOptionId, e.g. credit_debit_card) for a Transak order, which is not Bridge's vocabulary and disagreed with payment_method on the method itself on 448 prod rows — publishing it under this Bridge-documented field for another provider would misrepresent what the customer paid with. Never inferred — published verbatim from the column, or absent when it was never set or the provider is not Bridge.
The checkout's own money lines (R15 §4: "You pay" / "You receive"). The exact sub-field set is not yet spelled out beyond this pairing in RESOURCE-MODEL — flagged for confirmation rather than invented further. Fixer round 1 (2026-09-22) — either leg is WITHHELD, never published at an unverified decimal scale, when this gateway cannot verify that leg's currency/asset — see docs/api/CHANGELOG.md's entry of the same date. This rule is identical on every order.* event's own amounts (GET /v1/events, the SSE stream, GET /v1/activity) — each leg withholds independently there too, and amounts is absent from the event only when NEITHER leg resolved, never when just one could not be verified — see docs/api/CHANGELOG.md's L5-FIX-event-money-parity fixer round 1 entry (2026-09-22).
money_stateOPTIONAL — absent when this row has never been classified (BL-38, #3086): the writer is the Paybis/Bridge failure-classification pipeline, gated on failed/cancelled Paybis rows only, so most rows never receive one. When present, read it together with money_status: money_state says whether funds were ever captured, money_status carries refund truth. A row can read status: completed while money_state reads never_authorized (D-10). Never infer a money state from status alone, and never treat its absence as never_authorized — that is exactly the fabricated-default bug this field's optionality fixes.
money_statusDrives the "Money:" line on the terminal screens (R15 §3). Every bank rail resolves to unknown; only a card rail is ever positively not_charged.
failure_kindThe seven normalized failure kinds (R15 BS-G9, prod-inventory §1.4). This is the field the failed screen's "Status code" must read from — never status.
failure_reasonSafe, human-readable copy for failure_kind — never a raw provider error string.
return_reasonWhy a settled leg was sent back, as the provider itself worded it (e.g. Receiver Name Mismatch, Too Many Recent Transactions) — the returned leg is a fiat pay-in on a buy, and either the crypto deposit or the fiat payout on a Bridge-native sell (L1-5). Present only on an order whose money actually arrived and was then returned — distinct from failure_reason, which is OUR safe copy for a failure_kind. Same field and same contract as payments.return_reason on a payment link, so one integration handles both. Provider vocabulary is open-ended and NOT an enum: branch on failure_kind/status, and treat this as display and support text.
What the caller must do next before this order can advance, when anything is required of them at all. Present ONLY while a hosted-provider checkout is open and reachable: the order is in an active status, a hand-off URL exists for it, that URL has not expired, and its host is one this API recognises. ABSENT — never null, never a stale URL — the moment any of those stops being true, including on a Bridge-native order (which completes in place and publishes bank_deposit_instructions or deposit_instructions instead). A caller that receives no next_action on a hosted order whose status is still active must request a new quote; there is no way to re-mint a checkout session for an order that already has one. L1-6b (contract only, no producer yet): every route stays silent on this field until L1-6c/d build the hosted-checkout branch and its resume. Published by orders.create's 201 and GET /v1/orders/{id} only — GET /v1/orders never carries it, for any row.
offer_idThe offer actually executed for this order (transactions.offer_id), verbatim. Normally the offer_id the caller sent, or the quote's winning offer when none was sent; it can differ when the selected offer was not executable at order time and the server fell through to the next-ranked one — see offer_selection. Absent on an order created before this field existed or by a path that records no offer.
Why THIS offer ran, when that is not simply "the one you asked for". Published on the 201 from orders.create only — it is a record of a creation-time decision, and a later GET of the same order republishes offer_id but not this object. Absent when the executed offer is the one the request selected.
Present on a self-custody sell that needs crypto sent to a provider-issued address before payout — the shared DepositInstructions component, same shape as wallet.deposit_intents.deposit_instructions (D-24, BS-G7).
Present on a Buy order awaiting a bank-rail payment — the deposit slip (IBAN/account number, beneficiary, reference) the payer sends fiat to (BL-19). Reuses the shared BankDepositInstructions component verbatim rather than a second same-named shape, so this never drifts from the identical projection payment_sessions/payroll funding already publish. UNLIKE payout_destination below, this is deliberately the full, unmasked provider payload (D-24): the payer must have the real IBAN to actually send funds to it. Bound by the same D-24 rule as every BankDepositInstructions consumer — never logged, never placed in a URL or query string.
Present on a buy, sell or swap once its settlement destination is known (the buy crypto branch, BL-48, added below). The bank branch is masked the same way as payouts.beneficiary's output projection — never the full account number, never a public link or token (D-24); a sell order never itself becomes a payout object. The crypto branch (chain/address/tx_hash, BL-19; buy, BL-48) is exclusive with the bank branch: a self-custody payout is on-chain and has no account number to mask, so address is published verbatim (BS-G7's own deposit_instructions.address precedent). tx_hash on the buy branch is read from the SAME Bridge transfer receipt (bridge_data's receipt.destination_tx_hash) the amounts.fee producer reads (fix-pack, independent review — 66 of 70 completed Bridge buys carry one); absent before the transfer settles or on an older payload shape that predates the field, never guessed.
Cancel an order before it settles — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
orders.write· Test mode: unavailable
The live writer of status: cancelled (transactions.cancel, D-18). Cancelling cannot claw back money that already moved — check money_state first when it is present (BL-38, #3086: it is OPTIONAL, published only when classified). If money_state is absent, this row was never classified — that is NOT a green light to treat the order as unfunded; there is no refund operation because Swaps never initiates one (RESOURCE-MODEL §0.10). BL-48 narrows the success case beyond status alone: even a status that is otherwise cancellable is refused 409 when money_state has already been classified as hold_placed or captured — money moved or was reserved despite the row not yet having transitioned off a cancellable status (reconciliation lag). A money_state of never_authorized, or its absence (never classified), still cancels normally. BSR-2: for a Bridge-native buy this also asks Bridge to delete the upstream transfer before the local row moves — Bridge rows are never classified into money_state (it is Paybis-only today), so the dominant refusal for one of these orders is 409 cancel_unavailable (Bridge itself refused: funds may already be in flight), not the money_state gate above. A transient Bridge outage answers 503 temporarily_unavailable instead — nothing moved, retry after Retry-After. A payment-link pay-in order answers 409 payment_link_pay_in: it belongs to the payer, cancel the link itself from the Payment Links screen instead. An order that funds a payout, or whose Bridge transfer belongs to a payout, answers 409 cancel_unavailable before any provider call: it is cancelled only through the payout (POST /v1/payouts/{id}/cancel).
path Parameters
id^ord_ · requiredHeaders
Idempotency-Key^[A-Za-z0-9_-]{16,12… · requiredCaller-generated, 16-128 characters of [A-Za-z0-9_-] (a UUID or a base64url token both qualify). The value is part of a primary key, so the shape is enforced, not advisory: outside it the request is 400 invalid_request/idempotency_key_invalid. A replay within 24 hours returns the stored response; a different body under the same key is idempotency_error; a replay of a request that failed after it may have taken effect is 409 idempotency_failed — reconcile, then retry under a NEW key. After 24 hours the key is forgotten and the request is new (D-108).
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
Cancel an order before it settles — available › Responses
The order, now status: cancelled.
id^ord_ · requiredPrefixed, opaque. The prefix is part of the contract; the suffix is never parsed.
objectstatusA1-2: open — a new transactions.status value is a real, growing set (see K6/BL-36 history below); an unrecognized member is an opaque string, never a deserialization failure. transactions.status verbatim (D-22) — transactions/recovery.ts's ACTIVE_STATUSES + TERMINAL_STATUSES + UNCLASSIFIED_STATUSES vocabulary (19 values), widened from the seven the approved R15 §3 screens draw on (K6 fix: the original enum only covered the seven approved terminal/active screens and rejected every other real transactions.status value as a contract violation). BL-36 (2026-09-16) adds initiated (3 prod rows) and abandoned (1293 prod rows) — the DB status_check constraint has allowed both since 20260708201001_transactions_status_add_refunded.sql, but the vocabulary here and in recovery.ts never caught up: orders.get 500ed on either status (respondValidated rejects a status the enum does not list) and a page of orders.list containing one failed the same way whenever that row also carried a quote_id. abandoned joins TERMINAL_STATUSES; initiated is admitted here for contract purposes only and is deliberately left in recovery.ts's UNCLASSIFIED_STATUSES — prod shows every live initiated row already carries an on-chain tx_hash (one is metadata.lifecycle_stage: 'completed'), so it is a stranded post-broadcast swap the DEX reconciler never picked up, not a start-of-lifecycle "no money moved" state; see recovery.ts's comment for the full writer/reconciler trace. Never conflate with failure_kind — the failed screen's "Status code" must read failure_kind, not this field (BS-G9).
sidequote_id^qt_An opaque reference to the quote this order came from, when one exists. OPTIONAL — ABSENT, never a fabricated id, for an order that has no producer for it (a live write-path gap on some provider-native orders, or a swap that never went through a quote at all; see the API changelog for current prod scope). NOT resolvable via GET /v1/quotes/{id} for a historical order: this value can reference quote_attempts.rid, a different table than the one that endpoint reads, while an order created through POST /v1/orders carries a GET /v1/quotes/{id}-resolvable id instead.
rateDecimal string, from_asset per to_asset, the SAME basis as QuoteOffer.rate (BL-28, BS-10; fixer round 2, P1, independent review 2026-09-20 — round 1 published this on Bridge's own native, opposite basis, which collided with sibling PR BSR-9/#3357's provider-agnostic wire contract). BSR-10 gives this a live producer for a Bridge-native buy order: read rate_basis, below, to know which of two figures this is — never assume one from this field's mere presence. The planned read-time source (transactions.quote_id -> quotes.id -> quotes.offers) is a dead join in production — transactions.quote_id has referenced quote_attempts.rid, not quotes.id, since 20260210100000_fix_transactions_quote_fk.sql, and quote_attempts carries no rate/offers column. BL-48 re-examined this for orders.create's Bridge-native path (BL-47), which prices via api_quotes (a genuinely joinable row, unlike quotes) — still ruled out as a read-time source, since api_quotes is quote-cache infrastructure with its own TTL, and reading it back from orders.get/.list days after settlement, for a row a future cleanup job may have already pruned, would make this field's presence flicker on a purge schedule having nothing to do with the order itself. BSR-10 uses a WRITE-time source instead: createBridgeNativeOrder's execute-transfer call opens a server-sealed route claim and bridge-core.ts derives the persisted rate from THAT claim, inverted onto this wire basis (never from a request-body value), onto the transactions row itself at handoff (baseMetadata) — durable transaction history, never a re-derived read, and never a caller-supplied figure. Once settlement writes the observed to_amount (bridge-webhook/index.ts's transfer.payment_processed handler) AND Bridge's own transfer receipt proves the actual settled source amount, this field is RECOMPUTED as that receipt-proven source amount divided by the observed to_amount — a provider can legitimately re-price between quote and arrival for some corridors, so the settled figure is never assumed equal to the quoted one even when they agree; rate_basis flips to at_arrival in the same response. A row whose receipt never proves that source amount, or that reaches a terminal status without ever settling, publishes neither rate nor rate_basis rather than a stale or unproven figure. Still absent for every other order — a Sell/Swap order, a non-Bridge provider, or a Bridge buy this fix's write-time source never reached (a row created before this deploy) — never guessed.
rate_basisQualifies rate (above; both regimes share the same from_asset per to_asset basis). quoted: the taken offer's quote-time price, published only while the order is still active — the persisted estimate, never the final word; a terminal row without a receipt-proven settled figure publishes neither rate nor rate_basis (fixer round 3, P2, independent review 2026-09-20 — this paragraph had drifted to say the opposite of what buildOrderRateProjection actually gates on; rate's own description above, and RESOURCE-MODEL.md §2.4, already stated the gate correctly). at_arrival: the REALIZED rate, Bridge's own receipt-proven settled source amount divided by the observed amounts.receive once its transfer receipt lands — never assumed equal to the quoted figure, since a provider can legitimately re-price between quote and arrival for some corridors (Bridge's own "rate fixed on arrival" settlement semantics this field exists to name structurally, replacing a copy caveat with a field). Published alongside rate for a Bridge-native buy order (BSR-10); still absent alongside it for every other order, for the identical reason.
A provider's identity and, where the caller is pricing or reviewing capabilities, its registry-published limits — reused verbatim on Quote.provider, Order.provider and QuoteProviderResult (D-26, D-56). Capabilities.providers[] (product=buy_sell) uses CapabilityProvider below — a flat schema that duplicates these fields rather than an allOf extension of this one — so its own status vocabulary never collides with QuoteProviderResult.status, and so composing a second closed schema on top of this closed one never produces an unsatisfiable combined schema (K6b — see CapabilityProvider's own description).
payment_methodThe payment method originally requested for this order (transactions.payment_method, e.g. sepa/card/ach_push), verbatim — BL-48. Not restricted to a fiat leg: a Bridge buy funded from a crypto balance can carry a crypto method here too (e.g. bitcoin/ethereum) — prod carries 184 such rows. Absent when the row predates this column or the value was never set. Distinct from payment_rail: this is the REQUEST, not necessarily what settled — see that field.
payment_railBridge's own OBSERVED settlement rail for a Bridge order (transactions.payment_rail, BL-48) — starts equal to payment_method at creation and is overwritten once bridge-webhook/index.ts's buildBridgeTransferUpdatePayload resolves one (from transfer.created onward, not only once settled), so the two can genuinely diverge. Published ONLY for provider.id: bridge orders (fix-pack, independent review): the same column carries Transak's own private rail vocabulary (order.paymentOptionId, e.g. credit_debit_card) for a Transak order, which is not Bridge's vocabulary and disagreed with payment_method on the method itself on 448 prod rows — publishing it under this Bridge-documented field for another provider would misrepresent what the customer paid with. Never inferred — published verbatim from the column, or absent when it was never set or the provider is not Bridge.
The checkout's own money lines (R15 §4: "You pay" / "You receive"). The exact sub-field set is not yet spelled out beyond this pairing in RESOURCE-MODEL — flagged for confirmation rather than invented further. Fixer round 1 (2026-09-22) — either leg is WITHHELD, never published at an unverified decimal scale, when this gateway cannot verify that leg's currency/asset — see docs/api/CHANGELOG.md's entry of the same date. This rule is identical on every order.* event's own amounts (GET /v1/events, the SSE stream, GET /v1/activity) — each leg withholds independently there too, and amounts is absent from the event only when NEITHER leg resolved, never when just one could not be verified — see docs/api/CHANGELOG.md's L5-FIX-event-money-parity fixer round 1 entry (2026-09-22).
money_stateOPTIONAL — absent when this row has never been classified (BL-38, #3086): the writer is the Paybis/Bridge failure-classification pipeline, gated on failed/cancelled Paybis rows only, so most rows never receive one. When present, read it together with money_status: money_state says whether funds were ever captured, money_status carries refund truth. A row can read status: completed while money_state reads never_authorized (D-10). Never infer a money state from status alone, and never treat its absence as never_authorized — that is exactly the fabricated-default bug this field's optionality fixes.
money_statusDrives the "Money:" line on the terminal screens (R15 §3). Every bank rail resolves to unknown; only a card rail is ever positively not_charged.
failure_kindThe seven normalized failure kinds (R15 BS-G9, prod-inventory §1.4). This is the field the failed screen's "Status code" must read from — never status.
failure_reasonSafe, human-readable copy for failure_kind — never a raw provider error string.
return_reasonWhy a settled leg was sent back, as the provider itself worded it (e.g. Receiver Name Mismatch, Too Many Recent Transactions) — the returned leg is a fiat pay-in on a buy, and either the crypto deposit or the fiat payout on a Bridge-native sell (L1-5). Present only on an order whose money actually arrived and was then returned — distinct from failure_reason, which is OUR safe copy for a failure_kind. Same field and same contract as payments.return_reason on a payment link, so one integration handles both. Provider vocabulary is open-ended and NOT an enum: branch on failure_kind/status, and treat this as display and support text.
What the caller must do next before this order can advance, when anything is required of them at all. Present ONLY while a hosted-provider checkout is open and reachable: the order is in an active status, a hand-off URL exists for it, that URL has not expired, and its host is one this API recognises. ABSENT — never null, never a stale URL — the moment any of those stops being true, including on a Bridge-native order (which completes in place and publishes bank_deposit_instructions or deposit_instructions instead). A caller that receives no next_action on a hosted order whose status is still active must request a new quote; there is no way to re-mint a checkout session for an order that already has one. L1-6b (contract only, no producer yet): every route stays silent on this field until L1-6c/d build the hosted-checkout branch and its resume. Published by orders.create's 201 and GET /v1/orders/{id} only — GET /v1/orders never carries it, for any row.
offer_idThe offer actually executed for this order (transactions.offer_id), verbatim. Normally the offer_id the caller sent, or the quote's winning offer when none was sent; it can differ when the selected offer was not executable at order time and the server fell through to the next-ranked one — see offer_selection. Absent on an order created before this field existed or by a path that records no offer.
Why THIS offer ran, when that is not simply "the one you asked for". Published on the 201 from orders.create only — it is a record of a creation-time decision, and a later GET of the same order republishes offer_id but not this object. Absent when the executed offer is the one the request selected.
Present on a self-custody sell that needs crypto sent to a provider-issued address before payout — the shared DepositInstructions component, same shape as wallet.deposit_intents.deposit_instructions (D-24, BS-G7).
Present on a Buy order awaiting a bank-rail payment — the deposit slip (IBAN/account number, beneficiary, reference) the payer sends fiat to (BL-19). Reuses the shared BankDepositInstructions component verbatim rather than a second same-named shape, so this never drifts from the identical projection payment_sessions/payroll funding already publish. UNLIKE payout_destination below, this is deliberately the full, unmasked provider payload (D-24): the payer must have the real IBAN to actually send funds to it. Bound by the same D-24 rule as every BankDepositInstructions consumer — never logged, never placed in a URL or query string.
Present on a buy, sell or swap once its settlement destination is known (the buy crypto branch, BL-48, added below). The bank branch is masked the same way as payouts.beneficiary's output projection — never the full account number, never a public link or token (D-24); a sell order never itself becomes a payout object. The crypto branch (chain/address/tx_hash, BL-19; buy, BL-48) is exclusive with the bank branch: a self-custody payout is on-chain and has no account number to mask, so address is published verbatim (BS-G7's own deposit_instructions.address precedent). tx_hash on the buy branch is read from the SAME Bridge transfer receipt (bridge_data's receipt.destination_tx_hash) the amounts.fee producer reads (fix-pack, independent review — 66 of 70 completed Bridge buys carry one); absent before the transfer settles or on an older payload shape that predates the field, never guessed.
List one order's timeline — available
Status: Available · Callers: business key, agent (business key), dashboard session · Scope:
orders.read· Test mode: sandbox
order.status_changed, .failed, .refunded, .cancelled — lifecycle and money_state together, when money_state is present (it is OPTIONAL, published only when classified — BL-38, #3086) (RESOURCE-MODEL §3, D-92). A view over the K8 outbox (api_events) scoped to this order — there is no separate order-history table, so this reports exactly what the outbox recorded, never a value re-derived from the order's current row. While the api_v1.events outbox is off, this legitimately answers an empty page for an order that has moved through several statuses — it is reporting the outbox, not the order, and it never fabricates an event to fill the gap.
path Parameters
id^ord_ · requiredquery Parameters
limitDefault 25. Out of range is never rejected: a value outside 1..100, a fraction, or a non-number is not a 400 — 0, a negative number, a non-integer or anything non-numeric falls back to the default (25); anything above 100 is capped to 100. A documented clamp, not a silent one; minimum/maximum are deliberately absent from this schema so a generated client (the MCP tool included) does not reject a value the API itself accepts.
cursorOpaque cursor from a previous response's next_cursor.
Headers
Swaps-VersionPins behaviour within /v1 to a dated version; defaults to the key's version; echoed on every response.
Swaps-AccountA1-2. Selects which of the caller's account memberships this request acts on, by Account.id (acct_…), when the credential can reach more than one; the caller's default account otherwise (D-109, supabase/functions/api-v1/auth.ts). Declared on every operation a dashboard session can call (#3561: the runtime caller class, not the formal security list, which names only businessKey on operations both credential classes reach). A value the session is not a member of answers 404 not_found. A business key already resolves to exactly one account (its own) and ignores this header.
List one order's timeline — available › Responses
The order's event timeline, newest first.
has_morenext_cursorA1-2: required (still nullable) — every producer already emits null rather than omitting the field when there is no next page, so this only removes a third, unused wire state (absent) a strict client had to handle for nothing.