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:
- Authenticate and mint API credentials.
- Open the WebSocket at
wss://rfq-api.silhouette.exchange/v1/rfq/wsand authenticate it with an in-bandauthframe. - Subscribe to
openRfqsfor each instrument you quote, and toquoteStatus. - Receive
rfqframes as takers raise RFQs on your instruments. - Price each RFQ and submit a quote with
POST /v1/rfq/maker/quotes. - Watch
quoteStatusfor the outcome. - 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:
| Code | Disposition |
|---|---|
AUTH_REQUIRED | Closes the session. |
INVALID_MESSAGE | Closes the session, unless it answers a frame sent out of sequence. |
UNSUPPORTED_CHANNEL | Refuses the frame, socket stays open. |
RATE_LIMITED | Refuses the frame, socket stays open. |
INVALID_INSTRUMENT_ID | Refuses the frame, socket stays open. |
UNKNOWN_INSTRUMENT | Refuses the frame, socket stays open. |
UNAVAILABLE | Refuses 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.
auctionEndsAtis 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 ownauctionEndsAtrather than assuming a fixed length.settlementDeadlineis unix seconds:auctionEndsAtrounded 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 fromauctionEndsAtyourself.autoAcceptsays how the winner is chosen, not when. A taker may accept a quote early in either mode, so any quote can win beforeauctionEndsAt. WithautoAccept: truethe best conforming quote is selected at the auction end if nobody accepted sooner, and the taker's funds are locked at submission; withfalseonly an explicit acceptance fills the request, and funds lock at acceptance.baseQtyis the quantity to quote on, in base-token units, and it has a side-dependent subtlety. On aSELLthe taker fee is carved out of the base the taker delivers, sobaseQtyis what the maker actually buys; a quote priced on any other quantity cannot win. On aBUYit 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.
acceptableForMsis a duration in milliseconds, counted from thereceivedAtSilhouette 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 restampsreceivedAt, 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
settlementDeadlineand 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
| Status | Meaning |
|---|---|
202 | The 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. |
400 | Refused; the message names the reason. |
503 | The 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
settlementModenaming an adapter the maker does not operate; - an
instrumentIdorsidedisagreeing with the RFQ's; - a missing
settlementpayload on a mode that requires one; - a
settlementpayload on a mode that settles from the maker's own inventory; - a payload that is not the shape its
settlementModeroutes 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:
| Status | Meaning |
|---|---|
SUBMITTED | Accepted into the open auction and competing. |
SELECTED | Won. The frame carries the settlement authorization; settle it on-chain. |
NOT_SELECTED | A conforming quote beaten by the winner. Nothing to do. |
EXPIRED | Still submitted when the auction closed with no winner. Nothing to do. |
SETTLED | The fill was observed on-chain and the trade is done. |
FAILED | A SELECTED quote whose RFQ was later terminated without settling. |
CANCELLED | The 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.
settlementDeadlineis unix seconds, not milliseconds, because Permit2 signs it into its EIP-712 payload and the chain compares it toblock.timestamp. It is the same figure therfqframe published, echoed here so a maker settling from the quote alone never has to hold the RFQ.acceptableForMsechoes the window the maker submitted, or is absent when the submission named none.spenderis your wrapper's address, bound into the Silhouette-signed authorization. It must bemsg.senderwhen 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.