Skip to main content
Skip to main content

Quoting xStocks

This page is the off-chain half of the integration: everything between receiving an RFQ and holding a settlement authorization. The loop a maker runs is:

  1. Authenticate and mint API credentials.
  2. Open the WebSocket at wss://rfq-api.silhouette.exchange/v1/rfq/ws and authenticate it with an in-band auth frame.
  3. Subscribe to openRfqs for each instrument you quote, and to quoteStatus.
  4. Receive rfq frames as takers raise RFQs on your instruments.
  5. Price each RFQ and submit a quote with POST /v1/rfq/maker/quotes.
  6. Watch quoteStatus for the outcome.
  7. On SELECTED, settle on-chain; see the settlement page.

The transport split is absolute: every client-initiated mutation goes over REST; the WebSocket is read-only. The socket carries the handshake, the subscriptions, and the server's pushes, nothing else. There is no quote frame and no cancel frame; a quote is submitted and cancelled over HMAC-signed REST, and its outcome comes back as a push.

Credentials

Maker status is a property of the Silhouette account, not of the credential: Silhouette activates the account as a maker during onboarding, and any API key minted by that account is maker-scoped from then on. The maker-only topics and endpoints reject accounts that are not active makers.

Mint credentials with the standard login flow and sign every private request as described in signing requests. Two facts worth internalising before automating: a signature's timestamp must be fresh within a 30-second window, and every authentication failure (missing header, bad signature, stale timestamp) is a 401.

Connecting

Open wss://rfq-api.silhouette.exchange/v1/rfq/ws. Frames are JSON tagged with a kind discriminator. The first frame the client sends is auth:

{
"kind": "auth",
"accessKey": "<access-key>",
"signedAt": 1716284399000,
"signature": "<base64 HMAC-SHA256>"
}

The signature is over a canonical GET /v1/rfq/ws with an empty body, the same recipe as a signed REST request, so the canonical string is "{signedAt}\nGET\n/v1/rfq/ws\n". signedAt plays the role the Silhouette-API-Timestamp header plays on REST: it must fall within 30 seconds of receipt, be unique per access key, and be assigned in increasing order at issuance.

The server accepts the handshake with a health frame:

{ "kind": "health", "userId": "0193b6f1-7c10-7d6c-8000-abc123456789", "isMaker": true }

isMaker reflects whether the account is registered as an active market maker; a maker integration should treat isMaker: false here as a configuration failure, since every subscription that matters on this page will be refused.

Keepalive is application-level: send {"kind": "ping"} roughly every 15 seconds and the server answers {"kind": "pong"}. The socket is authenticated once; later frames carry no signature. A subscription lasts only for the life of the connection, so a reconnect needs a fresh auth frame and fresh subscriptions.

Subscribing

Dispatch is opt-in: after health the server sends nothing until the client subscribes. A maker holds two subscriptions: openRfqs per instrument, and quoteStatus once:

{ "kind": "subscribe", "topic": "openRfqs", "instrumentId": "XTSLA-USDC-SPOT" }
{ "kind": "subscribe", "topic": "quoteStatus" }

Each is acknowledged with a subscribed frame echoing the selector:

{ "kind": "subscribed", "topic": "openRfqs", "instrumentId": "XTSLA-USDC-SPOT" }

The openRfqs subscription is not just a data feed. Holding the openRfqs subscription for an instrument is what makes a maker eligible to be quoted on it, together with the standing eligibility a maker earns at onboarding. All three conditions must hold: an active maker record, approval for the instrument's pair, and at least one operated settlement adapter, on top of the live subscription. Silhouette dispatches each RFQ to every maker subscribed to its instrument. Publishing price ladders is a separate concern and is not a precondition for receiving RFQs.

Eligibility is re-checked when each RFQ is pushed, not only at subscribe. A maker whose approval or adapter registration lapses mid-session stops receiving RFQs on a subscription that was accepted earlier and is still open.

