Changelog
One entry per Swaps-Version date. Nothing lands here before its contract test passes (API-CANON §12) — mirrors docs/api/CHANGELOG.md in the repository, the source of record.
Unreleased — Partna: a production reference account; the browser checkout no longer relays Partna's words (2026-10-29)
No field, parameter, enum value or error code is added, no Swaps-Version bump (API-CANON §3). feature_flags.partna_quote_breakdown
stays as it is (off): nothing on /v1 changes until the flag is flipped on the founder's word.
- Swaps registers its own Partna production reference account for anonymous quotes (once per isolate, as on staging). With the flag on, a Partna NGN quote is then priced for it; before, production would have quoted no Partna offer.
- The browser checkout's
502when Partna refuses account creation is now Swaps' ownPARTNA_CHECKOUT_UNAVAILABLE, not Partna's text./v1is unchanged (it already answered that code).
Unreleased — Completing a Paybis order clears a failure money_state left by an earlier rejection (2026-10-29)
No new field, no Swaps-Version bump (API-CANON §3): money_state is already OPTIONAL (BL-38).
- A Paybis order rejected and then delivered kept the classification of the rejection, so a delivered order could read
money_state: hold_placedornever_authorized. From this releasepaybis-webhookclears the five failure columns when an order completes; it never writescapturedthere (a server default is not an observation). - History is unchanged: completed Paybis orders from before this release can still read a failure state (on production
at 2026-10-28 23:55Z: 678 buys with a delivery
tx_hash— 661hold_placed, 17never_authorized— and 25 sells without one). They are reconciled separately, together with the evidence the dashboard needs to show them delivered. - Branch on the field's presence, as before: absent means unclassified, never "not charged"; never read money truth
from
statusalone.
Unreleased — WALLET-K5: a key-lost mark outlives the account, and the key-mark lookup joins the first check (wallet phase 1, PR-4a, 2026-10-28)
No new code, no Swaps-Version bump (API-CANON §3). Four answers change at every door that runs the inbound rule
(the payment-link writes, wallet.virtual_accounts.*, the payroll destination routes, payouts, wallet.send and the
orders that settle to a Swaps Wallet):
- The address of an erased account whose wallet key was reported lost stays refused:
422 destination_not_acceptedto every caller, the former holder included — no one owns the address any more, so no one hears the exact code. Before, erasing the account let the address be admitted again. 503 destination_check_unavailable(temporarily_unavailable) now also answers every caller when the key-mark lookup failed: the open key marks are read together with the wallet rows, before anything about the address is known. Before, that failure answered the holder503 destination_risk_unverifiableand anyone else422 destination_not_accepted. Handle it like anydestination_check_unavailable: nothing was changed, retry later.- For any other EVM address, the key marks are now read too. If that read fails, the door answers
503 destination_check_unavailablewhere it used to admit the address (fail closed); at the payer door the same failure is409 link_not_payableandaccepting_payments: false. - A payer session for a link that settles to an erased account's address reads
accepting_payments: false, and a payment on it is refused409 link_not_payable, as for any destination Swaps no longer routes new money to.
Unreleased — A Bridge sell sells the first deposit that arrives, whatever its amount; one open transfer per deposit address, 409 deposit_in_use (G, 2026-10-28)
Additive and a behaviour change, no Swaps-Version bump (API-CANON §3): one new error code (deposit_in_use), and
existing answers gain causes; no field, parameter or enum value is added or removed. A Bridge sell order created from
this release on is a flexible-amount transfer at Bridge, and Swaps' 1 % fee on it is sent to Bridge as a percent:
- Another amount is sold too, in one deposit. The order sells the first deposit that arrives at its address,
whatever its amount, and a later deposit is not added to it. Before, Bridge matched a sell's deposit only when it
equalled
deposit_instructions.amountto the cent, and any other amount failed the sale.amountis still the amount the order was quoted for andamount_is_estimatestaysfalse: send exactlyamount, in one transfer. A deposit below the sell's minimum may not complete the sale; a deposit on another network or without itsmemostill does not, and crypto sent to another address or on another network may be lost. - The payout follows what arrives.
total_fees.swapson a sell quote is still 1 % of the quoted amount, rounded down to the cent; Bridge takes 1 % of the amount that arrives, rounded up to the cent, so the order's receipt can show 0.01 more than the quote's line. On a currency conversion Bridge converts at its own rate at that moment, not the quote's. So the payout can differ from the quote, an exact send included. amounts.payfollows the deposit. On a Bridge sell order,amounts.payis the quoted amount until Bridge reports the deposit it received, then the amount received.- One open sell per network and asset. A second open sell of the same customer on the same network and asset is
409 conflict,order_already_open, whatever its amount; before, only one for the same amount was refused. Bridge reuses the customer's deposit address on a network, and an open transfer there takes the first deposit that arrives, whatever its amount, so two open sells there would be ambiguous.error.details.order_idnames the open order, as before: send to it, or cancel it first. 409 deposit_in_use(conflict, new,sideEffectFree: true): that deposit address is held by something that is not an open sell order, so there is no order to send to or cancel anderror.details.order_idis never set.orders.createanswers it for a sell while a Pay an invoice payout being funded on that network waits for its deposit (error.details.payout_idwhen it can be read: fund that payout, or cancel it, first), while an earlier sell on that network is still being confirmed with Bridge (try again later), or while a Swaps Wallet withdrawal of USDC on Tempo waits for its deposit. It comes before any reservation and before any transfer is created at Bridge.- The other way round.
payouts.fundanswers409 deposit_in_use, before any Bridge call, while a sell of the payer's USDC on the source chain holds the deposit address the funding transfer would use:error.details.order_idnames an open sell order (send to it, or cancel it, first); with no order named, a sell is still being created or confirmed (try again later). Another payout of the payer still open on that chain refuses it too, named byerror.details.payout_id(fund or cancel that payout first); the payout being funded never refuses itself. It answers503 temporarily_unavailable, side-effect free, when that check cannot be read, and it does not ask when the payout's transfer already exists.wallet.offramp_intents.createanswers409 deposit_in_usethe same way while a sell of the holder's USDC on Tempo holds the address, and503 temporarily_unavailable, side-effect free, when the check cannot be read; nothing is created in either case. - A retry of the same sell is not refused by its own unconfirmed attempt. When a sell's creation at Bridge was left
unconfirmed (a timeout or an ambiguous answer), a retry of the same quote, offer and payout account carries on with
that sell instead of being refused; any other sell on that network answers
deposit_in_useuntil it is resolved. - Ceilings. The per-order and daily ceilings are still checked on the quoted amount when the order is created. A larger deposit is still sold, even past a ceiling, and the amount received counts toward the daily ceiling for 24 hours from when it arrived, also when it arrives late in the order's 24 hours.
- Orders created before this release keep their fixed amount until they close (at most 24 hours).
- The descriptions say so:
DepositInstructions.amount_is_estimate,orders.createand its409,Order.amounts.pay,payouts.fundand its409and503,wallet.offramp_intents.create, and theget_order_status,get_payout_funding_instructionsandprepare_bank_withdrawalMCP tools.
Unreleased — WALLET-INBOUND-2: the inbound rule's codes reach every /v1 door, and a payer session says whether its link takes money (wallet phase 0 item 0.7, PR-2, 2026-10-28)
Additive, no Swaps-Version bump (API-CANON §3): one new boolean on PaymentSession (optional in the schema for now), no new error code.
The payment-link writes (create, update with a settlement_destination, activate, the Crypto processing link to
your own wallet included) and wallet.virtual_accounts.create / .reactivate now answer the inbound rule's refusals
under their own codes — 422 destination_rail_keyless, destination_wallet_disabled,
destination_wallet_key_reported_lost, destination_wallet_signer_at_risk, destination_wallet_signer_unconfirmed,
and 503 destination_risk_unverifiable (side-effect free: retry after Retry-After with the same Idempotency-Key)
— instead of a generic invalid_request, internal_error or temporarily_unavailable. The payroll destination
routes, approve and execute keep their 503 under the check's own code too (no Retry-After on execute, which
records the block). Which code a caller hears follows the refusal-code rule: the exact code only to the wallet's own
holder, 422 destination_not_accepted to anyone else (an employer about a recipient's wallet included), and
503 destination_check_unavailable to every caller when the wallet lookup itself failed. PaymentSession.accepting_payments (boolean, sent on every response from this change; not required in the schema until production api-v1 is verified to send it) says whether the
link takes a payment now — false for a closed or expired link and when Swaps does not route new money to the link's
destination, never with a reason; while the destination is the reason, GET .../payments/{payment_id} keeps the
payment's status and what already arrived but returns no instructions.
Unreleased — Partna, switch off: the amount to receive is after Partna's fees (2026-10-28)
Value change, no Swaps-Version bump (API-CANON §3): no field, parameter, enum value or error code changes.
While feature_flags.partna_quote_breakdown is off — as it ships, production today — a Partna offer's final_out on
POST /v1/quotes (to_amount on GET /v1/quote_estimates) was Partna's rate before Partna's fees. It is now what is
left after the fees Partna states, from the deploy of this change: on a buy, 1 % of the naira paid + NGN 50 (rounded
up to the kobo), and its crypto withdrawal fee on the chosen network; on a sell, NGN 100. On 150,000 NGN at
107.263912129403 USDT (1,398.42 NGN per USDT), USDT on tron: total_fees.provider NGN 1,550.00 (155,000 minor
units), total_fees.network NGN 4,195.26 (419,526), total_fees.total NGN 5,745.26 (574,526) and final_out
103.155518 USDT (103,155,518), where it read 107.263912 before. fees on GET /v1/quote_estimates carries the same
figures.
- Partna's fee.
total_fees.providercarries Partna's deposit fee on a buy and its payout fee on a sell, in naira (it was 0);networkis the withdrawal fee, as before;totalis their sum. They are Partna's fees, never a Swaps fee: a Partna offer has noswaps. - Figures Swaps derives from what Partna states, not Partna's own figures for the order, which its checkout shows. A buy or a sell may deliver up to about 1 % less than quoted: Partna has not said whether the 1 % customer-borne merchant fee its dashboard shows comes on top of its stated fees.
- No Partna offer below the amount that clears Partna's fees. A buy is judged on what can be delivered: what
is left, rounded down to the token's decimals, must be above zero and reach Partna's minimum withdrawal on the
network (0.1 USDT or USDC on every network but celo). Below that, Partna's outcome is
no_offerand it names the smallest whole-naira amount that clears at the lowest price Partna's rounded rate allows (so the amount named is not refused again), never one above the route's NGN 5,000,000 maximum (then no amount), so when every provider asked refused the amount,POST /v1/quotesanswers409 capability_unavailablewithdetails.reason_code: LIMIT_BELOW_MIN,param: from_amountanddetails.minimum(BUY-SELL-CANON §1.2), where it answeredPROVIDER_UNAVAILABLE(below the route's NGN 3,000,LIMIT_BELOW_MINwith no figure): NGN 3,000 for USDT on tron at 1,398.42 NGN per USDT, Partna alone asked, names NGN 4,430.00. A sell the NGN 100 takes whole has no offer and names no amount. A quote that givesto_amounthas no Partna offer either (no_offer): Partna is priced only for an amount to pay, as while the switch is on. A withdrawal fee or minimum Swaps holds for the network that is missing or below zero is no figure: no Partna offer for that network (providers[].status: no_offer; it waserror, which counted against Partna for every pair). - Unchanged.
rateis derived fromfinal_inandfinal_out, as for every offer;POST /v1/ordersstill classifies a Partna offerprovider_not_supportedwhile the switch is off; with it on nothing changes — the fees are Partna's own breakdown. - Stored quotes. A quote stored before this change keeps the figures it was answered with.
Unreleased — Wallet.passkey.name: the name the holder's device shows for the wallet's passkey (G, 2026-10-28)
Additive, no Swaps-Version bump (API-CANON §3): one new optional object on Wallet, on GET /v1/wallet/wallets,
GET /v1/wallet/wallets/{id} and POST /v1/wallet/wallets. passkey.name (string | null, at most 64 characters)
is the name the holder's device shows for the wallet's passkey, as recorded when the passkey was registered, so the
holder can find the right one — display only. It is null for every passkey registered before this change (none is
guessed), for a business key (the name is returned to the passkey's owner only, with their own session) and when it
cannot be read at that moment; the wallet is still returned. A replay of POST /v1/wallet/wallets with the same
Idempotency-Key answers passkey: { name: null } (the name is never kept in the replay store; read it with GET).
The name is never written to a log or an error report.
Unreleased — The residence rule decides every rail link: COP refused in the EU/EEA in every direction, an approved individual with no residence on file refused (D-124, architect 2026-10-27)
Behaviour change, no Swaps-Version bump (API-CANON §3): no field, enum value or error code is new. Every refusal
below is D-122/D-124's own 403 permission_error, code: endorsement_required, with
details: {payment_rail, endorsement, next} — nothing is requested from Bridge and no link is minted. On
POST /v1/customers/{id}/verification_links with kind: kyc and a rail:
- COP in every direction. A
bre_borco_bank_transferlink for a resident of the EU/EEA is refused withnext: not_availablewhateverpurposesays —selland absent included. Bridge does not offer COP to EU customers and names no direction. Before, onlypurpose: buywas refused;sellor nopurposeasked Bridge forcopand minted the link. - No residence on file. For an individual whose initial verification is approved and for whom Bridge holds no
residence, a
pixlink withpurpose: buyand a COP link (anypurpose) are refused withnext: provider_review. Every approved individual carries a residence, so its absence is not a fact to send the holder through Bridge's flow on. Before approval an unknown residence still refuses nothing: the link starts identity verification as before. - Every read decides. The rule also runs inside the link request itself, on Bridge's live customer read — so it
decides the reads the route's own check does not: a customer whose initial verification is not approved yet, and a
degraded or stale status read. A
pixlink withpurpose: buyfor a resident outside Brazil, or a COP link for a resident of the EU/EEA, is refused there too. That refusal is published as the same403— never a201, and never the retryable503 temporarily_unavailablethis operation answered before for a refusal it had not made itself. - Unchanged. A
pixlink withpurpose: sellor withoutpurposeis not refused on residence (Bridge pays BRL out to residents of other countries);spei,faster_paymentsandsepaare never refused on residence; an endorsement Bridge has approved is never refused; a business customer is never refused on residence.
Unreleased — Screenings: screened by the screening service itself, and one retryable 503 when a screening cannot run (2026-10-28)
No Swaps-Version bump (API-CANON §3): no field, parameter, enum value or error code changes.
POST /v1/screenings, POST /v1/address_book/{id}/recheck and POST /v1/address_checks now run their screening in
Swaps' screening service, with its own pipeline, cache and OFAC list. Verdicts are computed the same way, with one
difference in what is checked: a Tron address is also checked against TronScan's live fraud flags, so a Tron address
TronScan flags can read riskier, and Tron coverage and confidence can differ. TON coverage and confidence can
differ too: the screening service sends its TonAPI key, so it is rate-limited less. When a screening cannot run —
including when no sanctions source answered — all three answer 503 temporarily_unavailable with Retry-After and
store nothing for the caller (never allow, never not_screenable); POST /v1/screenings and recheck no longer
spend the Idempotency-Key there: retry with the same key after Retry-After. POST /v1/screenings and recheck
refuse an address longer than 128 characters — longer than any address on a network screening covers — with
409 network_not_supported, before anything is screened.
Unreleased — fix: with Partna's switch off, a buy's network fee is published in naira, not as its crypto figure, and a sell carries none (2026-10-28)
Value correction, no Swaps-Version bump (API-CANON §3): no field, parameter, enum value or error code changes.
While feature_flags.partna_quote_breakdown is off — as it ships, production today — a Partna buy's network fee is
Partna's cached withdrawal fee, a crypto figure (3 USDT on tron), and it was published as if it were naira:
total_fees.network and total_fees.total on POST /v1/quotes, and fees.network and fees.total on
GET /v1/quote_estimates, read NGN 3.00 (300 minor units) for a 3 USDT fee. A buy's network fee is now that cached
withdrawal fee stated in naira at the offer's own rate, rounded half-up to two decimals: on 150,000 NGN at
0.000712545068 USDT per NGN (106.8817602 USDT), 3 × 150,000 / 106.8817602 = NGN 4,210.26 (421,026 minor units), in
network and in total.
- A sell carries no network fee. Partna charges no crypto deposit, and the cached figure is its withdrawal fee —
the on-chain leg of a buy — so a Partna sell offer states no network fee and does not depend on that cache.
/v1prices a sell through Bridge alone, so it publishes no Partna sell offer either way. - Unchanged until the 2026-10-28 entry above (which takes Partna's fees off
final_out):final_outwas still the rate's figure before Partna's fees and there was no Partna fee line (provider0).POST /v1/ordersstill classifies the offerprovider_not_supported. With the switch on, nothing changes: the fees are Partna's own breakdown, already in naira. - No price, no offer. A buy whose rate amounts give no positive price carries no Partna offer
(
providers[].status: error), never a default figure. - Stored quotes. A quote stored before this fix keeps the figure it was answered with.
Unreleased — Screenings: the OFAC list in every screening, and no «no sanctions» without a sanctions source (2026-10-27)
No Swaps-Version bump (API-CANON §3): no field, parameter, enum value or error code changes; verdicts do.
POST /v1/screenings, POST /v1/address_book/{id}/recheck and POST /v1/address_checks now check the OFAC list
first, so a listed address reads sanctions: true, risk_level: critical and block. A screening for which no
sanctions source answered is no longer reported as «no sanctions»: POST /v1/address_checks answers
503 temporarily_unavailable (never treat it as allow), and POST /v1/screenings and recheck answer a 5xx and
store no screening — unless another signal blocks the address, which still reads block. A Bitcoin, Tron, Solana or
XRP address is screened and cached exactly as given: a variant of it in another letter case no longer shares its
result. Screening results cached before this change are not reused.
Unreleased — Orders: Partna hosted buys in Nigerian naira by bank transfer, behind a switch that ships off (2026-10-25)
Behaviour change behind a switch, no Swaps-Version bump (API-CANON §3): no field, parameter, enum value or error
code changes. Nothing changes when this ships: the switch feature_flags.partna_quote_breakdown ships off, so a
Partna quote stays priced by Partna's public rate (see Price truth) and POST /v1/orders still answers a Partna
offer with 409 capability_unavailable, code: provider_not_supported. POST /v1/quotes, GET /v1/quote_estimates
and GET /v1/capabilities/buy_sell keep offering Partna for NGN bank_transfer in Nigeria. While the switch is on,
an answer that was 409 is 201 and a Partna offer executes, priced by Partna's fee breakdown:
- Hosted buy. While the switch is on:
201withstatus: awaiting_paymentandnext_action {type: redirect, checkout_url, expires_at},checkout_urlonpay.getpartna.com. As for every hosted buy,wallet_address(oraddress_book_id) and a verified account email are required, and an offer withoutexecution_context.to_networkis409 capability_unavailable. - Sealed amount. While the switch is on, as with OnMeta and Stripe Onramp, the checkout is minted from the quote's
stored offer —
final_inis what the payer is charged,final_outwhat they receive,execution_context.to_networkthe chain — never from the request. Partna prices again when the checkout is opened: a move of 50 bps or more is409 conflict,requote_required(error.details.reason: price_moved,drift_bps,quote_id,order_id), and a checkout Partna cannot prepare is503 temporarily_unavailablewithquote_idandorder_id. Both close that order at once asabandoned(money_state: never_authorized,money_status: not_charged): no checkout reached the payer. - Price truth. While the switch is on, a Partna offer's
final_outis what Partna delivers after its fees — Partna's own fee breakdown for the offer's rate, amount, asset and network, read for every quote — and itstotal_feesare Partna's fees in the fee currency (provider: deposit and merchant fees,network: withdrawal fee); no breakdown means no Partna offer (providers[].status: error), and an amount that does not cover Partna's fees on the chosen network has none either (no_offer). Partna prices each breakdown for one of its accounts: when the checkout opens, the payer's own; a quote has no payer yet, and where no account for quotes is set up the quote carries no Partna offer (no_offer), so no Partna order can be created from it. A quote that givesto_amountcarries no Partna offer either (no_offer): Partna is priced only for an amount to pay, since its fees would come off the amount asked. When the checkout opens, a price 50 bps or more from the quoted one isrequote_required, as above. While the switch is off — today — a Partna offer is priced by Partna's public rate less the fees Partna states (the 2026-10-28 entry above; until thenfinal_outwas the rate's figure before Partna's fees, with no Partna fee line):total_fees.provideris Partna's deposit fee andtotal_fees.networkthe cached withdrawal fee, stated in naira at the offer's own rate (until the 2026-10-28 fix above, the crypto figure itself was published as naira), and a quote that givesto_amountcarries no Partna offer either;POST /v1/ordersclassifies the offerprovider_not_supported. - Checkout URL. While the switch is on, Partna's
checkout_urlcarries no email: the account's verified email reaches Partna only server to server. It carries the wallet address and Swaps' order reference — treat it as personal data. - NGN ceiling. While the switch is on, per account, NGN 15,000,000 over a rolling 24 hours (
422 amount_invalidpast it); an order closed asabandonedwithmoney_state: never_authorizeddoes not count toward it. - No return redirect. Partna's hosted checkout does not send the payer back;
return_urlis validated and not used. Follow the order withGET /v1/orders/{id}: its status comes from Partna's notifications. - Unchanged. A hosted sell is still
409 capability_unavailable(a sell executes through Bridge only), Paybis is stillprovider_paused, andprovider_not_supportedstays a value ofOrder.offer_selection.skipped[].reason(an open enum): it is returned for Partna whilefeature_flags.partna_quote_breakdownis off — as it ships, and whenever it is turned off again — when the Partna offer is priced by the public rate and Partna's stated fees, not by Partna's breakdown.
Unreleased — WALLET-INBOUND-1: one rule for every way new money reaches a Swaps Wallet (2026-10-27)
Additive, no Swaps-Version bump (API-CANON §3): two new error codes, and existing answers gain causes. Every
operation that can route new money to a Swaps Wallet address now admits it exactly as Receive does — only on Tempo,
and only with a key Swaps can confirm — before anything is written and before any provider call. A new wallet whose
key derives its address is confirmed, whether or not it has ever signed; nothing changes for any other address.
422 destination_wallet_signer_unconfirmed(invalid_request, new): «This Swaps Wallet's key could not be confirmed, so new money can't be routed to it. Choose another destination.» Answered byorders.create, the payroll destination routes (payroll_recipients.adopt_pending_destination,payroll_runs.approve,payroll_runs.execute,payroll_recipient_sessions.destination.set) andpayouts.fundfor arefund_address; the payment-link writes andwallet.virtual_accounts.create/.reactivateanswer it as422 invalid_requestwith the same message.422 destination_swaps_deposit_address(invalid_request, new):wallet.send_intents.createrefuses a recipient that is a deposit address Swaps issued — not a wallet. A Swaps Wallet address as the recipient is422 destination_rail_keyless(a Swaps Wallet receives on Tempo only);503 destination_risk_unverifiablewhen that could not be checked.orders.create: a buy into a Swaps Wallet address answers422 destination_rail_keyless(another network, or a hosted offer with no network bound),destination_wallet_disabled,destination_wallet_key_reported_lost,destination_wallet_signer_at_riskordestination_wallet_signer_unconfirmed(param: wallet_address), and503 destination_risk_unverifiablewhen the check could not run.- Payroll and
payouts.fund'srefund_addressnow also refuse, on Tempo, a disabled wallet (422 destination_wallet_disabled) and a wallet whose signer is at risk (422 destination_wallet_signer_at_risk); before, they refused only another network and a key reported lost. - A payer (
payment_sessions.payments.create) gets422 link_not_payablewhen the link's settlement wallet cannot take new money — never the merchant's reason — andGET /v1/payment_sessions/{token}then shows no payable address (pending_payment: null,underpaid_payment: null,crypto_relay.deposit_instructions: null).
Contract tests: supabase/functions/_shared/services/wallet_signer_risk.test.ts (the rule, every refusal, the
never-used wallet), supabase/functions/_shared/services/__tests__/inbound_doors.test.ts (a reported loss refuses at
every door; a healthy wallet passes every door), supabase/functions/swaps/__tests__/{bridge-buy,redirect-intent}-inbound-guard.test.ts,
supabase/functions/payment-links/__tests__/crypto_relay_rail.test.ts (the payer), supabase/functions/payroll/keyless_destination.test.ts,
supabase/functions/api-v1/__tests__/routes_payouts_refund_address.test.ts and supabase/functions/wallet-send/send_destination.test.ts.
Unreleased — WALLET-KEY-LOST-1: a Swaps Wallet whose key was reported lost takes no new money (2026-10-26)
Additive, no Swaps-Version bump (API-CANON §3): one new error code, and two existing answers gain a cause. When a
holder tells Swaps support that their wallet's key is lost, support records it on their case (an audited operator
action, dry run first). From then on Swaps refuses to point new money at that wallet's address, before any provider
call. The funds already there do not move, and sending from the wallet stays open.
422 destination_wallet_key_reported_lost(invalid_request, new): «This Swaps Wallet's key was reported lost, so new money can't be routed to it. Choose another destination.» Answered bypayroll_recipients.adopt_pending_destination,payroll_runs.approve,payroll_runs.executeandpayroll_recipient_sessions.destination.setfor a destination that is that wallet's address on Tempo (on any other network a Swaps Wallet address is422 destination_rail_keyless, as before; payroll checked only the network until now), and bypayouts.fundfor arefund_addressthat is that wallet's address while no funding chain is chosen (on a chosen chain a Swaps Wallet address is422 destination_rail_keylessfirst), and bywallet.virtual_accounts.createandwallet.virtual_accounts.reactivatefor an account that would pay into that wallet (no account is created or reopened). Nothing is written, and the same request keeps the same answer. Thepayment_linkscreate, update and activate writes refuse the same destination as422 invalid_requestwith the same message, as they do the guard's other wallet refusals.422 wallet_signer_at_risk(wallet.deposit_intents.create,wallet.deposit_quotes.create) also covers a wallet whose key was reported lost, and its message then says so: «Receiving is paused on this wallet. Its key was reported lost, so do not send funds to it. Nothing was received.» Thecreate_deposit_addressMCP tool says to stop there: no retry, no other network, and not the wallet's own address either.wallet_paused(payouts.fundwithfunding_source: swaps_wallet,payouts.wallet_funding_quote.get, andfunding_sources[swaps_wallet]ofGET /v1/capabilities?product=payouts) also covers that wallet.- The record is cleared only after a newer signature by the same key (a key check or a confirmed send); until then each answer above stands.
Contract tests: supabase/functions/_shared/services/wallet_signer_risk.test.ts (the reason, its order and the
refusal), supabase/functions/payroll/keyless_destination.test.ts (payroll asks for it by name),
supabase/functions/api-v1/__tests__/routes_payroll_recipients_adopt.test.ts, routes_payouts_refund_address.test.ts
and routes_wallet_virtual_accounts.test.ts (the /v1 answers), routes_payouts_wallet_funding.test.ts
(wallet_paused), supabase/functions/wallet-funding/handler.test.ts (the Receive message) and
packages/mcp-tools/__tests__/changelog-docs-drift.test.ts (the code on the errors, payroll and wallet pages, the tool
naming wallet_signer_at_risk).
Unreleased — verification=snapshot: account status from Swaps' stored provider check, with the time it was checked (2026-10-22)
Additive, no Swaps-Version bump (API-CANON §3). AccountReadiness, SetupGuide, Customer and Capabilities carry
verification_checked_at: when the verification facts behind the answer were checked with the provider. GET /v1/account (its ?expand=), /v1/account/readiness, /v1/account/setup_guide, /v1/customers,
/v1/customers/{id} and /v1/capabilities take verification (live by default, or snapshot): snapshot answers
from Swaps' stored check while it is no more than 24 hours old, reads the provider live otherwise, and answers
503 temporarily_unavailable — never an older status — when that live read cannot reach the provider. Use it only to
display status: every money operation checks the provider live itself.
Unreleased — The two kill switches name themselves: 503 temporarily_unavailable carries error.details.reason (2026-10-21)
Additive, no Swaps-Version bump (API-CANON §3): one new key in error.details of an existing error. A resource's own
switch (api_v1.<resource>) answers 503 temporarily_unavailable with details.reason: "resource_disabled", the global
api_v1 switch with details.reason: "api_disabled"; every other 503 (a failed read of the switches, a rate limiter
or a dependency that is down) carries neither, so an off switch can be told from an outage. Retry-After: 60 is
unchanged.
Unreleased — A coin on one network only gets a Transak price without to_network; Transak's own network names (2026-10-21)
Behaviour fix, no Swaps-Version bump: answers that were 409 can now be 200; no field, parameter or error code
changes.
- A buy of a coin on one network only, without
to_network(POST /v1/quotes,GET /v1/quote_estimates) — of those, Transak's currency list carries BTC, XRP, SOL, BNB, ADA, DOGE, LTC, TRX, XLM, ATOM, NEAR, DOT, APT and SUI. Transak is asked for its price on that network and its checkout is bound to the same one. Before, the answer could be409 capability_unavailablewithQUOTE_NETWORK_REQUIRED(US · USD → BTC · card among them). A network the request names always wins. - A coin on several networks is unchanged (USDT, USDC, ETH…): without its network the answer stays
409 capability_unavailable,QUOTE_NETWORK_REQUIRED, namingto_network, when no other offer remains. - Transak's network names: TRX, XLM, ATOM and NEAR reach Transak on
mainnet, the name its own currency list gives them, instead oftron,stellar,cosmosandnear; a token on those chains keeps the chain name (USDT ontron).
Unreleased — Bridge sells start at Bridge's payout minimums (Pix 20 BRL, SPEI 50 MXN); Transak is not a Pix provider (G, 2026-10-21)
No Swaps-Version bump (API-CANON §3): no new field, error code or status. A sell that Bridge's documented minimum
refuses is no longer priced, and one capability cell no longer claims a route its provider cannot price.
POST /v1/quotesandGET /v1/quote_estimates,side: sell, BRL bypix. Bridge pays Pix out from 20 BRL (its BRL guide: «Offramp minimum 20 BRL»); before, a sell was priced from the route's 2.02 USDC (about 10 BRL). Below it the answer is409 capability_unavailable,LIMIT_BELOW_MIN:error.details.minimumis 20.00 BRL when the amount clears the route's source minimum (ato_amountof 15 BRL, or 3 USDC), else the amount of the asset sold that clears both at the quote's rate (4.062750 USDC at 4.9725 BRL per USDC). That amount is priced.- The same for
side: sell, MXN byspei. Bridge pays SPEI out from 50 MXN (its rail-specific page: «Minimum: 50 MXN»); before, a sell was priced from the same 2.02 USDC (about 37 MXN). Below it:LIMIT_BELOW_MINwithdetails.minimum50.00 MXN (2.03 USDC, or ato_amountof 37 MXN), or the amount of the asset sold that clears both (2.743722 USDC at 18.4075 MXN per USDC). POST /v1/orders, a Pix or SPEI sell. The order re-quotes Bridge at execution; a quote priced under the payout minimum before this change, or one the rate has since moved under it, answers409 requote_requiredbefore any transfer.GET /v1/capabilities/buy_sell?side=sell.sell_coverage[].assets[].minstays the route's source minimum (2.02020203 USDC for Pix and SPEI): the snapshot reads no rate, so the payout floor is held by the quote (description updated).assets[].reason: coverage_unconfirmedis not produced.GET /v1/capabilities/buy_sell?country=BR. Thepix×transakcell readsblocked/method_unsupported(wasselectable_background): Transak's own payment options list no Pix and it never priced one. Bridge'spixand Transak'scard,apple_pay,google_payare unchanged, and so are the defaults. A BR buy priced by Pix (payment_method: pix) no longer asks Transak, so an amount under Bridge's 20.21 BRL floor is named (LIMIT_BELOW_MIN,details.minimum20.21 BRL) instead of being left unnamed by Transak's non-live answer; a fan-out over every method still names it only if Transak's card refusal names a limit too (it reports none).
Contract tests: supabase/functions/_shared/services/__tests__/bridge_service.test.ts («Pix sell», «SPEI sell»),
supabase/functions/best-quote/sell-capabilities.test.ts, supabase/functions/best-quote/capabilities.br-pix.test.ts,
supabase/functions/best-quote/amount-verdict.br-pix.test.ts.
Unreleased — Quotes: an amount outside every provider's limit is answered as the amount, with the bound (2026-10-21)
Additive, no Swaps-Version bump: two new optional error.details keys and param on the existing
409 capability_unavailable, one new reason_code value. No new error code, no new HTTP status.
POST /v1/quotesandGET /v1/quote_estimates. When every provider asked refused the amount on its own limit,paramis the amount field (from_amountorto_amount) anderror.details.reason_codeisLIMIT_BELOW_MINorLIMIT_ABOVE_MAX(new), with the bound asMoney:error.details.minimum(the smallest minimum, rounded up) orerror.details.maximum(the largest maximum, rounded down) — the figure from which, or up to which, a provider prices. Change the amount, not the route.- Only an answer counts. If any provider asked failed, timed out or answered for another reason, the amount is not
claimed: an outage stays
503 temporarily_unavailable. Refusals in different currencies name the reason without a figure. - A provider that refused the amount on its own limit reads
status: no_offerinproviders[], noterror. - Bridge Pix buys start at 20.21 BRL (Bridge's documented 20 BRL minimum, grossed up by the developer fee). An order
quoted under it is refused with
422 amount_invalidbefore anything is reserved or sent. defaults.resolved_limitsonGET /v1/capabilities/buy_sell: Brazil 20–500,000 BRL, Mexico 50–500,000 MXN, Colombia 100–5,500,000 COP — what Bridge, the one provider pricing Pix, SPEI and Bre-B there, takes.
Unreleased — Payroll: a USD pay run pays a recipient's local bank account in BRL, MXN or COP (D-126, 2026-10-20)
Additive, no Swaps-Version bump: new optional response fields, one new optional request field, one new error code.
Enabled per payer and per rail (Pix, SPEI, Bre-B); a USD run's local payout is one conversion, by the provider, at
payout — a run in another currency still pays no bank account (bank_payout_fx_unsupported).
capabilities.bank.local_currencieson the recipient session: each local account offered (currency,rail,enabled,reason, a labelledfx_estimateand the provider'sminimum); absent when none is.bank.bre_b_key(new): withpix_keyandclabe, a local account takesaccount_holderand the key only. A currency the session does not list answers409 capability_unavailable.409 local_payout_below_minimum(new): below the provider's minimum at today's rate — at save, approve or execute (nothing created, provisioned or queued);error.details:basis,minimum,minimum_currency,item_id.local_payoutonPayrollRunItemand the recipient session: theestimatebefore payout and exactly what the provider's receipt says wasdeliveredafter.- A provider
undeliverablenow readsfailedon a payroll item.
Unreleased — COP bank transfer (PSE) through Bridge on /v1: paid on Bridge's hosted page via next_action (D-123, 2026-10-20)
Additive, no Swaps-Version bump: a new optional request field and query parameter, Order.next_action newly present
on one Bridge-native case, one new provider_error code.
hosted_handoff(new, optional):POST /v1/quotesbody (boolean) andGET /v1/quote_estimatesquery (true/false).truedeclares a caller that orders throughPOST /v1/ordersand followsnext_actionto a provider-hosted page, a Bridge-native order included; only then are offers priced whose payment can only be completed there — today a Bridge buy by COP bank transfer, PSE (co_bank_transfer). Absent orfalse: no such offer, every other offer unchanged. A COP estimate withhosted_handoff=trueis cached and kept warm in its own scope, so it is served from the pre-warmed cache like any other market (priced live only on a miss); every other estimate is unchanged.POST /v1/orders, Bridge-native buy onco_bank_transfer(Colombia's PSE). Offered on/v1(the buy widget v2 sendshosted_handoff: true); Swaps' legacy web and iOS checkouts don't offer it until they support the hand-off. The capability grid keeps the Bridge ×co_bank_transfercellblocked/route_disabled(one answer for every client). It needs the customer'scopapproval.return_urlis required for it — its absence is400 invalid_request,param: return_url, before anything is read, reserved or sent. The201carries nobank_deposit_instructions; it carriesnext_action(type: redirect,checkout_url= Bridge'sstart_url,expires_at= Bridge's), andGET /v1/orders/{id}keeps publishing it while the order ispending/awaiting_paymentand the page has not expired.- The D-123 exception to the hosted-checkout host rule.
next_actionotherwise names a provider host this API recognises; Bridge's PSE page host is not known before the first real order, and the URL comes from Bridge's own server-to-server response, never from the caller. It is published when it parses, ishttpsand carries no user name or password — on any host. 502 provider_error,code: provider_handoff_invalid(new): the Bridge transfer for a PSE buy was created, but the page Bridge returned is not publishable.error.details.order_idnames the order; not side-effect-free — reconcile withGET /v1/orders/{id}, do not retry under a new Idempotency-Key. A Bridge5xxon the transfer itself stays the existing Bridge-native503.
Unreleased — A Bridge buy on Pix or COP answers not_available up front from the customer's residence (D-124, 2026-10-20)
Additive, no Swaps-Version bump: one new optional request field. A refinement of D-122's next:
POST /v1/orders, Bridge-native buy. Apixbuy for a resident outside Brazil, and abre_b/co_bank_transferbuy for a resident of the EU/EEA, answer403 permission_error,code: endorsement_required,next: not_availableat once — Bridge grants a Pix onramp to Brazilian residents only and does not offer COP to EU customers. Before,nextwasverifyuntil Bridge itself refused, which it does only after the person has gone through its verification.CustomerVerificationLinkRequest.purpose(new, optional,buy|sell, only besiderail— otherwise400 invalid_request,param: purpose) onPOST /v1/customers/{id}/verification_links: what the rail-scoped KYC link is for. Withpurpose: buythe same residence rule refuses the link —403 endorsement_required,next: not_available, nothing requested and no link minted. On apurpose: buylink thepixpayout side (pix_offramp/pix) does not count: onlypix_onrampdecides. Absent orsell, the link is decided exactly as before (a payout onpixis open to residents of other countries).- Unchanged: an endorsement already approved is never refused on residence; a business customer, a customer whose
residence is not known, and
spei,faster_paymentsandsepa. - Changed since (2026-10-27, the entry at the top): a COP link is refused in the EU/EEA whatever
purposesays, an approved individual with no residence on file is refused apurpose: buyPix link and any COP link, and the rule also runs on the link request's own live read.
Unreleased — No operation asks for a terms acceptance (2026-10-20)
No field or operation changes; no Swaps-Version bump. People accept the Terms of Service when they sign up for Swaps, and Swaps does not ask them again. Nine live operations checked a terms acceptance before their handler ran — POST /v1/payment_links, POST /v1/payment_links/{id}/activate, POST /v1/subscriptions, POST /v1/subscriptions/{id}/resume, POST /v1/payouts, POST /v1/payroll_runs, POST /v1/wallet/send_intents, POST /v1/wallet/deposit_intents and POST /v1/orders — and could answer 409 capability_unavailable, code terms_acceptance_required, or 503 temporarily_unavailable, code legal_acceptance_unavailable, when the check could not be made. Neither code is returned any more; both stay in the error registry, because a published code is never removed. Every other refusal of those operations is unchanged. The hosted MCP server's instructions no longer carry a terms rule, and @swaps/sdk's SwapsApiError.requiresTermsAcceptance is deprecated: it is false for every current response.
Unreleased — Pix reads its own direction everywhere; BRL/MXN payroll funding checks the deposit approval; BankDepositInstructions.br_code (D-125, 2026-10-20)
Additive, no Swaps-Version bump: one new optional response field; new values of existing open codes and refusals
under existing error codes. Bridge split Pix into pix_onramp (deposits, Brazilian residents only) and pix_offramp
(payouts); the legacy pix now counts only while Bridge publishes no entry of the direction a check reads.
BankDepositInstructions.br_code(new, optional): the Pix pay code («Pix Copia e Cola») a BRL virtual account returns in place of apix_key— published onPayrollFundingInstructions.bankfor BRL payroll funding.- Payroll funding in BRL (Pix) or MXN (SPEI). Before the funding account is requested, the employer's deposit
approval at Bridge is read. Not in place:
status: blockedwithfailure_reasonfunding_endorsement_required,funding_endorsement_in_revieworfunding_rail_not_available(retryable: false); unreadable:status: failed,failure_reason: funding_endorsement_unverifiable. USD, EUR and GBP funding are unchanged. - Payment links: Pix needs the merchant's Pix deposit approval, for every merchant. A payer session offers
pixonly when the merchant'spix_onrampis approved; otherwiseunavailable_railsnames itmerchant_rail_not_enabledand paying bypixanswers400 rail_not_allowed.payment_links.activaterefuses a draft whose only payer rail would be such a Pix:422 no_payable_rail,error.details={ "rail": "pix", "reason": "merchant_rail_not_enabled" }. - Payouts and sells on Pix read
pix_offramp: a holder with onlypix_offrampapproved is ready; a stalepixbeside an incompletepix_offrampis not.
Unreleased — A Bridge buy on a rail the customer is not approved for names the next step (D-122, 2026-10-20)
Additive, no Swaps-Version bump: one new error code, two new request enum values, one new response field.
POST /v1/orders, Bridge-native buy. A buy onpix,spei,bre_b,faster_paymentsorsepareads the customer's live Bridge approval for that rail (pix_onramp,spei,cop,faster_payments,sepa) before the amount checks, the daily cap and any Bridge call. Without it:403 permission_error,code: endorsement_required,error.details: {payment_rail, endorsement, next};nextisverify,accept_terms,complete_profile,provider_reviewornot_available. Before, the order reached Bridge, reserved against the daily cap for 180 s, and came back403 permission_error«Not permitted to create this order» with nothing to act on. USD rails (ach_push,wire) are not pre-checked; when Bridge itself refuses a buy's rail at transfer time, that refusal now answers the same code (next: verifyunless Bridge named the step,endorsementwhen known,error.details.order_idwhen an order row was already written). A sell is unchanged.- COP
co_bank_transferthrough Bridge is no longer offered for a buy. Bridge collects that rail through its own hosted page, whichOrderhas no field to carry, so the capability grid marks itblocked/route_disabled, no quote offers it, and an order that still names it is refused409 capability_unavailable(param: quote_id,error.details.payment_rail: co_bank_transfer) before anything is written. A Bre-B (bre_b) buy is unaffected. Superseded before release by D-123 (above). BankDepositInstructions.bre_b_key(new, optional): the key a COP Bre-B buy is paid to. A Bre-B instruction without it is not published at all, and aco_bank_transferinstruction is never published as bank details.POST /v1/customers/{id}/verification_links.railacceptsbre_bandco_bank_transfer(both ask for thecopendorsement). For an individual whose verification is approved, arailis checked against the live approval a buy on that rail needs (before that, the link starts identity verification as before): when a hosted KYC link cannot move it (nextaccept_terms— mint akind: toslink,complete_profile,provider_review,not_available), nothing is requested and no link is minted —403 permission_error,code: endorsement_required, the samedetails. Forpixthe payout side counts too: either direction with a hosted step or an approval mints the link; otherwise the most actionable step of either is named. A status read only from a cached copy decides nothing: the link is minted as before. A business customer's link and a link withoutrailare unchanged.
Unreleased — Selling is live through Bridge; only swap stays paused (2026-10-15)
Additive, no Swaps-Version bump: no field, code or operation is added or removed. The trade switch now reads buy and
sell live, swap paused (D-87). What changes for a caller:
POST /v1/quoteswithside: sellprices; it answered409 capability_unavailable,TRADE_PAUSED. A sell is a stablecoin sold through Bridge only, paid out to a bank account in the seller's own name by ACH, wire, SEPA, Faster Payments, PIX or SPEI, on a launchable pair: USDC on arbitrum, avalanche, base, celo, ethereum, optimism, polygon, solana, stellar and tempo; USDT on ethereum, solana and tron; USDB on base. Residents of the EEA sell USDC only (for acountryin the EEA, USDT and USDB are not priced). On a Bridge selltotal_fees.swapsis Swaps' 1 % fee, and on a sale that converts currency Bridge's FX spread is part oftotal_fees.provider. The sell refusals already published apply unchanged, before any pricing:QUOTE_NETWORK_REQUIRED,NETWORK_NOT_SUPPORTED,LOCAL_CURRENCY_NOT_SUPPORTED(COP today) /PAYMENT_METHOD_NOT_SUPPORTED, and422 amount_invalidfor afrom_amountabove 8 decimals or the network's token scale when that is smaller (USDC and USDT: 6).POST /v1/orderscreates a Bridge-native sell from a stored sell quote; it answered409 TRADE_PAUSED. Only a Bridge offer becomes a sell order — another provider's sell offer answers409 capability_unavailable, as before.external_account_idis required; the201carriesdeposit_instructions(exactamountofassetonchain,memowhere the network needs one — Stellar, Tron) anddeposit_instructions.expires_at, 24 hours out: the sell's only deadline (Orderhas noexpires_at). Crypto sent to another address or on another network may be lost. One open sell per deposit:409 conflict,order_already_open.side: swapis unchanged:409 capability_unavailable,param: side,reason_code: TRADE_PAUSEDonPOST /v1/quotes, and the same refusal for a stored swap quote onPOST /v1/orders.TRADE_PAUSEDnow means swap only.- The descriptions say so: the
quotes.createsummary («Price a buy or a sell (swap is paused)») and description, theget_quoteMCP tool,orders.createandOrder.deposit_instructions. Corrected with them inorders.create:POST /v1/wallet/external_accountscreates all six sell rails (faster_paymentsandpixsince the GBP and BRL entry below; the sentence that a Pix or Faster Payments sell needs an account created outside this API was stale) and asepaaccount takes its IBAN and BIC, both required;POST /v1/quotesrefuses a COP sell before pricing and scale-checks a sellfrom_amount, so the order's own check is defense in depth, not the only gate.
Contract tests: packages/contracts-api/__tests__/contract/trade-availability-truth.test.ts (the bundled spec and
the Buy & sell docs page against packages/config/tradeAvailability.ts),
supabase/functions/api-v1/__tests__/routes_quotes.test.ts and routes_orders.test.ts (TRADE_PAUSED on swap,
the sell gates).
Unreleased — Sell before sign-in: estimates, coverage and the country summary take side=sell (widget v2 sell, A1 §2.1–§2.3)
Additive, no Swaps-Version bump; the three operations stay dark-flag (503 while their switches are off).
GET /v1/quote_estimates serves side=sell (only swap is still 409 capability_unavailable): from_asset is the
stablecoin sent, the new from_network — required on a sell, read only there — the network it is sent on, to_asset
the fiat paid out, and either from_amount or to_amount (Bridge's reverse quote); to_network on a sell is 400.
A sell pair Swaps has not launched is 409 capability_unavailable with param: from_network and
details.asset/details.network, and a payout currency the pair cannot be paid out in on a rail a sell executes
(Bre-B/COP included) is 409 with param: to_asset — both before any pricing. On a sell from_amount is in the asset, to_amount and every
fee line in the payout fiat — the currency the binding sell quote states them in — and identity.required is always
true. Only Bridge sells through /v1 today: another provider's sell price is listed paused (provider_paused),
never offered. GET /v1/capabilities/buy_sell gains an optional side (buy, the default, or sell): a sell answer
carries side: sell, sell_defaults (CapabilitySellDefaults: the payout currency by the D-65 rule over sell routes
with its fiat_reason, default_asset: USDC, default_network: base, the first selectable payout method and its
USDC resolved_limits) and sell_coverage (CapabilitySellCoverageCell[]: one cell per payout method and fiat the
country can be paid in, Bridge as provider, eta_seconds when selectable, and assets[] —
CapabilitySellCoverageAsset: asset, network, state, reason, and when selectable min and max in the asset,
min being the minimum the sell quote enforces) instead of defaults and coverage, and an empty provider_fees.
Bre-B (COP) is blocked (route_disabled); USDT and USDB are blocked (policy_blocked) for a country in the EEA.
The new state and reason fields are open response enums (A1-2). The authenticated GET /v1/capabilities?product=buy_sell is unchanged and ignores side. GET /v1/capabilities/buy_sell/countries gains
the same optional side; with sell it counts, per payout currency, the payout methods whose sell cell is selectable
and has at least one selectable asset. An invalid or repeated side is 400 on both. The sell coverage is stored by
the same warm run that stores the buy coverage and is answered from that copy while it is fresh. No MCP tool. Contract test:
packages/contracts-api/__tests__/contract/public-buy-widget.test.ts.
Unreleased — Sell: GBP and BRL bank accounts, the rail on a KYC link, and where a sale stands (widget v2 sell)
Additive, no Swaps-Version bump. Three changes for selling stablecoins to a bank account through Bridge.
POST /v1/wallet/external_accounts accepts two more rail variants: faster_payments (currency: gbp, a
six-digit sort_code and an eight-digit account_number, digits only) and pix (currency: brl, a pix_key and
an optional pix_key_type: cpf, cnpj, email, phone or evp). A Pix key is a CPF (11 digits) or CNPJ
(14 digits) — punctuation is removed and the check digits are verified —, a phone number as +55 and 11 digits, an
email address or a random key (a UUID); anything else, or a key that is not the declared type, is 400 invalid_request with param: pix_key, and the message never repeats the key. 11 bare digits are a CPF, never a
phone number. attest_own_account: true is required and the holder name is the verified legal name, as for every
rail. ExternalAccount.rail (create and GET /v1/wallet/external_accounts) adds faster_payments and pix and
is now an open response enum (A1-2): tolerate a value you do not know. For pix, last4 is the unmasked tail of
the provider's masked key preview, otherwise the last 4 digits of the CPF or CNPJ the key belongs to; a pix
account with neither is not listed. These accounts are payout destinations for a sell order (POST /v1/orders,
external_account_id); the wallet's own bank withdrawal (offramp_intents) still pays only ach, wire, sepa
and spei. Bank details stay REST-only, never an MCP tool argument (MCP clients log tool arguments verbatim), so
no tool changes. Contract test: supabase/functions/api-v1/__tests__/routes_wallet_sends_offramp.test.ts.
POST /v1/customers/{id}/verification_links accepts an optional rail (ach, wire, sepa, faster_payments,
pix, spei; a request enum, closed) with kind: kyc only — with any other kind it is 400 invalid_request,
param: rail. For an individual customer, sepa, faster_payments, pix and spei ask the verification partner
for that rail's approval when the customer does not carry it yet, and the hosted link opens on that approval's
requirements; ach and wire ride the approval every customer is created with, and a business customer's link is
not scoped by rail. Without rail the link is minted exactly as before. When the approval cannot be requested
the answer is 503 temporarily_unavailable naming the rail; retry with a new Idempotency-Key. A link is not an
approval: read Customer.endorsement_statuses. Contract test:
packages/contracts-api/__tests__/contract/platform.test.ts.
Order gains progress on a Bridge-native sell: progress.stage is awaiting_deposit (Bridge is waiting for the
crypto, status: awaiting_payment), received (Bridge has it, processing), paying_out (the bank payout is
sent, processing) or paid (Bridge reports the payout processed, completed), read from Bridge's own transfer
state. It refines status and never contradicts it: it is absent on every other order, for any other transfer
state (a review, a return, a failure), and when the state disagrees with status. stage is an open response
enum. get_order_status (MCP) describes it. Contract test: packages/contracts-api/__tests__/contract/platform.test.ts.
Unreleased — Payroll: crypto deposit details older than 14 days read blocked; the funding detection lag (2026-10-15)
No Swaps-Version bump; a value change on PayrollFundingInstructions (payroll_runs.get_funding_instructions,
payroll_runs.fund, and PayrollRun.funding_instructions). A crypto-funded run still funding_pending whose
deposit details were issued more than 14 days ago is held for review — Swaps no longer counts a deposit to
those details automatically — and its instructions now read status: blocked with failure_reason: deposit_window_expired, crypto: null and retryable: false, where they read verified with the deposit
address and amount. While crypto funding is switched on, a funding call that does not switch method returns
them unchanged (never re-priced, never a new window); while it is off, that call answers 409 capability_unavailable. A call that switches to fiat provisions bank instructions instead, unless money was
already observed on the run (409 funding_method_locked). Contact support. blocked is an existing status
and failure_reason an open string: deposit_window_expired is a new value of it. A funded run keeps its
instructions verified. Descriptions only: funding is detected by a
five-minute poll and the crypto route reads the wallet up to a minute before each poll, so the run's status
and funding_state can show a real transfer about six minutes late (was «up to that window», and named
verified, which describes the instructions and stays verified after a deposit is observed). Contract
tests: supabase/functions/api-v1/__tests__/routes_payroll_crypto_funding.test.ts (the held instructions
against PayrollFundingInstructionsServerSchema and PayrollRunServerSchema; exactly 14 days, and a funded
run, stay verified), supabase/functions/api-v1/__tests__/routes_payroll.test.ts (both funding endpoints).
Unreleased — Selling and swapping are refused on the server while paused (D-87, 2026-10-15)
From 2026-10-14 sell and swap were paused (buy only; sell is live again since 2026-10-15, see the entry above, and swap stays paused). The pause is now enforced by the API too, not only hidden in the
interface: POST /v1/quotes with side: sell or side: swap answers 409 capability_unavailable (param: side,
details.reason_code: TRADE_PAUSED) before any pricing, and POST /v1/orders refuses a stored sell or swap quote the
same way before any provider is contacted. It lifts with the sell and swap releases. GET /v1/quote_estimates already
answered sell with 409. Buy is unaffected.
Unreleased — The binding quote takes the US state (2026-10-15)
POST /v1/quotes accepts an optional region, the same US state GET /v1/quote_estimates takes (D-82): read only for
country=US, where it must be a US state, DC or outlying-area code (otherwise 400, param: region). Send the value the
estimate used, so a provider that prices a US route only once it knows the state (Stripe Onramp) quotes the offer the
estimate showed instead of dropping it. The quote's execution_context echoes it (region), and an order made from
the quote carries it to the provider's checkout. Additive, no Swaps-Version bump.
Unreleased — Bridge's exchange spread is Bridge's part of the fees (D-98, 2026-10-15)
No Swaps-Version bump; value correction on GET /v1/quote_estimates and POST /v1/quotes for Bridge buys and sells
that convert currency (EUR, GBP, MXN, BRL, COP). fees.provider now includes Bridge's exchange spread as an amount
in the fee currency — what the spread costs on the amount Bridge converts (EUR 200 by SEPA: 0.99 EUR) — and
fees.total includes it. Before, the spread was computed as a per-unit rate difference (about 0.0056) and left out of
both, so the breakdown read «Bridge 0.00». to_amount does not change: it was already priced at Bridge's rate. A
same-currency route (USD → USDC) has no spread. Contract test:
supabase/functions/_shared/services/__tests__/bridge-swaps-fee-truth.test.ts.
Unreleased — Every country's ways to pay in one read (widget v2 D-93)
Additive, no Swaps-Version bump. New GET /v1/capabilities/buy_sell/countries
(capabilities.buy_sell.countries.list): one row per supported country, sorted by country. A row built from a
fresh stored route snapshot is status: ok and carries currencies (the paying currencies with at least one way to
pay: default_fiat first, then the country's fallbacks, then the rest), ways (per currency, the number of distinct
payment methods selectable for buying — the same count GET /v1/capabilities/buy_sell?country=XX gives from the same
snapshot), default_fiat and, when the snapshot states it, fiat_reason. An ok row with no currencies has no way to
pay today. Every other row is unknown and carries nothing — never "not available". Top level: as_of (the oldest
snapshot used) and degraded (any row unknown). status is an open response enum (A1-2). Computed from the stored
snapshots alone — no route-truth read, no quote, no provider call — and Cache-Control: private for at most 60
seconds and never past the earliest snapshot's expiry, like the per-country read (no-store when no row is ok). Public and unauthenticated
like GET /v1/capabilities/buy_sell, behind the same api_v1.capabilities_public kill switch (503 while off) and
the same per-client-IP limit and block list; dark-flag until launch. No MCP tool: agents read coverage through
list_capabilities. Contract test: packages/contracts-api/__tests__/contract/public-buy-widget.test.ts.
Unreleased — Buy defaults say why the currency is the one shown (widget v2 D-65)
Additive, no Swaps-Version bump. Capabilities.defaults (product=buy_sell, on both
GET /v1/capabilities?product=buy_sell and GET /v1/capabilities/buy_sell) gains the optional
fiat_reason: local_currency (the country's own currency), fallback_fiat (the country's own
currency has no buy route in this snapshot, so default_fiat is the first fallback that has one —
a surface names it as not the local currency) or requested (the caller asked for it). It is an
open response enum (A1-2: an unrecognized member is an opaque string); a deployment that predates
it omits the field, and a client reads its absence as "not stated", never as local_currency.
With it, the SEPA members outside the euro area (CH, CZ, DK, GB, HU, IS, LI, NO, PL, RO, SE)
list EUR as their fallback right after their own currency, ahead of USD, so a country whose own
currency has no buy route defaults to EUR (SEPA) rather than to a USD wire, and its coverage grid
carries the EUR cells. Contract test: packages/contracts-api/__tests__/contract/platform.test.ts.
Unreleased — Payroll: an EURC funding pool and EURC payouts on Base and Ethereum (2026-10-14)
Additive, no Swaps-Version bump; open only to accounts enabled for an EURC pool (none when this
shipped). PayrollRun gains pool_asset, read-only: the asset the run's funding pool holds — USDC,
or EURC for an EUR run whose recipients chose EURC wallets — null while the run's funding records no
pool (before approve, or an older approved run whose funding recorded no wallet).
PayrollRecipientDestinationRequest.crypto gains asset (USDC | EURC, USDC when absent); any
other value is 400 invalid_request (an asset key sent before this field existed was dropped and the
wallet saved as USDC). PayrollRecipientSession.capabilities.crypto_external_wallet gains assets[]:
every wallet asset open for the run, with its networks and, where one exists, its fx_estimate —
the USDC entry mirrors the existing fields beside it; EURC (Ethereum and Base) carries no figure before
the run is funded, then realized. New codes: 409 run_mixed_pool (approve, fund: the recipients'
wallets ask for an EURC and a USDC pool on one run — split it into two; details.items_by_pool counts
the wallet rows per asset, a bank row casts no vote), 409 pool_asset_locked (fund: the pool would change after money was observed), 409 crypto_funding_pool_unsupported (approve, fund: an EURC pool on a crypto-funded run — fund it by bank
transfer), 409 asset_network_unsupported (destination.set: EURC on a network it is not paid out on,
such as Polygon or Arbitrum, refused before anything is written). An EUR run on an EURC pool is
executed like a converted run: at the rate its deposit realized (never assumed 1:1), rounded down,
under the pool guard and the provider dry run, with the same refusals. Contract tests:
packages/contracts-api/__tests__/contract/payroll.test.ts (assets[], crypto.asset),
supabase/functions/api-v1/__tests__/routes_payroll_eurc.test.ts (pool_asset, the four codes on
the wire).
Unreleased — Payment links: bank corridors follow the merchant's endorsements (2026-10-14)
No Swaps-Version bump; value correction on GET /v1/capabilities?product=payment_links only. An
individual account whose Bridge customer carries status-bearing endorsements no longer reads
gathering_no_path / payin_fiat_pending on its kind: 'bank' corridors because the deprecated
payin_fiat activation is not active: each corridor now reads its own endorsement's rung — an approved
sepa reads available, an incomplete one keeps gathering_no_path with no blocked_reason. This is
the rule the payer's payment already followed (since 2026-10-05), so a link the capability resource offers
is a link a payer can pay. payin_fiat_pending remains only for an individual whose customer has no
endorsement snapshot and whose payin_fiat is not active. This supersedes the 2026-10-11 entry's
«payment_links bank corridors keep the downgrade unchanged». wallet_bank and payroll are unchanged.
Contract test: supabase/functions/api-v1/__tests__/routes_capabilities.test.ts (BL-11 suite).
Unreleased — Buy & sell: Bridge wire fee passed through at cost on wire buys (D-86b, D-86c, 2026-10-14)
No Swaps-Version bump. On a Bridge wire buy, Bridge's own $10 wire fee (charged for each wire
Bridge receives) is now withheld together with the 1 % Swaps fee (both inside the transfer's
developer_fee) and passed through at cost. Wire-buy offers publish it inside total_fees.provider
(the «Bridge wire fee» line on the quote), total_fees.total grows by 10.00, and final_out drops by
10.00 — USD 100 by wire now delivers 89.00 USDC, USD 2,000 delivers 1,970.00 — so a wire ranks below
ACH for the same amount. Bridge wire buys start at $100; below that the Bridge wire offer is absent.
A sell's outgoing wire is unchanged: no Bridge wire fee is deducted (none is evidenced). Additive:
Capabilities.provider_fees (product=buy_sell only) lists the charge — {provider: bridge, side: buy, rail: wire, kind: flat, amount: 10.00 USD, label: «Bridge wire fee», pass_through: at_cost}.
Value correction on every Bridge buy and sell: the Swaps fee rounds down to the cent (USD 30.51 →
0.30, was 0.31). swaps stays the Swaps fee only; network stays 0; Order.amounts.fee on a
completed wire buy is the whole developer fee withheld. ACH, SEPA and the other rails are unchanged.
Contract tests: supabase/functions/_shared/services/__tests__/bridge-quote-v1-contract.test.ts
(best_get → /v1, wire provider 10.00), bridge-wire-fee-truth.test.ts,
bridge-developer-fee.test.ts.
Unreleased — Buy & sell: Bridge cross-currency buys priced at what Bridge converts at (D-80, 2026-10-14)
No Swaps-Version bump; value correction on Bridge cross-currency buy offers only (EUR, GBP, MXN, BRL,
COP → stablecoin). They were priced at Bridge's buy_rate, mid-market plus ~0.48 %, while Bridge
converts at mid-market less its FX spread (its card: EUR, GBP, MXN 0.50 %; BRL 0.55 %; COP 0.75 %), so
final_out read about 1 % above what arrived (9 of 9 quoted EUR/GBP buys). They are now priced at
Bridge's mid less that spread: final_out drops by about 1 %, rate follows, final_in for a
to_amount request rises to match. The spread stays inside rate (provider stays 0; swaps, the
1 % Swaps fee, is unchanged). A currency without a spread on the card, or without a usable mid, is
left out rather than guessed. Sells, USD → USDC and USD → USDT are unchanged. Contract test:
supabase/functions/_shared/services/__tests__/bridge-fx-rate-side.test.ts (every production receipt
with Bridge's mid reproduced within 1 bps).
Unreleased — Buy & sell: Bridge quotes state what Bridge delivers (D-76, D-79, 2026-10-14)
No Swaps-Version bump; value correction on Bridge offers only. Wire (D-76): every Bridge wire
offer, buy and sell, carried 15.00 in total_fees.network and total_fees.total that Bridge never
withholds and final_out never deducted; network is now 0 on every Bridge rail and total drops by
15.00 — final_out is unchanged (it was already the delivered amount). USDT (D-79): Bridge withholds a
0.10 % USD ↔ USDT conversion fee (its card's USDT support line) from what the 1 % developer fee leaves,
and delivers the rest truncated to the cent (11 of 11 production receipts). USDT offers now deduct it:
final_out drops by that amount, total_fees.provider and total_fees.total grow by it (it is
Bridge's own fee, so it is provider, never swaps), and rate is final_in ÷ final_out of the
delivered amount; a to_amount request pays enough for the requested amount to arrive. swaps (the
1 % Swaps fee) is unchanged; USDC offers are unchanged. get_quote (MCP) describes provider and
network the same way. Contract tests:
supabase/functions/_shared/services/__tests__/bridge-quote-v1-contract.test.ts (Bridge service →
best_get → /v1), bridge-usdt-conversion-fee.test.ts (receipts to the cent) and
bridge-wire-fee-truth.test.ts.
Unreleased — Buy & sell: Onmeta final_out is net of the Swaps share (D-50, D-53c, 2026-10-14)
No Swaps-Version bump; value correction on Onmeta offers only. Onmeta withholds a share for Swaps
on top of its own fee (signed agreement §10; every completed order in production carries it). Onmeta
quotes now deduct it: final_out drops by that share (the amount the order actually delivers), and
total_fees.provider and total_fees.total grow by it. It is a markup inside Onmeta's price, so it stays
inside provider and no swaps is published (founder ruling D-53c). No other provider changes. Contract
test: supabase/functions/_shared/providers/__tests__/onmeta-fee-truth.test.ts (best-quote → /v1).
Unreleased — Buy & sell: total_fees.swaps, Swaps' own fee on the route (D-44, D-53c, 2026-10-14)
Additive, no Swaps-Version bump. QuoteFees (on Quote.total_fees, every offers[].total_fees and
providers[].offer.total_fees) gains optional swaps (Money, the fee currency like the other legs):
Swaps' own fee, itemized where Swaps charges it directly — Bridge routes: the Bridge developer fee (1 % of
the gross source amount today, also on Bridge's receipt); always already inside total and final_out.
0 on a route with no Swaps share (Stripe Onramp). On markup routes (Transak partner fee, Onmeta markup)
Swaps' share stays inside provider and is not itemized (founder ruling D-53c); never read an absent
swaps as zero. Value correction, same release: on Bridge routes provider was the developer fee and is
now Bridge's own fee on the quote (0; Bridge's spread stays in the rate and in fx). total,
final_out, final_in and rate are unchanged on every route. get_quote (MCP) says how to read it.
Contract test: supabase/functions/api-v1/__tests__/routes_quotes.test.ts («D-44: publishes
total_fees.swaps»).
Unreleased — Widget v2 buy: public estimate, coverage and address check (dark-flag)
Additive, no Swaps-Version bump. Three token-free operations for the buy widget before sign-in,
on the public table (security: []), each x-swaps-status: dark-flag behind its own kill switch —
api_v1.quote_estimates, api_v1.capabilities_public, api_v1.address_checks — which answers
503 temporarily_unavailable while off (a missing row is off). GET /v1/quote_estimates returns a
QuoteEstimate: an indicative buy price from best-quote's quote cache (a new service-key-gated
x-internal-cached hop that answers every source, a live miss included, as a historical
reference), with per provider and method the amounts paid and received, the fee lines (QuoteFees,
swaps by the same D-53c rule as POST /v1/quotes), the delivery time when given, and identity (required, and for a no-ID route its warning and any
published max_spend) from the one identity policy the authenticated quote path applies, now in
_shared/services/identity-policy.ts (D-22; any uncertainty reads required: true). It has no
id and no offer_id, carries no limits (those are GET /v1/capabilities/buy_sell's
defaults.resolved_limits), lists in providers only the providers that priced the route plus
Paybis as paused, and POST /v1/orders never accepts it. side=buy only (sell/swap answer 409 capability_unavailable, param: side); it shares POST /v1/quotes's country check, fiat-scale
refusal (422 amount_invalid), no-offer reasons (QUOTE_NETWORK_REQUIRED, PROVIDER_PAUSED, …)
and Paybis pause. GET /v1/capabilities/buy_sell returns the same Capabilities projection as
GET /v1/capabilities?product=buy_sell for the same country, method and provider
(Cache-Control: private, max-age=60; no-store when degraded). POST /v1/address_checks returns
an AddressCheck — decision (allow, offer_report, block, not_screenable) and
checked_at only: no score, level or labels, nothing stored, no credit; advice only, the binding
check stays on POST /v1/orders. Each has its own persistent, fail-closed per-client-IP limit (120,
60 and 20 per minute; 429 rate_limit_error, a limiter outage 503) and refuses a client IP on
the Swaps block list before any work (403 ip_not_allowed). No MCP tool for any of the three
(API-CANON §13). quotes.create's description now points an anonymous caller at the estimate.
Contract test: packages/contracts-api/__tests__/contract/public-buy-widget.test.ts.
Unreleased — Verification links return to return_to (widget v2 D-35)
Behaviour change on an existing field, additive on the wire, no Swaps-Version bump.
customers.verification_links.create now hands the allowlisted return_to to the verification
partner as the hosted page's return target, where before it was validated and ignored. Swaps appends
nothing to it. A return_to outside the allowlist is still 400 invalid_request, param: return_to,
and is refused again before the partner sees it. A kyc link that cannot be minted with the caller's
return_to fails rather than returning a link that would send the holder elsewhere. The widget's own
hand-offs keep returning to /bridge-callback. Tests:
supabase/functions/swaps/__tests__/bridge-hosted-link-return-to.test.ts,
supabase/functions/api-v1/__tests__/routes_customers.test.ts.
Unreleased — Verification links accept a staging return_to (widget v2 D-33)
Additive, no Swaps-Version bump. customers.verification_links.create now accepts return_to on
https://staging.swaps.app beside https://swaps.app and https://www.swaps.app. Any other origin is
still 400 invalid_request, param: return_to. return_to is still validated only: it is not yet
passed to the partner's hosted hand-off. Contract test:
supabase/functions/api-v1/__tests__/routes_customers.test.ts.
Unreleased — Orders: hosted OnMeta and Stripe Onramp buys, return_url, address_book_id (widget v2)
Additive, no Swaps-Version bump. On POST /v1/orders:
- Hosted buys. OnMeta and Stripe Onramp now execute hosted buys beside Transak. Their checkout is
minted from the quote's own stored offer, never from the request:
final_in(or the requestedfrom_amountwhenfinal_inis absent) is what the payer is charged,final_outwhat they receive, andexecution_context.to_networkis the chain. An offer withoutto_networkis409 capability_unavailable. The offer must also be fresh under its provider's checkout-readiness policy, with the maximum quote lifetime counted from the quote'screated_at; otherwise409 requote_required. - Re-pricing. OnMeta prices again when its checkout is opened, and so does a Stripe Onramp checkout
opened after its fresh window. A move of 50 bps or more answers
409 conflict,requote_required, witherror.details.reason: price_moved,drift_bps,quote_idandorder_id, and returns no checkout URL. That refusal is not side-effect-free: the order it started is closed at once asabandoned, withmoney_state: never_authorized,money_status: not_charged,failure_kind: generic_failedand afailure_reason, because no checkout ever reached the payer. An OnMeta checkout OnMeta cannot prepare is503 temporarily_unavailablewithquote_idand the same abandonedorder_id. - OnMeta currencies. Per-account rolling 24-hour ceilings: INR 1,500,000, IDR 45,000,000 and
PHP 150,000 (
422 amount_invalidpast them). Any other currency without a ceiling is still refused with409 capability_unavailable. An order closed asabandonedwithmoney_state: never_authorizeddoes not count toward the hosted daily cap (D-36); every other order, abandoned or not, still does. - Unchanged. Partna stays
provider_not_supported, Paybisprovider_pausedand a hosted sellcapability_unavailable. - Provider switches. An offer executes only while its provider is switched on in Swaps' provider
configuration, and that includes Bridge-native offers. A provider switched off, or with no switch
recorded, is refused with
409 capability_unavailable,provider_paused, anderror.details.eligible_offer_idswhen itsoffer_idwas pinned, or skipped by the no-offer_idwalk. A failed read of the switches is503 temporarily_unavailable, side-effect-free. return_url(optional). Where Transak and OnMeta send the payer after checkout. It must behttpsonhttps://swaps.app,https://www.swaps.apporhttps://staging.swaps.app, at most 2048 characters, with no query string, fragment or credentials. Anything else is400 invalid_request,param: return_url, never a silent default. It is signed into the checkout and checked again before the provider receives it. Stripe Onramp and Bridge-native orders validate it and do not use it.address_book_id(optional, dashboard sessions only). A buy destination from the caller's own address book. The address and network come from the entry. Awallet_addresssent with it must match byte for byte (400,param: wallet_address). The entry must be crypto and saved for the offer'sto_network, and the order must be a buy (400,param: address_book_id). A latest screening ofblockis403 destination_blocked, a foreign id is404, and a business key is403 scope_denied. After a201the entry'slast_used_atis set.- Host check. A freshly minted
next_action.checkout_urlis host-checked like a re-read one; a URL off the provider host list is never published (503, reconcile viaGET /v1/orders).
AddressBookEntry gains last_used_at (nullable date-time). Contract tests:
packages/contracts-api/__tests__/contract/platform.test.ts (orders.create) and
packages/contracts-api/__tests__/contract/tools.test.ts (address_book.create).
Unreleased — Crypto Processing context alignment
Documentation-only correction; no Swaps-Version, wire shape, validation, maturity status or runtime flag changes. Subscription amounts use stored minor-unit strings and an explicit scale. Public reminders always read off and are not configurable at create; crypto-only child links do not send invoice email. REST availability is separate from dark-flag MCP registration. Historical R19/K13 plans no longer describe implemented subscriptions, verdicts or Relay as missing.
Unreleased — Buy & Sell source-context alignment
Correct canon and guide inventory for orders, coverage, MCP/SDK, Bridge sell and hosted
Transak buy. Document next_action handoff and distinguish authenticated buy/sell coverage
from personal provider KYC eligibility. Contract descriptions only; no version bump,
schema, authentication, execution or runtime changes.
Unreleased — Pay an invoice source-context alignment
Reconcile documentation and canon with the implemented REST/MCP surface, gated Wallet funding, draft fields, frontend defaults, F5 settlement tolerance, sender snapshots and current source paths. Clarify in the funding contract that preparing an unsigned send is not payment evidence. No version bump, schema, caller, validation or runtime change.
Unreleased — Payroll source-context alignment
No Swaps-Version bump or runtime change. Correct description-only fee and limit semantics:
the enabled fee is 1% of each recipient amount, added on top; numeric amount caps are in the
run currency with no FX conversion and are rechecked before execution; the 500-row limit is
create validation. Align the guide/runbook/canon on draft eligibility, destination ready
versus payment, four-state funding classification, top-level ops assertion fields and current
source paths. Request/response shapes, validation, callers and execution gates are unchanged.
Unreleased — Wallet source-context alignment
Documentation correction only; no Swaps-Version, schema, caller, tool or runtime change.
The Wallet guide and canon now follow the existing operation status/caller/test-mode metadata,
including the public MCP surface and unsigned preparation boundary. They distinguish the
aggregate USD total from route-token spendability and bank-service verification from wallet-only
crypto self-custody. Historical Bridge crypto-floor guidance is marked superseded by Relay
quotes; eligible Payment Link copy and production deployment guidance are corrected.
Unreleased — Payment Links source-context alignment
No Swaps-Version bump or runtime change. Descriptions now distinguish USD crypto-only
wallet settlement from non-USD Bridge wallet settlement, including currency-specific gates,
bank-payable non-USD requests and draft-only MCP authority. The owning canon and PRD also
align public API availability, payer limits, collection-return transitions and short-link
routing with the existing implementation. Request/response shapes and validation are unchanged.
Unreleased — B32-1: a webhook delivery records its final outcome and is never sent past its 7th attempt
No Swaps-Version change; no new operation or field. A final outcome could not be recorded: the
database refused every succeeded and exhausted completion, so the delivery kept its earlier status
and was sent again roughly once a minute — an endpoint answering 2xx would have received the same event
again and again. Now both are recorded, next_attempt_at reads null on a succeeded or exhausted
delivery as documented, and a delivery is attempted at most 7 times: one picked up again past that ends
exhausted with the new error value attempt_limit_exceeded, and no request is sent for it.
Deliveries already past their 7th attempt are closed as exhausted, keeping their last recorded
response_status and error; that step closes no endpoint and emits no event. Auto-disable after 3
consecutive exhausted deliveries now takes effect as documented — it never could before, because no
delivery reached exhausted. The retry schedule, signatures and replay are unchanged. The OpenAPI
description of WebhookDelivery and the SDK's generated types now say the same: next_attempt_at is
null once status is succeeded or exhausted, and error lists the values the worker writes —
connection_error (the description said connection_refused, which is never written),
attempt_limit_exceeded and url_not_allowed:<reason>. See
Events & webhooks.
Unreleased — align public integration guidance with the enabled resource API
The public MCP page, its Markdown companion, localized installation guidance and generated
LLM manifests now describe /v1 as enabled, subject to scopes, account capabilities and
resource availability, and point to the live documentation host. Test mode remains limited to
the operations listed in Test mode; no complete provider
sandbox or completed rollout acceptance is claimed. Published npm route tools remain read-only;
hosted account tools preserve per-transaction human confirmation and signing requirements.
This corrects documentation only: no API, SDK, tool schema or Swaps-Version change.
Unreleased (2026-10-05) — one login, one account: POST /v1/accounts answers 409
POST /v1/accounts no longer opens a second account under the same login. A login that already owns a live account gets 409 conflict, code account_already_exists, and nothing is created. Every signed-in login is given its account on its first request without Swaps-Account, and only the owner of the live account a request acts on gets past the earlier refusals, so today a request that gets past them always gets this 409 and never a new account. The wallet, verification, payment links, bank details and transactions belong to the login rather than to an account, so a second account would promise a separation that does not exist. The reference marks accounts.create as not open today. Every earlier refusal still comes first and is unchanged: 403 for a business key or a member, 503 for a test-mode session, 400 for a malformed body or an unrecognised country. GET /v1/accounts and the Swaps-Account header are unchanged. Separate accounts under one login, each with its own verification, are planned for a later release. No Swaps-Version change.
Unreleased — explicit acceptance of legal revisions
Superseded (2026-10-20). No operation asks for a terms acceptance, and neither code below is returned any more: see No operation asks for a terms acceptance (2026-10-20) above. What follows stays as the record of what shipped; the errors page lists both codes as retired.
Adds a disabled-by-default registry and server receipt for a published legal revision, with
separate publication, acceptance, effective and per-action requirement dates. No legal release,
mailing or enforcement date is activated by this change. Selected new live operations can return
409 terms_acceptance_required (capability_unavailable, details.revision/details.action)
or 503 legal_acceptance_unavailable (temporarily_unavailable, Retry-After). People accept
through an authenticated Swaps screen, with owner authority required for account terms; personal
wallet intents require their own receipt. Keys and agents cannot mint receipts. Existing
completed idempotent responses, reads, cancellations and recovery retain their existing paths.
No Swaps-Version change. See Errors.
Unreleased — save a destination the recipient submitted as their default (payroll)
PayrollRecipient gains pending_destination: a destination the recipient saved through their payout link that is not their default yet (masked; null when nothing waits). New operation POST /v1/payroll_recipients/{id}/adopt_pending_destination (payroll.write, Idempotency-Key) saves it as the default, bound to the submitted_at you showed (expected_submitted_at), and returns the recipient plus updated_run_items / skipped_run_items for their waiting rows in unapproved runs. New error codes: pending_destination_changed, no_pending_destination (409), and destination_rail_keyless (422) — the last one also replaces the generic invalid_request code payroll approve, execute and the recipient's own destination write answered for the same refusal. On 409 pending_destination_changed the recipient's default is unchanged; in a rare race, rows the call already filled with the destination you confirmed keep it. Additive; no Swaps-Version change.
Unreleased — fund a payout from the Swaps wallet balance
POST /v1/payouts/{id}/funding_instructions takes an optional body: source_chain (chosen at funding, fixed afterwards) and funding_source: swaps_wallet, which prepares one unsigned send from the payer's own wallet to exactly the deposit address — the payer signs it with their passkey. New read: GET /v1/payouts/{id}/wallet_funding_quote. GET /v1/capabilities?product=payouts lists funding_sources. Additive; an absent body behaves exactly as before. Off until enabled per account.
Unreleased — naming freeze
- Operation ids follow one rule,
<resource>.<verb>, and something under a parent is part of the resource name. For example,payouts.list_eventsis nowpayouts.events.list, andscreenings.create_reportis nowscreenings.reports.create. Paths are unchanged. See Conventions. - The screening scopes are now
screenings.read/screenings.write. A key created with the oldscreening.*names keeps working until the nextSwaps-Versiondate. - The
mealias on/customers/{id}is deprecated. Use yourcus_…id instead. - Payouts and capabilities now return UK Faster Payments as
faster_paymentsinstead offps.
Unreleased — hosted MCP and the OpenAPI contract withdraw the advertised OAuth authorization server
Swaps operates no public OAuth authorization server. /.well-known/oauth-authorization-server
and /.well-known/oauth-protected-resource answer 404; the OpenAPI contract's agentOAuth
scheme and every MCP discovery document that named OAuth 2.1 + PKCE are corrected to advertise a
business key (sk_live_…/sk_test_…) as the sole agent/MCP credential. See
Authentication. No credential class that worked before this entry stops
working — this corrects what was advertised, not what was enforced.
Unreleased — truthful quote projection
rate(the winner, everyoffers[]entry, everyproviders[].offerecho, androute_facts.best_price.rate) is now derived server-side from each offer's ownfinal_in÷final_outdecimal values, computed before either is rounded into the publishedMoneyfields — alwaysfrom_assetperto_asset, identically for every provider on bothbuyandsell— never a provider's own field, whose basis previously differed by provider and by side. Because the derivation runs pre-rounding,rate× the publishedfinal_outamount can differ from the publishedfinal_inamount in the last digit(s) when an asset's published decimals are coarser than the server's internal precision — treat that as approximate, not an exact identity. Treatrateas informational only, never for ranking: compare byfinal_outfor afrom_amountrequest, or byfinal_infor ato_amountrequest — but only among offers whosefinal_outmatches the amount you requested; not every provider honors an exact-output target, so a differingfinal_outmeans that offer bought a different amount and itsfinal_inis not comparable this way. A non-winning offer whose facts cannot produce a usable rate is omitted rather than failing the whole response.- Transak quote execution requires an explicit crypto network. When no usable offers remain for this reason,
409 capability_unavailablepreservesQUOTE_NETWORK_REQUIREDand names the missing directional network field. - Winner and per-provider offers add optional
payment_method, preserving the actual method with itsoffer_id. Unknown methods remain absent; inconsistent methods for the same winning offer are rejected. This additive field does not changeSwaps-Versionor grant execution eligibility. POST /v1/quotesexposes the winningoffer_idand safe terminal provider outcomes, preserving success across payment methods. Route facts are projected from existing evidence.- Readiness and status come from validated provider expiry; malformed upstream facts return an explicit unavailable response. Public cache hits retain their original fetch time as indicative prices.
- Authentication remains required on
/v1/quotes. Test callers are refused before provider dispatch until sandbox routing is implemented. Order creation remains proposed. GET /v1/capabilities?product=buy_sellnow publishes fresh-snapshot defaults (primary_method, optionalresolved_limits) and a bounded coverage grid carryingfiatanddirection; it never creates a quote or starts provider work. A stale or cold route snapshot returns503 temporarily_unavailable.
2026-09-05 — K1: the /v1 gateway skeleton
- New edge function
supabase/functions/api-v1behind a global kill switch (feature_flags.api_v1, off by default) — this release changes no live behaviour until an operator enables it. - First two live operations:
GET /v1/account,PATCH /v1/account, plus the newGET /v1/accountsandPOST /v1/accounts(one login, many accounts).account.get/account.updateflip fromproposedtoavailable;accounts.list/accounts.createare new andavailablefrom the start. - New tables
accounts,account_members(backfilled 1:1 from every existing account) andapi_idempotency_keys(the(key, account, route)idempotency store, 24-hour TTL sweep). - Three route-resolution gates land: fail-closed global and per-resource kill switches, business-key and bearer auth classes with fail-closed scopes, and per-class rate limiting on the distributed limiter.
Account.idnow carries theacct_prefix (see Conventions).
2026-09-04 — Contract v1 (gate [A1])
Corrected 2026-09-16
The "OAuth 2.1 + PKCE with CIMD" auth class this entry announced was never built and is now withdrawn (decision C4-D32) — see Authentication & keys and the entry above. Two live credential classes remain: business key and dashboard session, plus the public capability token.
- Resource-shaped
/v1onapi.swaps.app: payment links, clients, products, payments, subscriptions, payouts, payroll, wallet, quotes, orders, capabilities, eligibility, customers, account, activity, events, webhook endpoints, address book, screenings, traces, credits, API keys. Three auth classes (business key, OAuth 2.1 + PKCE with CIMD, public capability token); the dashboard is a first-party OAuth client.Superseded above — see the 2026-09-16 correction.Moneyobject,Idempotency-Key, cursor pagination, one error envelope,Swaps-Versiondate header,livemodeon every object, test mode by key prefix.- The status of every operation is stated in the reference (
available·dark-flag·proposed); non-available operations are documented with their reason and are not callable. - Business-key scopes fail closed: a key with
scopesnull or empty now denies every action instead of granting all of them. - Usage-log KPI columns and distributed rate limiting land on the usage-tracking table, feeding the platform KPIs.
Next: Get started to build against the current contract · Providers & coverage for what's live right now, rail by rail.