Preview: RFQ V2, enabled October 16, 2026
RFQ V2 is enabled in production on October 16, 2026. Until the switch flips, every V2 route returns 403 rfq_v2_disabled. This page is the reference until the release ships. The generated REST reference, WebSocket reference, and error catalog pick up the V2 routes, messages, and codes with the release.
V1 RFQ does not change. /rfq/request, /rfq/accept, and the rfq WebSocket channel keep their current contract. The V2 switch never gates them.
V2 is a separate, signed RFQ contract. Every object a counterparty can act on (the parent RFQ, a firm quote, an execution) is an EIP-712 document the engine re-verifies when it fills. The API relays and stores; the engine decides.
What V2 adds over V1:
- Anonymous takers. Makers quote without seeing the taker wallet.
- Selected-maker routing. Send to one maker or a signed set of up to 128, or to every eligible maker.
- Indicative quotes. Non-executable price discovery, then a signed firm-up request.
- Partial fills. Three fill policies, including repeated partial fills against one firm quote.
- Sweeps. One signed, atomic execution across up to 1,024 firm quotes.
- Maker fences. Generation counters, bulk withdrawal, and an account-wide dead-man cancel.
- Auto execution. The taker signs a premium bound once; the first qualifying firm quote fills.
Go-live switch: api.rfq.v2_mode
V2 availability is one backend setting, api.rfq.v2_mode, with three values. The default is disabled.
| Mode | Writes | Reads and AuthenticateRfqV2 |
|---|---|---|
disabled | Every V2 route returns 403 {"code":"rfq_v2_disabled","message":"RFQ V2 is not enabled in this environment"}. The gate runs before path, query, and body parsing, before authentication, and before the rate limiter, so a malformed request also gets 403. | Read routes return the same 403. On the socket, AuthenticateRfqV2 answers {"type":"Error","message":"Invalid or expired RFQ V2 read grant"} with no code field. |
allowlist | A request whose signed account is not listed returns 403 rfq_v2_not_allowlisted. The checked account is terms.taker_account on create and parent replacement, terms.maker_account on quotes and quote replacement, and maker_account on bulk withdrawal and the cancel schedule. Every address in a selected_makers set must be listed. all_eligible routing reaches listed makers only; if none remain, create returns 422 rfq_v2_no_eligible_makers. | An unlisted account gets no read session: 401 rfq_v2_session_required over HTTP and the Error frame above on the socket. |
enabled | No gate. Normal admission and engine checks apply. | No gate. |
Execution, sweep, cancel, withdrawal, firm-up, and decline carry no account check of their own under allowlist. They act on a parent or quote that already passed it.
Detect availability by the code, not the status. 403 rfq_v2_disabled means the environment is off. 403 rfq_v2_unauthorized means your signer may not act for the account.
Conventions
Base URLs: https://api.hypercall.xyz and wss://api.hypercall.xyz/ws. V2 lives under /private/.... There is no /v2 path prefix and no public V2 route.
Amounts are decimal strings. Every package, capacity, price, premium, and fee value is an unsigned integer in a JSON string, up to 2^96 - 1. Timestamps (*_ms) and nonce are JSON numbers.
| Quantity | Unit | Example |
|---|---|---|
Package units (target, capacity, package_units, minimum, step) | 1,000,000 = one package | "5000000" is 5 packages |
quantity_micros_per_unit | Contracts per package, times 1,000,000 | "1000000" is 1 contract per package |
price_micros, *_premium_micros, fees | USD times 1,000,000 | "830000000" is $830 |
Leg size in contracts is package_units * quantity_micros_per_unit / 10^12. The engine books legs in whole millionths of a contract, so package_units * quantity_micros_per_unit must divide by 1,000,000 for every leg. A size that fails this is refused at execution.
Enums are lowercase snake_case: buy, sell, firm_only, repeated_partial. Addresses and bytes32 values are lowercase 0x hex. Signatures are 65 bytes, 0x plus 130 hex characters, with v of 0, 1, 27, or 28.
Errors are {"code": "...", "message": "..."}. Branch on code. Every V2 code starts with rfq_v2_. The full list is in Error codes.
Responses carry Cache-Control: no-store.
Identifiers, nonces, and operation IDs
Object IDs. terms.id on a parent or quote is any non-nil UUID except version 8. The object ID is its own idempotency key: resending the identical signed body returns the original acknowledgement; different terms under the same ID return 409 rfq_v2_idempotency_conflict. A firm quote that firms up an indicative needs a fresh ID.
Nonces. Same rules as order nonces: a millisecond timestamp within two days before and one day after server time, unique per signer. See Nonce Management. Firm quote nonces live in a separate per-signer set, so order flow on the same key cannot evict a live quote nonce. Indicative quotes and read grants carry no consumable nonce.
Operation IDs must be in-window UUIDv7. Every action that the engine journals takes a client operation_id:
- execution and sweep
- parent cancellation and quote withdrawal
- bulk withdrawal
- the cancel schedule
- parent replacement and firm-quote replacement
For these, operation_id must be an RFC 9562 version 7 UUID whose embedded timestamp is no more than 1 hour before and no more than 30 seconds after the API's clock at admission. Anything else returns 400 rfq_v2_invalid_operation_id ("generate a fresh UUIDv7"). The check runs after signer authorization, so a bad signature still reports as a signature error.
Version 8 is reserved. The server derives its own IDs (delivery records, auto-execution operations) as version 8 UUIDs. Any client operation_id with version 8 returns 400 rfq_v2_invalid_request on every route that takes one, before the signature is checked. Firm-up, decline, and indicative replacement accept any other version. Use UUIDv7 everywhere and the rule never bites.
The window is also your retry horizon. An exact retry of a journaled action returns the committed result, but only while its operation ID is inside the 1-hour window. After that the retry returns 400 rfq_v2_invalid_operation_id, not the original outcome. Reconcile through reads instead.
Signing
All V2 messages use the standard Hypercall domain from Authentication: name Hypercall, version 1, the environment's chain ID (999 in production), and verifyingContract 0x0000000000000000000000000000000000000000.
The signer is the account itself or an agent the account approved through /approve-agent. Agent authority is rechecked on every request, including reads.
| Action | Primary type | Signer |
|---|---|---|
| Create RFQ | AuthorizeRfqV2 | Taker |
| Firm quote | AuthorizeRfqQuoteV2 | Maker |
| Indicative quote | PublishRfqIndicativeQuoteV2 | Maker |
| Firm-up request | RequestFirmRfqQuoteV2 | Taker |
| Decline | DeclineRfqV2 | Maker |
| Execute or sweep | ExecuteRfqV2 | Taker |
| Cancel RFQ, withdraw quote | InvalidateRfqV2 | Taker, maker |
| Bulk withdrawal | BulkWithdrawRfqV2 | Maker |
| Cancel schedule | ScheduleRfqQuoteCancelV2 | Maker |
| Replace RFQ or quote | ReplaceRfqV2, plus the replacement's own signature | Taker, maker |
| Read session | AuthorizeRfqReadV2 | Any participant |
EIP-712 type definitions
struct RfqV2SigningLeg { bytes32 instrumentId; uint8 takerSide; uint128 quantityMicrosPerUnit; }
struct RfqV2SigningFeeLimits { uint128 perExecutionMicros; uint128 cumulativeMicros; }
struct RfqV2SigningTerms {
bytes16 rfqId; address takerAccount; RfqV2SigningLeg[] legs; uint128 targetUnits;
uint8 fillPolicy; uint128 minimumUnits; uint128 stepUnits;
uint8 executionMode; bool autoMakerReceivesPremium; uint128 autoPremiumLimitMicros;
uint8 quotePolicy; uint8 takerIdentity; uint8 routingMode; address[] selectedMakers;
uint64 quoteDeadlineMs; uint64 expiresAtMs; RfqV2SigningFeeLimits feeLimits;
address builder; bytes32 privateTermsBlinding;
}
struct AuthorizeRfqV2 { RfqV2SigningTerms terms; uint64 nonce; }
struct RfqV2SigningPriceLeg { bytes32 instrumentId; uint128 priceMicros; }
struct RfqFirmQuoteV2SigningTerms {
bytes16 quoteId; bytes16 rfqId; bytes32 rfqTermsCommitment; address makerAccount;
uint64 makerGeneration; uint64 rfqGeneration; RfqV2SigningPriceLeg[] pricedLegs;
uint128 referenceQuantity; bool makerReceivesReferencePremium; uint128 referencePremiumMicros;
uint128 capacity; uint8 fillPolicy; uint128 minimumUnits; uint128 stepUnits;
uint8 withdrawalPolicy; RfqV2SigningFeeLimits feeLimits; uint64 expiresAtMs;
bytes32 protectionScope; bool hasSourceIndicativeQuote; bytes16 sourceIndicativeQuoteId;
}
struct AuthorizeRfqQuoteV2 { RfqFirmQuoteV2SigningTerms terms; uint64 nonce; }
struct RfqIndicativeQuoteV2SigningTerms {
bytes16 quoteId; bytes16 rfqId; bytes32 rfqTermsCommitment; address makerAccount;
RfqV2SigningPriceLeg[] pricedLegs; uint128 referenceQuantity;
bool makerReceivesReferencePremium; uint128 referencePremiumMicros;
uint128 indicatedCapacity; uint64 expiresAtMs;
}
struct PublishRfqIndicativeQuoteV2 { RfqIndicativeQuoteV2SigningTerms terms; }
struct RfqV2ExecutionAllocation { bytes16 quoteId; uint128 packageUnits; }
struct ExecuteRfqV2 {
bytes16 rfqId; bytes16 operationId; RfqV2ExecutionAllocation[] allocations;
uint128 maxFeeMicros; uint64 expiresAtMs; uint64 nonce;
}
struct InvalidateRfqV2 {
bytes16 rfqId; bytes16 quoteId; uint8 scope; uint8 reason;
bytes16 operationId; uint64 expiresAtMs; uint64 nonce;
}
struct RfqV2BulkWithdrawalTarget { bytes16 rfqId; bytes16 quoteId; }
struct BulkWithdrawRfqV2 {
address makerAccount; uint8 scope; bytes16 rfqId; RfqV2BulkWithdrawalTarget[] targets;
bytes16 operationId; uint64 expiresAtMs; uint64 nonce;
}
struct ScheduleRfqQuoteCancelV2 {
bytes16 operationId; address makerAccount; uint64 cancelAtMs; uint64 expiresAtMs; uint64 nonce;
}
struct ReplaceRfqV2 {
bytes16 rfqId; bytes16 oldObjectId; bytes16 newObjectId; uint8 scope;
bytes16 operationId; uint64 expiresAtMs; uint64 nonce;
}
struct RequestFirmRfqQuoteV2 {
bytes16 operationId; bytes16 rfqId; bytes16 indicativeQuoteId;
uint128 requestedUnits; uint64 expiresAtMs; uint64 nonce;
}
struct DeclineRfqV2 { bytes16 rfqId; address makerAccount; bytes16 operationId; uint64 expiresAtMs; uint64 nonce; }
struct AuthorizeRfqReadV2 { address account; uint64 issuedAtMs; uint64 expiresAtMs; uint64 nonce; }
Field encoding:
| Signed field | Encoding |
|---|---|
UUIDs (bytes16) | The 16 raw UUID bytes |
instrumentId | keccak256("instrument" ‖ symbol), symbol as UTF-8, for example BTC-20261225-100000-C |
takerSide | buy = 0, sell = 1 |
fillPolicy | full_size_once = 0 (minimum and step signed as 0), one_smaller_package = 1, repeated_partial = 2 |
executionMode | manual = 0, auto = 1. Manual signs autoMakerReceivesPremium false and autoPremiumLimitMicros 0 |
quotePolicy | firm_only = 0, indicative_allowed = 1 |
takerIdentity | anonymous = 0, disclosed = 1 |
routingMode | all_eligible = 0 (empty selectedMakers), selected_makers = 1 |
withdrawalPolicy | not_permitted = 0, permitted = 1 |
builder, protectionScope | Zero when absent |
sourceIndicativeQuoteId | hasSourceIndicativeQuote false and zero bytes when absent |
InvalidateRfqV2 | Cancel RFQ: scope 0, reason 0, zero quoteId. Withdraw quote: scope 1, reason 1 |
BulkWithdrawRfqV2.scope | quote_ids = 0 (zero rfqId, targets set), rfq = 1 (rfqId set, empty targets), all_open = 2 (both empty) |
ReplaceRfqV2.scope | Parent = 0, quote = 1 |
ScheduleRfqQuoteCancelV2.cancelAtMs | 0 removes the schedule |
terms_commitment is the EIP-712 struct hash of RfqV2SigningTerms. Every quote signs it as rfqTermsCommitment, so a quote cannot move to another RFQ or to altered terms.
Taker: create an RFQ
POST /private/rfqs
{
"terms": {
"id": "6d3f7a52-9c1e-4b8a-a0f4-2f71c9e0b5d1",
"taker_account": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"legs": [
{ "instrument": "BTC-20261225-100000-C", "taker_side": "buy", "quantity_micros_per_unit": "1000000" },
{ "instrument": "BTC-20261225-120000-C", "taker_side": "sell", "quantity_micros_per_unit": "1000000" }
],
"target": "5000000",
"fill_policy": { "mode": "repeated_partial", "minimum": "1000000", "step": "1000000" },
"execution_mode": "manual",
"auto_execution_limit": null,
"quote_policy": "indicative_allowed",
"taker_identity": "anonymous",
"routing": {
"mode": "selected_makers",
"makers": ["0x1111111111111111111111111111111111111111", "0x2222222222222222222222222222222222222222"]
},
"quote_deadline_ms": 1792159220000,
"expires_at_ms": 1792159260000,
"fee_limits": { "per_execution": "0", "cumulative": "0" },
"private_terms_blinding": "0x5be1c0b4d08e2f6c3a97f4e1d2b8a6c50f3e9d7a1b4c8e2f6a0d3b5c7e9f1a2b"
},
"nonce": 1792159200000,
"signature": "0x..."
}
201 response:
{
"rfq_id": "6d3f7a52-9c1e-4b8a-a0f4-2f71c9e0b5d1",
"status": "open",
"terms_commitment": "0x4ac1...",
"quote_deadline_ms": 1792159220000,
"expires_at_ms": 1792159260000
}
A 201 means the parent is signature-checked, stored, and routed. It is not an engine admission and reserves nothing. The parent nonce is consumed on the first fill; later partial fills reuse that binding.
| Field | Values | Rule |
|---|---|---|
legs | 1 to 10 | No repeated instrument. Instruments must be listed and open for RFQ, else 422 rfq_v2_unknown_instrument. Quotes price legs in this order |
target | package units | Nonzero |
fill_policy.mode | full_size_once, one_smaller_package, repeated_partial | See Fill policies |
execution_mode | manual, auto | auto requires auto_execution_limit; manual forbids it |
auto_execution_limit | {maker_receives_premium, premium_micros} or null | Premium bound per package (1,000,000 units), scaled by the executed size. maker_receives_premium: true caps the taker's debit; false floors the taker's credit |
quote_policy | firm_only (default when omitted), indicative_allowed | Gates indicative quotes and firm-ups |
taker_identity | anonymous, disclosed | Anonymous requires a nonzero private_terms_blinding |
routing.mode | all_eligible, selected_makers | Selected set: 1 to 128 addresses, sorted ascending by raw bytes, no zero or duplicate address |
quote_deadline_ms | ms | Must be before expires_at_ms. Quotes, firm-ups, and declines close at this time |
expires_at_ms | ms | Last instant any fill can land |
fee_limits | {per_execution, cumulative} | Builder fee ceilings. Nonzero values require builder; per_execution at most cumulative |
builder | {recipient} or omitted | Builder attribution address |
private_terms_blinding | bytes32 | Fresh CSPRNG output per anonymous RFQ |
Builder fees are zero at launch. The engine charges no V2 builder fee yet; fee_micros in every fill is "0". Signed fee ceilings still bind.
Anonymous is not unsigned. taker_account stays in the signed terms. Maker views drop it, along with routing, builder, and private_terms_blinding.
Maker: discover, quote, decline
Read grants
Private reads and the socket use a signed read grant, not the request signature.
{ "account": "0x1111...", "issued_at_ms": 1792159200000, "expires_at_ms": 1792159500000, "nonce": 1792159200000, "signature": "0x..." }
Sign AuthorizeRfqReadV2 with the account or an agent. Encode the JSON as base64url without padding (at most 1,024 bytes encoded) and send Authorization: Bearer <token>. The server refuses a grant issued in the future, already expired, or valid for more than 5 minutes (expires_at_ms - issued_at_ms <= 300000). Reuse a grant until it expires, then sign a new one.
Discovery
Makers learn about a new RFQ from an RfqV2Changed hint on the rfq_v2 channel or from the change feed. There is no RFQ payload on the socket. Read the parent:
GET /private/rfqs/{rfq_id}
Authorization: Bearer <grant>
The maker view carries parent.view: "maker" with the economic terms, taker_account only when the RFQ is disclosed, and the current fences:
"maker_quote_generation": { "maker_generation": 3, "rfq_generation": 0 }
Maker generations
Every firm quote signs two fence counters, maker_generation (account-wide) and rfq_generation (this maker on this RFQ). Both must equal the engine's current values at publication and at execution. A stale counter returns 409 rfq_v2_quote_revoked.
| Event | Effect |
|---|---|
Bulk withdrawal, all_open | maker_generation + 1 |
Bulk withdrawal, rfq | rfq_generation moves to a new value above every prior value for this maker. It can jump by more than 1 |
| Scheduled cancel fires | maker_generation + 1 |
| Withdrawal by quote ID | No generation change |
Never increment locally. After any withdrawal or a fired schedule, re-read the parent and sign the counters it returns. A quote signed against the old generation is dead even if it arrives after the withdrawal.
Quotes signed with withdrawal_policy: "not_permitted" ignore both fences and the scheduled cancel, and cannot be withdrawn. Use that policy only for quotes you are prepared to see filled until they expire.
Firm quote
POST /private/rfqs/{rfq_id}/quotes/firm
{
"terms": {
"id": "0e9b3c1a-7f45-4d2e-9b8c-1a2d3e4f5a6b",
"rfq_id": "6d3f7a52-9c1e-4b8a-a0f4-2f71c9e0b5d1",
"rfq_terms_commitment": "0x4ac1...",
"maker_account": "0x1111111111111111111111111111111111111111",
"maker_generation": 3,
"rfq_generation": 0,
"priced_legs": [
{ "instrument": "BTC-20261225-100000-C", "price_micros": "830000000" },
{ "instrument": "BTC-20261225-120000-C", "price_micros": "320000000" }
],
"reference_quantity": "1000000",
"maker_receives_reference_premium": true,
"reference_premium_micros": "510000000",
"capacity": "5000000",
"fill_policy": { "mode": "repeated_partial", "minimum": "1000000", "step": "1000000" },
"withdrawal_policy": "permitted",
"fee_limits": { "per_execution": "0", "cumulative": "0" },
"expires_at_ms": 1792159245000,
"protection_scope": null,
"source_indicative_quote_id": null
},
"nonce": 1792159209000,
"signature": "0x..."
}
Quote rules:
priced_legsmatch the parent's legs one for one, in order.fill_policyequals the parent's exactly.capacityis at most the parenttargetand on the step grid;reference_quantityis at mostcapacity. Afull_size_oncequote sets both to the parent target.expires_at_msis at most the parent'sexpires_at_ms. Publication closes at the parent'squote_deadline_ms.fee_limitsare at most the parent's.- Reference premium must be exact. Sum
price_micros * quantity_micros_per_unit * reference_quantity / 10^12over legs, adding legs the taker buys and subtracting legs the taker sells. A positive result means the maker receives: signmaker_receives_reference_premium: trueand the amount. Zero signsfalse. Each step-sized fill must also come out in whole micros. In the example: (830 - 320) x 1 x 1 = $510, maker receives. source_indicative_quote_idlinks a firm-up to its indicative quote (same maker,indicative_allowedparent, different ID).nullfor a direct firm quote.
201 response:
{
"firmness": "firm",
"quote_id": "0e9b3c1a-7f45-4d2e-9b8c-1a2d3e4f5a6b",
"status": "open",
"executable": true,
"remaining_capacity": "5000000",
"preflight": { "status": "passed", "checked_at_ms": 1792159209120 }
}
Preflight
Before relay, the API runs the maker's risk check at reference_quantity against the latest published risk snapshot.
preflight.status | Result |
|---|---|
passed | Stored and relayed |
unavailable_admitted | Snapshot missing or stale. Stored and relayed; execution does the real check |
rejected | 422 rfq_v2_preflight_rejected. Not relayed. The parent stays open; send a fresh quote before the deadline |
Preflight reserves no margin and promises no fill. The engine checks current state at the executed size. Before relay the API also rechecks the parent, your generations, and deadlines against authority, so a quote that raced a fill, a withdrawal, or a deadline is not published as executable.
Indicative quote
Only on indicative_allowed parents. Same terms shape as a firm quote minus generations, fill policy, withdrawal policy, fees, protection scope, and source link, with indicated_capacity in place of capacity. Body is {terms, signature}: no nonce.
POST /private/rfqs/{rfq_id}/quotes/indicative
Response: {"firmness":"indicative","quote_id":"...","status":"open","executable":false,"indicated_capacity":"5000000"}. No preflight, no reservation, never executable. Passing an indicative ID to an execution returns 400 rfq_v2_quote_not_executable.
Decline
POST /private/rfqs/{rfq_id}/declines
{ "operation_id": "01a14503-38dc-72bc-a2d1-beb3ab04b646", "maker_account": "0x1111...", "expires_at_ms": 1792159215000, "nonce": 1792159201500, "signature": "0x..." }
Returns {"operation_id":"...","status":"declined"}. Closes at the quote deadline. The taker sees every decline in declined_makers on the parent read; a maker sees only its own. Declines are never public.
Taker: firm-up
Ask the maker of an open indicative quote for executable terms.
POST /private/rfqs/{rfq_id}/quotes/{indicative_quote_id}/firm-up-requests
{ "operation_id": "01a14503-3ad0-76a3-b5b3-a3216cb18501", "requested_units": "3000000", "expires_at_ms": 1792159212000, "nonce": 1792159202000, "signature": "0x..." }
Returns {"operation_id":"...","status":"requested"}. Delivery only: it changes nothing on the indicative quote, reserves nothing, and does not oblige the maker. The maker answers with a fresh firm quote (new ID, new nonce, source_indicative_quote_id set), which may carry a different price, size, and expiry. The first successful linked firm quote marks the indicative firmed.
Execute
One quote
POST /private/rfqs/{rfq_id}/executions
{
"operation_id": "01a14503-61e0-7cac-9a5d-5ba4beb4fc1b",
"quote_id": "0e9b3c1a-7f45-4d2e-9b8c-1a2d3e4f5a6b",
"package_units": "2000000",
"max_fee_micros": "0",
"expires_at_ms": 1792159230000,
"nonce": 1792159212000,
"signature": "0x..."
}
200 response:
{
"operation_id": "01a14503-61e0-7cac-9a5d-5ba4beb4fc1b",
"status": "filled",
"executions": [
{
"execution_id": "9b2f...",
"quote_id": "0e9b3c1a-7f45-4d2e-9b8c-1a2d3e4f5a6b",
"maker_account": "0x1111111111111111111111111111111111111111",
"maker_provider": "0x1111111111111111111111111111111111111111",
"maker_signer": "0x3333333333333333333333333333333333333333",
"package_units": "2000000",
"fee_micros": "0"
}
],
"rfq_remaining_target": "3000000",
"quote_remaining_capacity": "3000000"
}
status: "filled" describes this operation. The parent can still have target left. maker_account takes the position and premium; maker_provider is the enrolled quote provider that routed it; maker_signer signed the quote.
Sweep
POST /private/rfqs/{rfq_id}/sweeps
{
"operation_id": "01a14503-6d98-71d5-a57b-33a5eb6093e6",
"allocations": [
{ "quote_id": "0e9b3c1a-7f45-4d2e-9b8c-1a2d3e4f5a6b", "package_units": "3000000" },
{ "quote_id": "c41d8e2b-5a3f-4e6d-8b9a-0f1e2d3c4b5a", "package_units": "2000000" }
],
"max_fee_micros": "0",
"expires_at_ms": 1792159230000,
"nonce": 1792159215000,
"signature": "0x..."
}
Allocation order and sizes are signed. The engine applies all allocations against one candidate state; if any fails, nothing fills. At most 1,024 allocations, no repeated quote, else 400 rfq_v2_too_many_targets. Same response shape, one entry per allocation.
What the engine checks
In one journaled command: both signatures and signer authority, the commitment link, unexpired parent, quote, and action, current generations and cancel schedule, the fill policy on both documents, remaining target and capacity, the leg grid, fee ceilings, self-trade (taker and maker resolving to the same account, on-chain manager, signer, or provider: 409 rfq_v2_self_trade), then current market, MMP, and both sides' margin.
| Refusal | Code | Retry |
|---|---|---|
| Size breaks fill policy or exceeds remaining capacity or target | 409 rfq_v2_capacity_unavailable | Re-read, resize, re-sign |
| Quote or parent consumed, withdrawn, cancelled, or replaced | 409 rfq_v2_authorization_terminal | No |
| Quote signed against an old generation | 409 rfq_v2_quote_revoked | No |
| Current margin, funding, or position refuses | 409 rfq_v2_risk_rejected | Same signed request may pass once state changes; no nonce was used |
Inconsistent with the signed documents (reused nonce, mismatched terms, leg grid, any amount at or above 10^12 in a fill record) | 409 rfq_v2_authority_rejected | Not unchanged |
| Engine not progressing or outcome unknown | 503 rfq_v2_authority_unavailable | Resend the identical signed bytes |
Fill record range. Each leg size, leg price, leg premium, package premium, and sweep total in a fill must be below 10^12 in magnitude. Larger executions are refused before they are journaled. Signed terms can be larger; split the execution.
Fill policies and repeated partial fills
| Policy | Fills allowed |
|---|---|
full_size_once | One fill of exactly the full target. The quote's capacity and reference_quantity equal the target |
one_smaller_package | One fill, at least minimum, a multiple of step. Whatever is left after it is unusable |
repeated_partial | Any number of fills, each at least minimum, a multiple of step, and within what is left |
minimum and step are at most the target, and both minimum and the target (and each quote's capacity) are multiples of step.
Repeated partials against one quote. Capacity is tracked per quote; target is tracked per parent. Each fill consumes both, and every response returns the authoritative remainders. A taker can hit the same firm quote repeatedly until its capacity, the parent target, or a deadline runs out, and can mix quotes from several makers across executions or within a sweep. There is no exception for a final remainder below minimum: once what is left is under minimum, the quote reads executable: false.
Auto execution. On an auto parent the API fills the first qualifying firm quote at the largest size both signed policies, the leg grid, and the remainders allow, within the signed premium bound. No taker execution request is needed. Each auto fill's operation ID is derived from the parent, the quote, and the quote's usage so far, so a retried trigger never double-fills, and each later partial is a new operation. If auto execution is pending, publication returns 503 rfq_v2_auto_execution_unavailable; retry the identical publication.
Cancel, withdraw, replace
All take {operation_id, expires_at_ms, nonce, signature} (UUIDv7 operation ID) unless shown.
| Action | Route | Signer | Response |
|---|---|---|---|
| Cancel RFQ | POST /private/rfqs/{rfq_id}/cancellations | Taker | 200 {operation_id, status: "cancelled"} |
| Withdraw quote | POST /private/rfqs/{rfq_id}/quotes/{quote_id}/withdrawals | Maker | 200 {operation_id, status: "withdrawn"} |
| Replace RFQ | POST /private/rfqs/{rfq_id}/replacements | Taker | 201, create response for the new parent |
| Replace quote | POST /private/rfqs/{rfq_id}/quotes/{quote_id}/replacements | Maker | 201, publish response for the new quote |
| Bulk withdrawal | POST /private/rfq-quote-withdrawals | Maker | 200 {operation_id, status: "withdrawn"} |
Replacement is never an in-place edit. The body is {operation_id, replacement, expires_at_ms, nonce, signature}: replacement is a complete, separately signed create or publish body with a new ID, and the outer signature is ReplaceRfqV2 binding old and new IDs. The old object goes terminal and the new one is admitted in one step.
Bulk withdrawal takes maker_account and a scope:
{ "mode": "quote_ids", "quote_ids": [{ "rfq_id": "...", "quote_id": "..." }] }
{ "mode": "rfq", "rfq_id": "..." }
{ "mode": "all_open" }
quote_ids holds at most 64 distinct pairs, else 400 rfq_v2_too_many_targets; it withdraws those quotes and leaves generations alone. rfq and all_open advance a generation (see Maker generations), which kills every withdrawable quote signed against the old value, including any still in flight. The acknowledgement follows the engine commit.
Dead-man switch: scheduled quote cancel
A maker arms an account-wide deadline. If it is not renewed, every permitted firm quote of that maker, on every RFQ, stops being executable at that time.
POST /private/rfq-quote-cancel-schedule
{
"operation_id": "01a14503-5628-789d-b927-3a1e9ac8d854",
"maker_account": "0x1111111111111111111111111111111111111111",
"cancel_at_ms": 1792159230000,
"expires_at_ms": 1792159220000,
"nonce": 1792159209000,
"signature": "0x..."
}
Response: {"operation_id":"...","cancel_at_ms":1792159230000}. Trust the returned value; a timed-out renewal may not have landed.
- Lead time:
cancel_at_msat least 5,000 ms after admission, else422 rfq_v2_deadline_too_short. No maximum. - Renew by sending a later
cancel_at_msbefore the current one arrives. Each request replaces the schedule. - Clear by omitting
cancel_at_ms(signed ascancelAtMs = 0). An explicit0is refused. If the old deadline is already due when you renew or clear, it fires first. - On fire:
maker_generation+ 1 and the schedule is removed. The deadline is also checked at publication and execution, so a due quote cannot fill in the gap before the fire is journaled. - Scope: account-wide, not per socket. A dropped connection cancels nothing. Parents and other makers' quotes are untouched.
not_permittedquotes are exempt. Indicative quotes were never executable.
Reads and the change feed
All reads need Authorization: Bearer <grant>. Missing or invalid grant: 401 rfq_v2_session_required.
| Route | Returns |
|---|---|
GET /private/rfqs | {rfqs, page, as_of_sequence}. Query: role (taker, maker, either; default either), status, limit (default 50, max 100), and cursor or after_sequence, not both |
GET /private/rfqs/{rfq_id} | One parent: rfq_id, status, terms_commitment, parent (tagged by view: taker or maker), target, remaining_target, quote_policy, maker_quote_generation (maker only), declined_makers (when present) |
GET /private/rfqs/{rfq_id}/quotes | {quotes, page, as_of_sequence}. Query: firmness, status, include_terminal, limit (max 100), cursor or after_sequence |
GET /private/rfqs/{rfq_id}/quotes/{quote_id} | One quote, tagged by firmness, with terms, status, executable, provider, signer, and for firm quotes remaining_capacity and preflight |
GET /private/rfqs/changes | {changes: [{rfq_id, sequence}], next_after_sequence, has_more}. Query: after_sequence, limit (1 to 100, default 100) |
GET /private/rfqs/{rfq_id}/activity | Aggregate counts only (deliveries, indicative and firm responses, firm-ups, declines, preflight rejections, withdrawals, no-response by deadline), never wallets or terms. The taker sees its RFQ's totals; a maker sees only its own. Non-participants get 403 rfq_v2_unauthorized |
page is {limit, next_cursor, has_more}. Parent statuses: open, partially_filled, filled, expired, cancelled, replaced. Quote statuses add closed, withdrawn, and firmed.
Visibility. A taker reads every quote on its RFQ. A maker reads parents routed to it and only its own quotes, never a competitor's. History is kept: terminal quotes stay readable, and include_terminal=false is a filter, not deletion. Old RFQs remain readable after the engine retires their usage records, with projected remainders and terminal statuses.
Check executable, not status. A quote can read open after its parent filled or was cancelled.
Expiry emits nothing. Passing a signed deadline creates no change-feed row and no socket hint. Refresh anything you display at its deadline.
Change feed recovery. The feed lists only RFQ IDs that changed for your account. Read the named parents and quotes, apply them, then persist next_after_sequence. Gaps in sequence are normal: other accounts' changes are omitted. Page until has_more is false.
WebSocket
Same /ws connection. The legacy Authenticate message and ?wallet= do not authorize rfq_v2.
{"type":"AuthenticateRfqV2","token":"<base64url read grant>"}
{"type":"RfqV2Authenticated","account":"0x1111111111111111111111111111111111111111"}
{"type":"Subscribe","channel":"rfq_v2"}
{"type":"Subscribed","channel":"rfq_v2"}
{"type":"RfqV2Changed","rfq_id":"6d3f7a52-9c1e-4b8a-a0f4-2f71c9e0b5d1","sequence":812045}
Subscribewithout a priorAuthenticateRfqV2fails: "RFQ V2 signed read grant required". The channel takes nosymbols,expiry, oroption_typefilters.- The grant is rechecked before each hint. Expiry or agent revocation stops delivery; sign a new grant and re-authenticate before
expires_at_ms. RfqV2Changedis a hint, not state. It carries an RFQ ID and a global sequence, nothing else. Hints can repeat. Fetch the parent and quotes.- Who gets a hint. Routed makers on creation. The taker and the quoting maker on that maker's quotes, firm-ups, and declines. The taker and every routed maker, traded or not, on fills and parent cancellation. The maker and the affected takers on withdrawals and fired cancels.
Run the feed alongside the socket. After subscribing, replay GET /private/rfqs/changes from your last applied sequence before trusting local state. Poll it while connected too, because a missing private hint is invisible in the global sequence. On reconnect, replay from the last applied cursor and de-duplicate.
Retries and ambiguous outcomes
- Create and publish: resend the identical signed body. Same ID, same bytes returns the original; changed bytes return
409 rfq_v2_idempotency_conflict. - Journaled actions: resend the identical body with the same
operation_idinside the 1-hour window. A different body under the same operation ID conflicts. 503 rfq_v2_authority_unavailableor a timeout on an execution: the fill may have committed. Resend the same bytes until you get the committed result or a definite refusal, then confirm with a parent read. Do not sign a new execution for the same intent until then.503 rfq_v2_projection_pending: the command committed; the result is not readable yet. Retry the read.429 rfq_v2_rate_limited: honorRetry-After. The ingress limit is 6,000 requests per minute per source IP.
Limits
| Limit | Value |
|---|---|
| Legs per RFQ | 10 |
| Selected makers | 128 |
| Allocations per sweep | 1,024 |
| Quote pairs per bulk withdrawal | 64 |
| Read grant lifetime | 5 minutes |
| Encoded read grant | 1,024 bytes |
| Operation ID window | 1 hour back, 30 seconds forward |
| Cancel schedule lead | 5 seconds minimum |
| Page size | 50 default, 100 max (change feed: 100 default) |
| Fill record amounts | Below 10^12 |
| Signed amounts | Up to 2^96 - 1 |
| V2 ingress rate | 6,000 requests per minute per source IP |
Error codes
| Code | HTTP | Meaning |
|---|---|---|
rfq_v2_disabled | 403 | V2 is off in this environment |
rfq_v2_not_allowlisted | 403 | V2 is limited to allowlisted accounts |
rfq_v2_not_enabled | 501 | Route not enabled on this host |
rfq_v2_session_required | 401 | Read needs a valid signed read grant |
rfq_v2_unauthorized | 403 | Signer not authorized for the account, or the account may not see this RFQ |
rfq_v2_not_found | 404 | Parent or quote does not exist for this account |
rfq_v2_invalid_signature | 400 | Signature malformed or does not recover a signer |
rfq_v2_invalid_operation_id | 400 | Operation ID not a UUIDv7 inside the window; generate a fresh UUIDv7 |
rfq_v2_invalid_request | 400 | Request cannot be served, including a version 8 operation ID |
rfq_v2_invalid_terms | 400 | Signed terms or action fields violate protocol policy |
rfq_v2_invalid_create | 400 | Create routes to more makers than one atomic delivery accepts |
rfq_v2_invalid_package_units | 400 | Package, leg, target, or fill-policy quantity zero, off grid, or out of range |
rfq_v2_invalid_maker_set | 400 | Selected makers empty, over 128, unsorted, or repeated |
rfq_v2_invalid_quote | 400 | Quote malformed or does not fit the parent |
rfq_v2_invalid_firm_up | 400 | Firm-up malformed or does not fit its indicative quote |
rfq_v2_invalid_decline | 400 | Decline malformed |
rfq_v2_invalid_action | 400 | Execution, sweep, cancel, or withdrawal malformed |
rfq_v2_invalid_replacement | 400 | Replacement malformed or does not fit the replaced object |
rfq_v2_too_many_targets | 400 | Sweep over 1,024 quotes or bulk withdrawal over 64 pairs |
rfq_v2_invalid_query | 400 | List or read query malformed |
rfq_v2_invalid_cursor | 400 | Cursor malformed or out of range |
rfq_v2_invalid_limit | 400 | Page limit out of range |
rfq_v2_path_mismatch | 400 | Path ID disagrees with the signed body |
rfq_v2_deadline_passed | 400 | A signed or quote deadline has passed |
rfq_v2_quote_not_executable | 400 | Execution named an indicative quote |
rfq_v2_unknown_instrument | 422 | Instrument not listed or not open for RFQ |
rfq_v2_no_eligible_makers | 422 | No eligible maker, or a selected maker is not eligible |
rfq_v2_preflight_rejected | 422 | Firm quote failed maker preflight; parent stays open |
rfq_v2_deadline_too_short | 422 | Cancel deadline under the 5-second lead |
rfq_v2_parent_closed | 409 | Parent cancelled, replaced, or filled first |
rfq_v2_idempotency_conflict | 409 | ID already bound to different signed terms |
rfq_v2_indicative_replaced | 409 | Indicative quote replaced first |
rfq_v2_quote_revoked | 409 | Quote signed before a maker withdrawal or fired schedule |
rfq_v2_replacement_conflict | 409 | Replacement target already used or changed |
rfq_v2_replacement_rejected | 409 | Engine refused the replacement |
rfq_v2_authorization_terminal | 409 | Quote or parent no longer executable |
rfq_v2_capacity_unavailable | 409 | Size exceeds remaining capacity or breaks the fill policy; refresh and resize |
rfq_v2_risk_rejected | 409 | Current risk refused; nothing applied, same request may pass later |
rfq_v2_authority_rejected | 409 | Inconsistent with the signed documents; final for that request |
rfq_v2_self_trade | 409 | Taker and maker resolve to the same principal |
rfq_v2_rate_limited | 429 | Rate limit exceeded |
rfq_v2_delivery_failed | 500 | Delivery store could not complete the operation |
rfq_v2_delivery_unavailable | 500, 503 | Delivery store unreachable; retry |
rfq_v2_authority_unavailable | 503 | Engine unavailable or outcome unknown; retry the same signed request |
rfq_v2_auto_execution_unavailable | 503 | Quote accepted, auto execution pending; retry the publication |
rfq_v2_projection_pending | 503 | Committed, result not yet readable; retry |
Rust SDK
V2 support is in the public hypercall-client crate (see Rust Crates for installation), in the rfq_v2 and rfq_v2_recovery modules, with wire types in hypercall-sdk-types. The public mirror picks up these modules with the V2 release; pin the release tag or commit once it is published and read signatures there.
HypercallClient has typed V2 calls: create_rfq_v2, publish_rfq_v2_firm_quote, publish_rfq_v2_indicative_quote, request_rfq_v2_firm_quote, decline_rfq_v2, execute_rfq_v2, sweep_rfq_v2, cancel_rfq_v2, withdraw_rfq_v2_quote, withdraw_rfq_v2_quotes, replace_rfq_v2, replace_rfq_v2_quote, schedule_rfq_v2_quote_cancel, and the reads get_rfq_v2, list_rfqs_v2, list_rfq_v2_changes, list_rfq_v2_quotes, get_rfq_v2_quote.
- Read grants:
HypercallWallet::sign_rfq_v2_read_grantandencode_rfq_v2_read_grant. - Socket:
connect_rfq_v2authenticates before subscribing and restores that order on reconnect.subscribe_rfq_v2andunsubscribe_rfq_v2manage the channel. - Change feed:
watch_rfq_v2_changespolls from a cursor, drainshas_morepages, and stops on authorization failure or grant expiry. - Selection:
select_best_rfq_v2_quotewithRfqV2BestQuoteCriteria::for_parentapplies the parent's fill policy, the leg grid, remainders, and expiry before ranking by direction-aware premium. - Recovery (
rfq_v2_recovery):sync_rfq_v2_mirrorreplays the feed and rereads RFQs past their deadlines;reconcile_rfq_v2_executionandreconcile_rfq_v2_sweepresend identical bytes after an ambiguous outcome (inside the 1-hour window);RfqV2MakerHoldtracks a maker's open exposure until fill, competitor exhaustion, or terminal evidence.
The SDK sends signed bodies. Build and sign the V2 action messages with your own EIP-712 signer using the type definitions above.
Also in this release: RPI outcomes on /order
This affects POST /order with route: "best_execution", not V2. If a retail price improvement (RPI) execution against an RFQ quote has an unknown outcome, the order call returns 504, "RPI execution outcome is pending; the order may have filled, do not resubmit", and does not fall back to another quote or the book. Do not resubmit. Check fills and order status.
The pending execution is now stored before it is sent and survives an API restart. A restarted server resubmits the exact command under its original journal ID; the engine answers a repeat from its committed record and never fills twice. The record is cleared once the outcome is read.