The two topics replay differently on subscribe. openRfqs is live-only: nothing is replayed, and a maker that reconnects mid-auction recovers open RFQs over REST (GET /v1/rfq/maker/requests). quoteStatus replays the maker's recent quotes on subscribe, then streams every later transition. This is what makes it safe to lose the socket, covered under tracking the outcome.

A subscribe to either topic from an account that does not qualify is refused with a recoverable error frame, and the socket stays open:

{ "kind": "error", "code": "UNSUPPORTED_CHANNEL", "message": "not a registered market maker" }

Seven error codes exist, and the code names the condition rather than what happens to the socket:

CodeDisposition
AUTH_REQUIREDCloses the session.
INVALID_MESSAGECloses the session, unless it answers a frame sent out of sequence.
UNSUPPORTED_CHANNELRefuses the frame, socket stays open.
RATE_LIMITEDRefuses the frame, socket stays open.
INVALID_INSTRUMENT_IDRefuses the frame, socket stays open.
UNKNOWN_INSTRUMENTRefuses the frame, socket stays open.
UNAVAILABLERefuses the frame, socket stays open. Nothing changed, so retry it.

INVALID_MESSAGE covers a malformed or unrecognized frame, including any write-shaped message, since the socket carries no mutations. The maker price-ingest socket sends errors in this same envelope, so one reader dispatching on kind handles both sockets.

Receiving an RFQ

Each open RFQ on a subscribed instrument arrives as an rfq frame carrying the OpenRfq resource:

{
"kind": "rfq",
"id": "rfq_0193b6f17c107d6c8000abc123456789",
"instrumentId": "XTSLA-USDC-SPOT",
"side": "BUY",
"baseQty": "1",
"autoAccept": false,
"auctionEndsAt": 1716284399750,
"settlementDeadline": 1716284430,
"createdAt": 1716284279750
}

Note the field is id, not rfqId, and it carries the rfq_ prefix; send it back verbatim as the quote's rfqId and never parse it. The frame carries what a maker needs to price the trade and nothing more; the taker's price bound, where one exists, deliberately stays private to the taker.

  • auctionEndsAt is unix milliseconds: the instant the RFQ stops accepting quotes and the winner is selected. The window is the taker's choice, so drive all timing off each RFQ's own auctionEndsAt rather than assuming a fixed length.
  • settlementDeadline is unix seconds: auctionEndsAt rounded up to the next whole second plus the operator's settlement headroom. It is the latest instant a win may settle. An inventory quote's Permit2 authorization is signed with exactly this value; an xChange quote's is signed with the expiry Backed stamped on its own swap, which must fall at or before it. Never derive it from auctionEndsAt yourself.
  • autoAccept says how the winner is chosen, not when. A taker may accept a quote early in either mode, so any quote can win before auctionEndsAt. With autoAccept: true the best conforming quote is selected at the auction end if nobody accepted sooner, and the taker's funds are locked at submission; with false only an explicit acceptance fills the request, and funds lock at acceptance.
  • baseQty is the quantity to quote on, in base-token units, and it has a side-dependent subtlety. On a SELL the taker fee is carved out of the base the taker delivers, so baseQty is what the maker actually buys; a quote priced on any other quantity cannot win. On a BUY it is the full size the taker asked for.

GET /v1/rfq/maker/requests lists the same OpenRfq objects, cursor-paginated, for the instruments the maker is approved for. It serves a maker that prefers to poll, and it is the recovery path after a disconnect, since openRfqs replays nothing on subscribe.

Submitting a quote

Submit with POST /v1/rfq/maker/quotes, HMAC-signed like every private request. The required body fields are rfqId, instrumentId, side, settlementMode, makerPays, and makerReceives. The optional acceptableForMs sets the maker's own acceptance window, covered under validity. There is no idempotencyKey on a maker quote; re-submission has replace semantics instead, described below.

The two legs are named from the maker's perspective and never flip meaning with the RFQ's side: makerPays is what the maker delivers, makerReceives is what it is paid. On a BUY the maker pays the base token and receives the quote token; on a SELL the reverse.

An inventory-mode quote (XSTOCKS_INVENTORY) is the base body and nothing more, with no settlement payload:

