The Request-for-Quote (RFQ) protocol lets makers stream quote requests and submit quotes over WebSocket. The SDK exposes the public RFQ feed, private delivery streams, and maker quote methods.
Availability: RFQ streams and methods are available in production for beta testing. Quoting requires an eligible account with quoting capabilities enabled.
Public discovery (rfqs() stream) requires no authentication and runs in browser and server environments. Quote submission, confirmation, and withdrawal require authenticated server sessions.
The flow
A taker submits an RFQ, which broadcasts to the public rfqs stream.
A maker submits a quote in response (rfq.submitQuote).
The taker accepts the quote; the winning maker receives an event on rfqDeliveries.
The maker confirms execution (rfq.confirmQuote) or withdraws active quotes (rfq.withdrawQuote).
Complete maker example with a pricing hook
This server-side example separates venue interactions from quoting logic:
calculateFmv: Fair-market value calculation.
quotePolicy: Risk, inventory, spread, and collateral filters. Return null to pass on an RFQ.
lastLook: Verification hook before final fill confirmation.
The example uses decimal.js for precise financial calculations. Keep prices and quantities as decimal strings; do not convert them to JavaScript number values.
Code
import Decimal from "decimal.js";import { createClient, HmacAuth, WebSocketRequestError, type RfqPrivateDelivery, type RfqPublicEvent,} from "@gemini-markets/sdk/server";type QuoteDecision = { price: string; quantity: string; validUntil?: number | bigint; clientId?: string;};type QuoteState = { quoteId: string; decision: QuoteDecision; expiresAt: bigint;};const client = await createClient({ env: "sandbox", auth: new HmacAuth({ apiKey: process.env.GEMINI_API_KEY!, apiSecret: process.env.GEMINI_API_SECRET!, }),});// Implement this against your own pricing model or service. Keep the return// value as a decimal string until it enters Decimal.declare const pricingModel: { comboFairValue(legs: RfqPublicEvent["l"]): Promise<string>;};async function calculateFmv(rfq: RfqPublicEvent): Promise<Decimal> { return new Decimal(await pricingModel.comboFairValue(rfq.l));}function quantityFor(rfq: RfqPublicEvent, price: Decimal): string { if (rfq.q) return rfq.q; if (!rfq.n) throw new Error(`RFQ ${rfq.r} has neither quantity nor notional`); // The default quantity grid is whole contracts. Use the configured grid if // your venue setup permits fractional RFQ quantities. return new Decimal(rfq.n).div(price).ceil().toFixed(0);}type QuotePolicy = (input: { rfq: RfqPublicEvent; fmv: Decimal;}) => Promise<QuoteDecision | null>;const quotePolicy: QuotePolicy = async ({ rfq, fmv }) => { // APPLICATION HOOK: replace this with your own inventory/risk/spread logic. // Examples: reject when inventory is too concentrated, widen the spread // during volatile markets, cap quantity by collateral, or return null when // a kill switch is active. This example applies a fixed 1-cent adjustment. const price = fmv.plus("0.01").toDecimalPlaces(4); if (price.lte(0) || price.gte(1)) return null; return { price: price.toFixed(4), quantity: quantityFor(rfq, price), clientId: `rfq-${rfq.r}`, // Omit validUntil to use the service-assigned quoting-window close (`w`). };};type LastLook = (input: { delivery: RfqPrivateDelivery; quote: QuoteState;}) => Promise<boolean>;const lastLook: LastLook = async ({ delivery, quote }) => { // APPLICATION HOOK: re-check inventory, collateral, limits, and the current // model before confirming. Return false to decline the fill. The SDK does // not confirm on your behalf. console.log("last look", delivery.r, quote.decision.price); return true;};const RFQ_STATE_TTL_MS = 15 * 60 * 1000;const MAX_TRACKED_RFQS = 10_000;const MAX_TRACKED_DELIVERIES = 10_000;const MAX_CONFIRM_RATE_LIMIT_RETRIES = 3;const CONFIRM_RATE_LIMIT_BACKOFF_MS = [100, 250, 500];const terminalRfqStates = new Set(["FINALIZED", "CANCELLED", "EXPIRED", "FAILED"]);const terminalDeliveryTransitions = new Set(["CONFIRMED", "DECLINED", "FINALIZED", "FAILED"]);const terminalQuoteStatuses = new Set(["WITHDRAWN", "EXPIRED", "WON", "LOST"]);const quotes = new Map<string, QuoteState>();const consideredRfqs = new Map<string, number>();type DeliveryRecord = { state: "in-flight" | "handled" | "failed"; updatedAt: number;};const deliveryRecords = new Map<string, DeliveryRecord>();function pruneState(now = Date.now()): void { const nowMs = BigInt(now); for (const [rfqId, quote] of quotes) { if (quote.expiresAt <= nowMs) quotes.delete(rfqId); } for (const [rfqId, expiresAt] of consideredRfqs) { if (expiresAt <= now) consideredRfqs.delete(rfqId); } for (const [deliveryId, record] of deliveryRecords) { if (record.state === "handled" && record.updatedAt + RFQ_STATE_TTL_MS <= now) { deliveryRecords.delete(deliveryId); } } while (consideredRfqs.size > MAX_TRACKED_RFQS) { const oldest = consideredRfqs.keys().next().value; if (oldest === undefined) break; consideredRfqs.delete(oldest); } while (deliveryRecords.size > MAX_TRACKED_DELIVERIES) { const oldest = [...deliveryRecords].find(([, record]) => record.state !== "in-flight")?.[0]; if (oldest === undefined) break; deliveryRecords.delete(oldest); }}// Call this only after querying the RFQ/order state for a failed mutation.// Keep the delivery deduplicated if the mutation was applied; otherwise allow// a redelivery to retry it safely.function reconcileFailedDelivery(deliveryId: string, mutationApplied: boolean): void { const record = deliveryRecords.get(deliveryId); if (record?.state !== "failed") return; if (mutationApplied) { deliveryRecords.set(deliveryId, { state: "handled", updatedAt: Date.now() }); } else { deliveryRecords.delete(deliveryId); }}async function quoteRfq(rfq: RfqPublicEvent): Promise<void> { pruneState(); if (rfq.w !== undefined && BigInt(rfq.w) <= BigInt(Date.now())) return; const fmv = await calculateFmv(rfq); const decision = await quotePolicy({ rfq, fmv }); if (!decision) return; // Pricing and risk checks can take time; never submit after the window closes. if (rfq.w !== undefined && BigInt(rfq.w) <= BigInt(Date.now())) return; const response = await client.websocket.private.rfq.submitQuote({ rfqId: rfq.r, price: decision.price, quantity: decision.quantity, ...(decision.validUntil === undefined ? {} : { validUntil: decision.validUntil }), ...(decision.clientId === undefined ? {} : { clientId: decision.clientId }), }); if (!response.result) throw new Error(`submitQuote returned no result for ${rfq.r}`); quotes.set(rfq.r, { quoteId: response.result.quoteId, decision, expiresAt: BigInt(Date.now()) + BigInt(RFQ_STATE_TTL_MS), });}class UnknownMutationOutcome extends Error { constructor(cause: unknown) { super("RFQ confirmation outcome is unknown; reconcile before retrying", { cause }); this.name = "UnknownMutationOutcome"; }}function sleep(milliseconds: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, milliseconds));}async function confirmQuoteWithRetry(input: { rfqId: string; quoteId: string; confirm: boolean;}): Promise<void> { for (let attempt = 0; ; attempt += 1) { try { await client.websocket.private.rfq.confirmQuote(input); return; } catch (error) { if (!(error instanceof WebSocketRequestError)) throw error; // A rate-limit response means the request was rejected before the // mutation ran, so a short bounded retry is safe. Other 4xx responses // are definitive business rejections; 5xx responses have an unknown // mutation outcome and must be reconciled instead of retried. if (error.status >= 400 && error.status < 500 && error.status !== 429) { throw error; } if (error.status !== 429) throw new UnknownMutationOutcome(error); if (attempt >= MAX_CONFIRM_RATE_LIMIT_RETRIES) throw new UnknownMutationOutcome(error); await sleep(CONFIRM_RATE_LIMIT_BACKOFF_MS[attempt] ?? 500); } }}async function handleAccepted(delivery: RfqPrivateDelivery): Promise<void> { if ( terminalDeliveryTransitions.has(delivery.x) || terminalRfqStates.has(delivery.S) || (delivery.qs !== undefined && terminalQuoteStatuses.has(delivery.qs)) ) { quotes.delete(delivery.r); return; } if (delivery.x !== "ACCEPTED" || !delivery.q) return; const quote = quotes.get(delivery.r); if (!quote || quote.quoteId !== delivery.q) return; const confirm = await lastLook({ delivery, quote }); try { await confirmQuoteWithRetry({ rfqId: delivery.r, quoteId: delivery.q, confirm, }); } catch (error) { if (error instanceof WebSocketRequestError) { // A terminal business rejection is definitive. Do not redeliver this // quote, but preserve 5xx/unknown outcomes for reconciliation below. quotes.delete(delivery.r); console.error("RFQ confirmation was rejected", delivery.r, error); return; } throw new UnknownMutationOutcome(error); } quotes.delete(delivery.r);}async function processDelivery(delivery: RfqPrivateDelivery): Promise<void> { pruneState(); const existing = deliveryRecords.get(delivery.i); if (existing?.state === "handled" || existing?.state === "in-flight" || existing?.state === "failed") return; if (deliveryRecords.size >= MAX_TRACKED_DELIVERIES) { console.error("RFQ delivery deduplication cache is full; reconcile before processing", delivery.i); return; } deliveryRecords.set(delivery.i, { state: "in-flight", updatedAt: Date.now() }); try { await handleAccepted(delivery); deliveryRecords.set(delivery.i, { state: "handled", updatedAt: Date.now() }); } catch (error) { if (error instanceof UnknownMutationOutcome) { // Keep the delivery suppressed until an operator reconciles the // mutation outcome; an automatic retry could duplicate a confirmation. deliveryRecords.set(delivery.i, { state: "failed", updatedAt: Date.now() }); console.error("RFQ confirmation needs reconciliation before retrying", delivery.i, error); } else { // lastLook failed before the mutation was sent, so a redelivery may // safely retry it. Do not finalize deduplication in that case. deliveryRecords.delete(delivery.i); console.error("RFQ delivery handling failed before mutation; retrying on redelivery", delivery.i, error); } } pruneState();}const rfqs = client.websocket.public.rfqs();const deliveries = client.websocket.private.rfqDeliveries({ scope: "account" });rfqs.on("message", (rfq) => { pruneState(); if (terminalRfqStates.has(rfq.S)) { quotes.delete(rfq.r); consideredRfqs.set(rfq.r, Date.now() + RFQ_STATE_TTL_MS); return; } if (rfq.S !== "OPEN" || consideredRfqs.has(rfq.r)) return; consideredRfqs.set(rfq.r, Date.now() + RFQ_STATE_TTL_MS); // Stream listeners should stay short; mutations are intentionally not // retried because replaying a quote could create an unexpected position. void quoteRfq(rfq).catch((error) => { console.error("RFQ quote failed", rfq.r, error); });});deliveries.on("message", (delivery) => { // Authenticated lifecycle delivery is at-least-once. A delivery is marked // handled only after its work succeeds; unknown mutation outcomes stay in // a failed state until reconciled instead of being retried blindly. void processDelivery(delivery);});await Promise.all([rfqs.ready, deliveries.ready]);
This process is now listening for new auctions and maker acceptances. Close the
client during application shutdown with client.close(). Because RFQ
mutations are one-shot and the quote is immutable, treat an unknown submission
or confirmation result as an operational event to reconcile rather than blindly
retrying it.
const rfqs = client.websocket.public.rfqs();rfqs.on("message", (event) => { // event.r — RFQ ID // event.l — legs (RfqLeg[]): each has c (contract), o (side), and s (leg's own symbol, when available) // event.S — lifecycle state // event.q — quantity (optional) // event.s — symbol (optional) console.log("RFQ", event.r, "state", event.S);});await rfqs.ready;
Submitting a quote
Code
const quote = await client.websocket.private.rfq.submitQuote({ rfqId: event.r, price: "0.65", // decimal string quantity: "100", // decimal string validUntil: 1710547200000n, // optional, millisecond timestamp (bigint) clientId: "maker-fill-123", // optional, returned as `c` on the maker fill});// quote: RfqSubmitQuoteResponse
RfqSubmitQuoteParams:
Field
Type
Required
Meaning
rfqId
string
Yes
The RFQ being quoted
price
string
Yes
Quote price (decimal string)
quantity
string
Yes
Quote quantity (decimal string)
validUntil
number | bigint
No
Expiry (millisecond timestamp)
clientId
string
No
Client order ID for tracking the maker fill; returned as c in the authenticated orderUpdate event. Printable ASCII, maximum 36 characters. Keep it unique per maker account.