{
"rfqId": "rfq_0193b6f17c107d6c8000abc123456789",
"instrumentId": "XTSLA-USDC-SPOT",
"side": "BUY",
"settlementMode": "XSTOCKS_INVENTORY",
"makerPays": { "token": "XTSLA", "amount": "1" },
"makerReceives": { "token": "USDC", "amount": "200" },
"acceptableForMs": 30000
}

An xChange-mode quote (XSTOCKS_XCHANGE) carries the Backed-signed swap in the settlement object: the swapMessage you requested from Backed, and Backed's signature over it:

{
"rfqId": "rfq_0193b6f17c107d6c8000abc123456789",
"instrumentId": "XTSLA-USDC-SPOT",
"side": "BUY",
"settlementMode": "XSTOCKS_XCHANGE",
"makerPays": { "token": "XTSLA", "amount": "1" },
"makerReceives": { "token": "USDC", "amount": "200" },
"acceptableForMs": 30000,
"settlement": {
"swapMessage": {
"quoteId": "0x5555555555555555555555555555555555555555555555555555555555555555",
"expiration": "1716284425",
"incomingTransfer": {
"from": "0x1111111111111111111111111111111111111111",
"to": "0x2222222222222222222222222222222222222222",
"token": "0x3333333333333333333333333333333333333333",
"amount": "199000000"
},
"outgoingTransfer": {
"from": "0x2222222222222222222222222222222222222222",
"to": "0x6666666666666666666666666666666666666666",
"token": "0x4444444444444444444444444444444444444444",
"amount": "1000000000000000000"
}
},
"backedSignature": "0x…"
}
}

The addresses are placeholders: 0x1111… stands for your wrapper, 0x2222… for Backed's transfer account (a value exchanged at onboarding), 0x3333…/0x4444… for the USDC and xTSLA contracts, and 0x6666… for Silhouette's omnibus. expiration is unix seconds, and it is Backed's value, not ours: Backed stamps it on the swap it signs, so relay it unchanged rather than overwriting it with the RFQ's settlementDeadline. It must still be in the future and at or before that deadline — above it sits five seconds inside one. Silhouette cross-checks every field of the swapMessage against the quote and the values registered at your onboarding before it will sign: parties and tokens for equality, the expiration against the deadline, and the two amounts directionally, as below. The on-chain meaning of each field is on the settlement page.

The amounts are checked as bounds, not equalities, and above they differ on purpose. The taker pays the quoted 200 USDC, only 199 of it buys the stock, and the 1 USDC difference is your spread, left in your wrapper. incomingTransfer.amount may be at most the quoted taker amount, and outgoingTransfer.amount at least the quoted maker amount. To take your spread on the outgoing leg instead, ask Backed for more than you quoted and set outgoingTransfer.to to your own wrapper: the omnibus is owed the quote exactly, so a surplus sent to it is refused. This example delivers the exact quoted amount, so it names the omnibus directly.

Two levels of token identity

The single most important thing on this page: an xChange quote names its tokens at two levels, and they must agree.

The quote's own legs name tokens by canonical uppercase symbol ("USDC", "XTSLA"), with amounts as human-unit decimal strings. Symbols are the identity the whole account-facing API speaks: balances, instruments, quotes, everything.

The inner swapMessage names ERC-20 addresses, with amounts in raw base units at each token's on-chain decimals. It has to: Backed signs over those bytes and the AtomicSwap contract reads them, so nothing about them may be re-shaped in transit.

Silhouette resolves each leg's symbol and cross-checks the resolved address and converted amount against the matching swapMessage transfer at intake, so a maker that builds the two levels from different tokens is refused on submission, not discovered at settlement. Resolve a symbol to its contract address and decimals with GET /v1/rfq/tokens.

One further identity to keep separate: swapMessage.quoteId is Backed's own per-quote identifier, living inside Backed's replay protection. It is distinct from Silhouette's quoteId (the qt_-prefixed id), which becomes the Permit2 nonce. The two never mix.

Validity and mode consistency

  • Mode consistency. An inventory quote must omit settlement; an xChange quote must carry it. A mismatch between the declared mode and the payload's presence or absence is refused at intake, as is a mode the account does not operate.
  • Quote validity. acceptableForMs is a duration in milliseconds, counted from the receivedAt Silhouette stamps on the quote, and it is optional. Omit it and no maker cutoff applies: the quote stands until the auction closes. Send it and anything below the venue floor (1000 ms by default) is refused. There is no upper bound, because a window outlasting the auction never bites; the auction close bounds acceptance either way. A duration rather than an instant means the maker's own clock can never shorten its window, and a re-quote restamps receivedAt, so each submission restarts the window rather than inheriting the first one.
  • Settlement timing is not yours to set. The quote carries no settlement deadline. Silhouette publishes one per RFQ as settlementDeadline and refuses any swap whose expiry runs past it. In xChange mode the expiry itself is Backed's: Silhouette adopts it and signs the Permit2 with it, because your wrapper rebuilds its own Permit2 deadline from that same field.

Responses

StatusMeaning
202The quote passed validation and joined the RFQ's collection. It also means accepted pending validation, when the verdict did not arrive in time or was lost after the quote had been taken in.
400Refused; the message names the reason.
503The quote never reached validation, or validation faulted before recording anything. Nothing was persisted and a retry is safe.

A quote accepted pending validation may or may not have joined the auction. Read GET /v1/rfq/maker/quotes to find out rather than re-submitting blind: a submission carries no idempotency key, so a fresh one replaces whatever already stands for the same adapter, which may be the very terms you are trying to confirm.

The refusal reasons behind a 400:

  • an unknown rfqId, or one no longer open;
  • an instrument the maker is not approved for;
  • a settlementMode naming an adapter the maker does not operate;
  • an instrumentId or side disagreeing with the RFQ's;
  • a missing settlement payload on a mode that requires one;
  • a settlement payload on a mode that settles from the maker's own inventory;
  • a payload that is not the shape its settlementMode routes it to;
  • a payload that fails validation against the third party and the quote's own economics.

Re-pricing

A maker holds one live quote per adapter on an RFQ. Re-submitting for the same RFQ and the same adapter replaces that quote's terms in place; that is how a maker re-prices as the market moves inside the auction window. A maker operating both modes may quote one RFQ twice, once per mode, and the two quotes compete independently.

Winning

Selection at the deadline (or at an early taker accept) checks each candidate against the RFQ. To be selectable a quote must have both amounts non-zero and the leg matching the RFQ's baseQty exact; and where the RFQ carries a price bound, on a BUY makerReceives.amount must be at or under the limit net of the frozen taker fee (the bound limits the taker's all-in cost, and the fee is carved out before makers price the RFQ), and on a SELL makerPays.amount must be at or over it. A quote failing these is passed over and selection re-runs across the remaining conforming quotes.

Tracking the outcome

Every transition of one of the maker's quotes arrives as a quoteStatus frame carrying the maker's full view of the quote: the MakerQuote. The statuses, and what each means for the maker:

StatusMeaning
SUBMITTEDAccepted into the open auction and competing.
SELECTEDWon. The frame carries the settlement authorization; settle it on-chain.
NOT_SELECTEDA conforming quote beaten by the winner. Nothing to do.
EXPIREDStill submitted when the auction closed with no winner. Nothing to do.
SETTLEDThe fill was observed on-chain and the trade is done.
FAILEDA SELECTED quote whose RFQ was later terminated without settling.
CANCELLEDThe maker retracted the quote before selection.

QuoteStatus is an open set: values may be added, so a client must tolerate one it does not recognize rather than failing to parse the frame. PENDING_DELIVERY and DEFAULTED exist on the shared enum but belong to promised-delivery settlement on other venues; they are not part of the xStocks flow, which runs SUBMITTED → SELECTED → SETTLED.

A SELECTED frame:

{
"kind": "quoteStatus",
"rfqId": "rfq_0193b6f17c107d6c8000abc123456789",
"quoteId": "qt_0193b6f17c107d6c8000aaa123456789",
"instrumentId": "XTSLA-USDC-SPOT",
"side": "BUY",
"status": "SELECTED",
"makerPays": { "token": "XTSLA", "amount": "1" },
"makerReceives": { "token": "USDC", "amount": "200" },
"acceptableForMs": 30000,
"receivedAt": 1716284400000,
"spender": "0x1111111111111111111111111111111111111111",
"permitSignature": "0x…",
"settlementDeadline": 1716284430
}

The legs name their tokens by symbol like everywhere else on the API; resolve a symbol to its contract with GET /v1/rfq/tokens when acting on-chain. The three settlement fields (spender, permitSignature, settlementDeadline) appear only from SELECTED onward, and they stay on the quote through the statuses that follow. Capture them the moment the quote reaches SELECTED and settle from that snapshot.

  • settlementDeadline is unix seconds, not milliseconds, because Permit2 signs it into its EIP-712 payload and the chain compares it to block.timestamp. It is the same figure the rfq frame published, echoed here so a maker settling from the quote alone never has to hold the RFQ.
  • acceptableForMs echoes the window the maker submitted, or is absent when the submission named none.
  • spender is your wrapper's address, bound into the Silhouette-signed authorization. It must be msg.sender when Permit2 is called, which means your wrapper submits the transaction; no other contract can consume the signature.

Durability is the point of the channel. Each transition is captured in the transaction that commits it and delivered in per-account commit order, and quoteStatus replays the maker's recent quotes on subscribe, so a maker whose socket dropped re-fetches every missed outcome on reconnect, including the Permit2 authorization on a quote that reached SELECTED while it was disconnected. A maker never loses the settlement authorization to a transient disconnect. GET /v1/rfq/maker/quotes is the REST equivalent, cursor-paginated with an optional ?status= filter, carrying the same MakerQuote view, permit fields included.

A SETTLED frame carries no settlement transaction hash: the maker submitted the transaction and already holds it. The RFQ's settlement hash lives on the taker-side rfqStatus view's txHash.

Settling a SELECTED quote (the wrapper call, the Permit2 binding, and the Filled event) is the settlement page.

Cancelling and last look

POST /v1/rfq/maker/quotes/{quoteId}/cancel retracts a still-SUBMITTED quote before selection, and so before the maker holds any signed permit. The quote is marked CANCELLED, dropped from the auction, and the response carries the CANCELLED quote. To re-price instead of withdrawing, don't cancel: submit a fresh quote for the same RFQ and adapter, which replaces the terms in place.

Last look works by omission. Silhouette never asks a maker to back out of a selected quote; a maker that chooses not to settle simply does not submit fill(). The RFQ then fails once the settlement deadline passes with no fill observed, the taker's locked funds are released, and the quote's final status is FAILED. Not submitting the fill is the entire last-look path; there is no endpoint for it.

Publishing price ladders

Indicative pricing travels on a separate, maker-only socket: wss://rfq-api.silhouette.exchange/v1/rfq/prices/ws. Unlike the shared stream, the HTTP upgrade request itself is authenticated, with the standard HMAC headers over a canonical GET /v1/rfq/prices/ws with an empty body, and the resolved account must be an active maker, or the upgrade is rejected before the socket opens.

After the socket opens, each message is a bare ladder object with no kind envelope, coalesced and unacknowledged. Each entry is a [price, size] string pair:

{
"instrumentId": "XTSLA-USDC-SPOT",
"bids": [["199", "10"], ["198.5", "25"]],
"asks": [["201", "10"], ["201.5", "25"]],
"ts": 1716284399750
}

Ladders feed the public top-of-book prices topic on the shared stream, aggregated across every maker pricing the instrument and attributed to none of them. This is how takers see your pricing and decide to raise an RFQ. The price-ingest socket uses native WebSocket ping/pong rather than the application-level keepalive, and a maker's ladders are cleared when its price-ingest session ends.

To restate the eligibility rule from above: ladders do not gate RFQ eligibility. Pricing a market and being quotable on it are separate: the openRfqs subscription plus pair approval makes you quotable; ladders make your pricing visible. A serious maker does both.