Combo Negotiation Channels
Topics: rfq:{user_id} for the requester, combo_quote:{user_id} for the maker
The channels a combo is negotiated over. A requester asks for a price on a set of legs, makers quote, the requester accepts one, and the trade binds. Each side has its own channel. Every command is a message on your own topic, and every answer is a reply to it or a push to the same topic.
Read Combos first for the model: roles, exact-price accepts, shortfall, maker exposure and settlement. This page is the protocol.
Connect and join
Section titled “Connect and join”Sign the socket. Connect with the same X-STX-ACCESS-* headers as REST, signing GET and the path /socket/websocket, and send a User-Agent header. See Sign the socket, then join what you need and Authentication. An unsigned socket cannot join these channels.
Use a read_write key. Every command here ends in an order, so both topics take the same key scope as POST /api/v1/orders. A read_only key cannot join them: the join is refused with This API key does not have write access. A read_only key still joins the read-only channels, such as positions and fills. A browser session is not an API key and joins normally.
Join the topic for your side. {user_id} is your user id from GET /api/v1/me.
| Side | Topic | Commands | Pushes |
|---|---|---|---|
| Requester | rfq:{user_id} |
request_quote, cash_out, accept, confirm, cancel, snapshot |
best_quote, bound, outcome_unknown, reconfirm_required, outcome_unresolved |
| Maker | combo_quote:{user_id} |
quote, withdraw_quote |
bound, negotiation_closed |
geo_location works on both. Every other command exists on one topic only, and sending it to the other answers {"reason":"unknown_command","command":"<name>"}. A requester’s topic carries only their own negotiations, and a maker’s carries only the fate of their own quotes, so neither side ever sees anyone else’s prices. One account can join both topics, to request on one combo and quote on another.
["1","1","rfq:<user_id>","phx_join",{}]["2","2","combo_quote:<user_id>","phx_join",{}]A successful join replies with your account’s external_id and the deadline for your next geolocation check, or null when nothing more is asked of this connection:
["1","1","rfq:<user_id>","phx_reply",{"status":"ok","response":{"external_id":"<uuid>","next_geolocation_at":null}}]external_id is the id a counterparty knows your account by. No command carries it: the socket already says who you are. It travels one way: makers are shown the requester’s, so a maker can keep its own list of takers it will not price. A requester never sees a maker’s, and a maker never sees another maker’s.
Join refused with reason |
Cause |
|---|---|
unauthorized |
The topic is not your own user id, or the socket is not signed |
This API key does not have write access |
The socket is signed with a read_only key |
Invalid user - this action is not allowed. |
Your user could not be read |
This action is not allowed for Test user accounts. |
The account is a test account |
Your account status prohibits this action. |
The account is suspended, closed or otherwise barred from trading |
| a geolocation message | The geolocation check failed; the message says why |
Geolocation
Section titled “Geolocation”Geolocation applies only where the environment requires it. next_geolocation_at on the join reply tells you: a timestamp is a deadline to meet on this socket, and null means nothing more is asked of the connection, either because the environment does not check or because you are whitelisted. Branch on that field.
Where it applies, send your geolocation packet on the join as geo_location:
["1","1","rfq:<user_id>","phx_join",{"geo_location":"<packet>"}]If your server’s IP address is whitelisted on your account, send nothing. That is the usual setup for a programmatic client.
The check is made once per connection, not per command, and it expires at next_geolocation_at. Renew it on the open socket rather than reconnecting, because reconnecting cancels your quotes. Either topic takes the renewal, and it covers both:
["1","9","rfq:<user_id>","geo_location",{"geo_location":"<packet>"}]The reply carries the next deadline. Once a check has lapsed, request_quote, cash_out, quote, accept and confirm are refused with Your geo location check has expired. Reconnect to continue. until you renew it. withdraw_quote, cancel and snapshot still work, so you can always wind a negotiation down. Quotes you already hold are not cancelled by a lapse.
A join or geo_location payload that is not a JSON object is refused with malformed payload, whether or not geolocation applies.
What a disconnect costs
Section titled “What a disconnect costs”- A maker’s quotes are cancelled when the connection that placed them closes. Each quote belongs to its connection, not to the account, so a maker on two sockets loses only the quotes from the one that dropped. A quote holds no funds, so this costs the prices and nothing else. Quote again after you reconnect.
- A requester’s open request is cancelled when their connection closes, as if they had sent
cancel. Every maker holding a quote on it is pushednegotiation_closedwith reasoncancelled. Logging out of a browser session closes its socket and so ends the request too; an API key connection has no session to log out of. The one exception is a request frozen on an unknown outcome, which is left to resolve: read your positions and fills. - A quote already inside an acceptance can still bind after its maker disconnects. The maker then misses the
boundpush. After any disconnect, take your position from the positions and fills channels, not from silence here.
Wire format
Section titled “Wire format”Money and quantities are strings, as on the other channels (see Wire format). Nothing on these channels uses integer cents.
| Kind | Server to client | Client to server |
|---|---|---|
| Money | At least 4 decimal places, "0.1200" |
A stake is whole cents at most: "250.00" and "250" both parse, "250.001" is refused. A price is a whole multiple of the 0.0001 tick, unless you send adjust_to_nearest_tick. |
| Quantity | Whole contracts, 2 decimal places, "2083.00" |
Never sent |
| Timestamp | Integer microseconds since the Unix epoch | Never sent. A quote’s lifetime is expires_in_ms, an integer of milliseconds. |
| Id | UUID string | UUID string |
"Infinity" and "NaN" are refused as malformed_money.
Commands
Section titled “Commands”Every command except request_quote and cash_out names the negotiation by both market_id and rfq_id. request_quote and cash_out return both. A negotiation that has ended, for any reason, answers not_found and does not say how it ended.
No command names your account or user. A payload that carries external_id, account_id or user_id anyway is ignored, and the command acts on your own account.
A payload must be a JSON object. Anything else answers malformed_payload. An event name the topic does not know, including a command that belongs to the other topic, answers {"reason":"unknown_command","command":"<name>"}.
outcome_unknown on quote, withdraw_quote, cancel and snapshot
Section titled “outcome_unknown on quote, withdraw_quote, cancel and snapshot”These four can answer outcome_unknown when the negotiation is too busy to answer within 5 seconds, typically while a bind on the same combo is running. It means only that the answer was lost, not that the command was refused. The command still runs afterwards:
quote: your quote may have been recorded. A blind retry can be refused withquote_limit_reachedby the quote that landed.withdraw_quoteandcancel: retrying is safe. A quote already gone answersunknown_quote, and an ended negotiation answersnot_found.snapshot: retry.
On accept and confirm, outcome_unknown means something else: see When the outcome is unknown.
request_quote
Section titled “request_quote”On rfq:{user_id}.
Opens a negotiation to buy a combo.
| Field | Type | Required | Notes |
|---|---|---|---|
legs |
array | yes | 2 to 12 legs |
legs[].market_id |
UUID string | yes | A market that is open right now |
legs[].outcome |
"won" or "lost" |
yes | What the combo requires of that leg |
stake |
money string | yes | The total to spend, not per contract. Greater than 0, whole cents, at most 100000.00 |
There is no expiry field. A request lasts 5 minutes.
Reply: {"rfq_id": "<uuid>", "market_id": "<uuid>"}.
The same legs with the same outcomes always return the same market_id, whatever their order.
Refusals, in the order they are checked:
reason |
Cause | What to do |
|---|---|---|
combo_minimum_two_markets_needed |
legs missing, not an array, or fewer than 2 |
Send at least two legs |
malformed_leg |
A leg is not an object | Fix the payload |
malformed_id |
A leg’s market_id is not a UUID string |
Fix the payload |
unknown_outcome |
A leg’s outcome is not "won" or "lost" |
Fix the payload |
malformed_money |
stake missing, not a string, or not a number |
Fix the payload |
non_positive_stake |
stake is 0 or less |
Send a positive stake |
sub_cent_stake |
stake is finer than a cent, such as "125.2222" |
Round to cents |
stake_above_max |
stake is above 100000.00 |
Stake less |
insufficient_assets |
Your available balance is below what the bind will require, which is at least the stake (see below) | Deposit, or stake less |
account_unreachable |
Your balance could not be checked | Retry. This says nothing about your balance. |
loss_limit24_hours, loss_limit_weekly, loss_limit_monthly |
A loss limit will not carry this request | Wait, or raise the limit |
rfq_limit_reached |
Your account already has 2 negotiations open | Finish or cancel one first |
combo_too_many_markets |
More than 12 legs | Send fewer |
unknown_leg |
A leg’s market_id is not a market |
Check the id |
duplicate_legs |
The same market appears twice | One leg per market |
nested_combo |
A leg is itself a combo | Combos do not nest |
leg_not_quotable |
A leg is not open, including one that is scheduled or not yet trading |
Every leg must be open when you request |
market_not_quotable |
This combo has itself closed, cancelled, resulted or voided | This combo cannot be traded again |
persistent_market_unreadable |
The combo exists but could not be read | Retry; contact support if it persists |
request_not_started |
The negotiation could not be started | Retry |
Only the first failure is reported, so a request wrong in two ways is told about the earlier one in this table. Your account is checked before the legs are resolved, so a request you cannot fund is refused insufficient_assets (and one over the limit rfq_limit_reached) whatever else is wrong with it.
Show insufficient_assets and account_unreachable to a user differently. Only the first is about money.
The balance check asks for what the bind will require. That is the stake plus the request fee, each rounded up to the cent, with the fee taken at the lowest possible price because no maker has quoted yet. Where your fee schedule charges per trade, the figure is more than the stake, so a "250.00" stake can be refused on a balance of exactly 250.00. Where fees are charged at settlement instead, the figure is exactly the stake. A request that passes this check cannot then be refused at the bind for the fee.
A dropped channel is not a refusal. During a deploy, request_quote and cash_out can end with phx_error on the topic and no reply. The request may still have been opened, so do not assume nothing happened. Rejoin; quotes on that connection are gone, and an orphaned request expires after 5 minutes.
An account can have at most 2 negotiations open at once, counting cash-outs.
cash_out
Section titled “cash_out”On rfq:{user_id}.
Opens a negotiation to sell your whole position in a combo you hold. Makers then quote to buy it, and you accept as on any request.
| Field | Type | Required | Notes |
|---|---|---|---|
market_id |
UUID string | yes | The combo you hold |
Reply: {"rfq_id": "<uuid>", "market_id": "<uuid>", "quantity": "2083.00"}.
You do not name a size. The whole position is sold, and quantity tells you what that is. There is no partial exit.
reason |
Cause | What to do |
|---|---|---|
malformed_id |
market_id is not a UUID string |
Fix the payload |
nothing_to_sell |
You hold no long position in that combo | Nothing to cash out |
insufficient_assets |
Your available balance cannot cover the fee on selling this position | Deposit, then cash out |
account_unreachable |
Your balance could not be checked | Retry. This says nothing about your balance. |
rfq_limit_reached |
Your account already has 2 negotiations open | Finish or cancel one first |
leg_not_quotable |
A leg of the combo is no longer open |
There is no exit from here; the position runs to settlement |
unknown_combination |
No combo has that market_id |
Check the id |
exit_already_open |
You already have a cash-out open on that combo | Finish or cancel it first |
A cash-out still pays a fee. Selling a position you hold owes no collateral, but the sale is charged the usual fee, so you need enough available balance to cover it. No maker has bid yet, so the check uses the price at which the fee is largest, half the ceiling. A cash-out that passes it cannot be refused for the fee at any price a maker bids.
A cash-out closes when the first leg does. Every leg must still be open, when you cash out and again at the bind. Once any leg results, nobody holding that combo can cash out, and positions run to settlement.
The size is fixed when the cash-out opens. If your position changes before you accept, the accept can fail.
On combo_quote:{user_id}.
Offers a price on an open request or cash-out.
| Field | Type | Required | Notes |
|---|---|---|---|
market_id |
UUID string | yes | From the requester, with the rfq_id |
rfq_id |
UUID string | yes | From the requester |
price |
money string | yes | Per contract. Greater than 0, less than the combo’s max_price of 1.0000, a whole multiple of 0.0001, and on a request no more than the stake |
adjust_to_nearest_tick |
boolean | no | true snaps an off-grid price to the nearest tick, halves rounding up, instead of refusing it. Default false. |
expires_in_ms |
positive integer | yes | How long the quote stands, in milliseconds |
client_order_id |
string or null | no | Copied onto your order if the quote binds. At most 255 bytes. |
Reply: {"quote_id": "<uuid>", "price": "0.1437", "external_id": "<uuid>"}. external_id is the requester’s, the same for every maker on the request; if you will not trade with that taker, withdraw_quote now. price is the price as recorded, which differs from the one you sent when adjust_to_nearest_tick moved it. The snap happens before the bounds are checked, so a price that rounds to 0 or to 1.0000 is refused.
reason |
Cause | What to do |
|---|---|---|
malformed_id |
market_id or rfq_id is not a UUID string |
Fix the payload |
malformed_money |
price missing or not a number |
Fix the payload |
malformed_client_order_id |
Not a string, or longer than 255 bytes | Shorten it |
expires_at_required |
expires_in_ms missing, not an integer, or not positive |
Send a duration |
not_found |
The negotiation has ended | Stop quoting it |
cannot_quote_own_request |
The request was opened by your own account | Quote someone else’s |
quote_limit_reached |
You already have a live quote on this request | withdraw_quote it first, or wait for it to expire |
non_positive_price |
price is 0 or less |
Send a real price |
price_above_max |
price is at or above 1.0000 |
Quote below the ceiling |
price_above_stake |
price is more than the whole stake |
The stake cannot buy one contract at that price |
price_off_grid |
price is not a whole multiple of 0.0001 |
Round it, or send adjust_to_nearest_tick |
account_unreachable |
Your account could not be checked | Retry. This says nothing about your limits. |
loss_limit24_hours, loss_limit_weekly, loss_limit_monthly |
A loss limit will not carry this quote’s exposure | Wait, or raise the limit |
One live quote per request. There is no amend. To move your price, withdraw_quote and then quote again. A quote that has expired or been withdrawn frees the slot.
A quote holds no funds. Your loss limits are checked when you quote; your balance is not. Instead, a quote your available balance cannot fund at the bind is dropped without notice, the moment it is placed or whenever your balance moves, so a quote_id reply does not prove the quote is still standing. The check uses the same figure as the bind, so a quote that survives it will not be refused at the bind for funds.
You are not told when your quote expires. Treat it as gone once expires_in_ms has passed.
withdraw_quote
Section titled “withdraw_quote”On combo_quote:{user_id}.
Removes one of your own quotes.
| Field | Type | Required |
|---|---|---|
market_id |
UUID string | yes |
rfq_id |
UUID string | yes |
quote_id |
UUID string | yes |
Reply: {"withdrawn": "<quote_id>"}.
reason |
Cause |
|---|---|
malformed_id |
market_id, rfq_id or quote_id is not a UUID string |
not_found |
The negotiation has ended |
unknown_quote |
You have no quote on this negotiation with that id: it expired, was already withdrawn or bound, or is not yours. These are deliberately one answer. |
outcome_unknown |
No answer in time; see above |
accept
Section titled “accept”On rfq:{user_id}.
Accepts a price and binds the trade.
| Field | Type | Required |
|---|---|---|
market_id |
UUID string | yes |
rfq_id |
UUID string | yes |
quote_id |
UUID string | yes, the quote you were shown |
price |
money string | yes, the price you were shown, from the best_quote push or snapshot you are acting on |
The accept fills at exactly price or not at all. If the live best is any other price, higher or lower, nothing is placed and the accept is refused with price_moved. The refusal carries the new best, in the same fields as a best_quote push, so you can show it and accept again:
{"reason": "price_moved", "quote_id": "<uuid>", "price": "0.1200", "expires_at": 1790000140000000}With nothing live, those three fields are null; wait for a best_quote push. A better price is refused too, so every fill is at a price the requester saw. Expect price_moved in normal running: best_quote is pushed on a cadence, so a quote can take the top while the requester is deciding.
quote_id does not pick the counterparty. Any maker quoting the same price can fill the accept, so a quote that was withdrawn or cancelled still fills if another maker stands at its price. See Accepting names an exact price.
Reply:
{"market_id": "<uuid>", "quantity": "2083.00", "price": "0.1200", "charged": "249.9600", "shortfall": "0.0400"}| Field | Meaning |
|---|---|
market_id |
The combo’s market, now a real market |
quantity |
Contracts traded. On a request, the most whole contracts the stake buys at price. On a cash-out, your whole position. |
price |
Always the price you sent |
charged |
quantity × price. What you paid on a request; what you receive on a cash-out. |
shortfall |
stake − charged on a request. Always "0.0000" on a cash-out. |
The reply does not say which maker filled. It may not be the one quote_id named.
reason |
Cause | What to do |
|---|---|---|
malformed_id |
market_id, rfq_id or quote_id is not a UUID string |
Fix the payload |
not_found |
The negotiation has ended, including one an earlier accept already bound | Do not retry |
not_the_requester |
You did not open this negotiation | Only the requester accepts |
malformed_money |
price is missing, not a string, or not a number |
Fix the payload |
price_moved |
The live best is not the price you sent |
The request stays open and nothing was placed. Show the best the refusal carries and accept again at that price. |
no_fundable_quote |
No maker at your price could fund their side | The request stays open. The refusal carries the new best in the same three fields as price_moved; show it and accept that price. |
place_refused:<reason> |
Every maker at your price was dropped, and at least one had their own order refused | The request stays open and those makers’ quotes are gone. Nothing about your account was refused. A best_quote push follows if the top moved. |
requester_refused:<reason> |
Your own order was refused, or your balance would not cover the bind | The request stays open. No maker is dropped for this, though makers dropped earlier in the same accept are gone. The same accept gets the same answer until you clear <reason>. |
leg_not_quotable |
A leg stopped being open while the request was live |
The request is cancelled. Those legs can never bind as a combo again. |
leg_gate_unavailable |
The legs’ state could not be checked | The request is untouched. Retry. |
account_unreachable |
An account could not be checked | The request is untouched. Retry. |
outcome_unknown |
The bind took too long to confirm. It may or may not have traded. | Do not retry. See When the outcome is unknown. |
confirmation_required |
An earlier accept did not trade and a fresh quote is waiting | Send confirm with the token from reconfirm_required |
persist_failed |
The combo could not be recorded | Retry |
persist_unknown |
It is not known whether the combo was recorded | The request stays open. Retry once; a record that did land is detected. |
manager_unavailable |
The combo could not be recorded right now | The request is untouched. Retry. |
combination_state_inconsistent |
The combo could not be recorded | Do not retry; contact support |
market_proc:<reason> |
The combo’s market could not be started | Retry |
The <reason> after place_refused: and requester_refused: is one of insufficient_assets, account_limits_reached, member_position_limit, total_orders_limit, same_price_orders_limit, loss_limit24_hours, loss_limit_weekly or loss_limit_monthly, or a free-text message such as a geolocation refusal. Split on the first : and treat the rest as opaque text. requester_refused:requester_escrow means your balance failed the bind’s funding check before anything was written: fund the account and accept again.
When the outcome is unknown
Section titled “When the outcome is unknown”An order can take longer to confirm than the bind waits, after it has already traded. Neither the server nor you can tell that apart from an order that placed nothing, so the reply is outcome_unknown. Never resend accept after it. What happens next depends on whose order was unknown, and the reply does not say which:
- Your side. The request freezes:
acceptandcancelboth answeroutcome_unknown. You are also pushedoutcome_unknown. The server finds out what happened and pushes one ofbound(it traded),reconfirm_required(it did not;confirmthe quote it carries) oroutcome_unresolved(it could not find out; the request stays frozen until it expires, so read your positions and fills). - A maker’s side. The negotiation ends at once. Makers are sent
negotiation_closedwith reasonoutcome_unknown, no further push reaches you, and later commands answernot_found. Open a new request.
Accepting commits your whole stake. There is no undoing an accept that binds.
confirm
Section titled “confirm”On rfq:{user_id}.
Binds the quote a reconfirm_required push presented.
| Field | Type | Required |
|---|---|---|
market_id |
UUID string | yes |
rfq_id |
UUID string | yes |
confirmation_token |
UUID string | yes, from reconfirm_required or snapshot |
Reply: the same object accept replies with. A token names one quote, carries its price, and works once, so confirm sends no price of its own. You fill at exactly that price. If the quote has gone by the time you confirm, nothing binds and the current best is presented again under a new token.
reason |
Cause | What to do |
|---|---|---|
malformed_id |
confirmation_token, rfq_id or market_id is not a UUID string |
Fix the payload |
not_found |
The negotiation has ended | Nothing to do |
not_the_requester |
You did not open this negotiation | Only the requester confirms |
no_confirmation_pending |
Nothing is waiting to be confirmed | Use accept |
stale_confirmation |
The token is not the current one | Use the token from the latest reconfirm_required, or snapshot |
outcome_unknown |
The request is still frozen | Wait for the push |
quote_changed |
The quote the token named has gone | A new reconfirm_required has been pushed; confirm that one |
confirm runs the same bind as accept, so it can also answer anything accept can, including price_moved when the best has moved off the token’s price.
cancel
Section titled “cancel”On rfq:{user_id}.
Ends the negotiation and drops every quote on it. Only the requester who opened it can cancel.
| Field | Type | Required |
|---|---|---|
market_id |
UUID string | yes |
rfq_id |
UUID string | yes |
Reply: {"cancelled": "<rfq_id>"}. Refusals: malformed_id, not_found, not_the_requester, and outcome_unknown, either while the request is frozen on an unknown outcome or when the answer was lost (see above). Only an accept or confirm you sent can have frozen it.
snapshot
Section titled “snapshot”On rfq:{user_id}.
The negotiation as it stands. Makers have no snapshot, so a maker never sees what others are quoting.
| Field | Type | Required |
|---|---|---|
market_id |
UUID string | yes |
rfq_id |
UUID string | yes |
Reply:
{ "rfq_id": "<uuid>", "market_id": "<uuid>", "status": "open", "side": "buy", "stake": "250.0000", "quantity": null, "expires_at": 1790000300000000, "best_quote": {"quote_id": "<uuid>", "price": "0.1200", "expires_at": 1790000140000000}, "confirmation": {"confirmation_token": null, "quote_id": null, "price": null}}statusis"open","outcome_unknown"(frozen; nothing binds yet) or"awaiting_confirmation"(sendconfirmwith the token underconfirmation). A negotiation that has ended answersnot_foundinstead.confirmationhas null fields unlessstatusis"awaiting_confirmation". It is how a client that reconnected picks up the token.sideis"buy"for a request and"sell"for a cash-out. A request carriesstakeand a nullquantity; a cash-out carriesquantityand a nullstake.best_quoteis the one live quote an accept would fill at: the lowest price on a request, the highest on a cash-out. With no live quote its fields are allnull, so the shape never changes. Between equal prices, the earlier quote stays best.snapshotreads the book at the moment you ask and is never held back by the push cadence or the 500 ms opening wait. Use it to be sure of the price before you accept.
Refusals: malformed_id, not_found, not_the_requester, and outcome_unknown when the answer was lost. That refusal is different from the "outcome_unknown" status in a successful reply.
geo_location
Section titled “geo_location”On either topic. Renews your geolocation check on the open socket. See Geolocation.
| Field | Type | Required |
|---|---|---|
geo_location |
string | yes, unless your IP address is whitelisted |
Reply: {"next_geolocation_at": "..."}, or a refusal whose reason says why the check failed.
Server pushes
Section titled “Server pushes”Six events arrive without being asked for. A push has null for both join_ref and ref.
| Push | Arrives on |
|---|---|
best_quote, outcome_unknown, reconfirm_required, outcome_unresolved |
The requester’s rfq:{user_id} |
bound |
Both: the requester’s and the winning maker’s, with a different payload on each |
negotiation_closed |
The maker’s combo_quote:{user_id} |
A maker’s pushes name only that maker’s own quote_id, plus the requester’s external_id.
best_quote
Section titled “best_quote”To the requester, whenever the best quote changes: a new quote takes the top, or the top quote is withdrawn or cancelled because its maker’s balance dropped or its maker disconnected. The push then carries the next best, or nulls if none is left. A quote that does not beat the current best is not pushed. It also fires once after an accept that dropped makers, if that moved the top.
{"rfq_id": "<uuid>", "market_id": "<uuid>", "quote_id": "<uuid>", "price": "0.1200", "expires_at": 1790000140000000}It is sent on a cadence. At most one best_quote push per negotiation every 250 ms. The first change after a quiet spell goes out at once, later changes in the same window are held, and the state at the end of the window is always sent, so the last push you hold is the price that stands.
An empty book waits. When the last live quote goes, the nulls are sent only at the end of the window, and not at all if a quote arrives first. A maker repricing withdraws and quotes again, so this stops the price blanking for a moment on every reprice.
The first push waits 500 ms. No best_quote arrives until 500 ms after the request opens, however fast the first maker is, so several makers can compete before you see a price. Silence before then is expected; snapshot reads the book through it.
Quotes still land on the book as they arrive; only the pushes are paced. So:
- Do not count
best_quotepushes. One push is not one quote. - Do not read silence as “no new quotes”. Quotes may have taken the top and been beaten between two pushes.
The cadence and the opening wait apply only to best_quote. Every other push goes out at once.
It does not fire when the best quote expires. Act on expires_at, and snapshot before accepting if the push you hold may be stale. An accept at an expired quote’s price answers price_moved with whatever stands instead.
To the requester and to the winning maker, when a bind succeeds. The two payloads differ.
To the requester:
{"rfq_id": "<uuid>", "market_id": "<uuid>", "quantity": "2083.00", "price": "0.1200", "charged": "249.9600"}To the maker, naming the requester’s external_id, which of your quotes traded and the exposure your account now carries:
{"rfq_id": "<uuid>", "market_id": "<uuid>", "external_id": "<uuid>", "quote_id": "<uuid>", "quantity": "2083.00", "price": "0.1200", "liability": "1833.0400"}shortfall is only on the accept reply.
negotiation_closed
Section titled “negotiation_closed”To each maker holding a quote that did not trade, once per quote, when the negotiation ends.
{"rfq_id": "<uuid>", "market_id": "<uuid>", "external_id": "<uuid>", "quote_id": "<uuid>", "reason": "bound_elsewhere"}reason is bound_elsewhere, cancelled (including the requester disconnecting), expired, leg_not_quotable (a leg stopped being open) or outcome_unknown (the bind left an order of unknown outcome).
The requester is pushed nothing when their request expires, unless it was frozen; later commands answer not_found.
outcome_unknown
Section titled “outcome_unknown”To the requester, when an accept’s outcome is unknown on your side. The request is frozen until one of the next two pushes, or bound, arrives.
{"rfq_id": "<uuid>", "market_id": "<uuid>", "quote_id": "<uuid>", "price": "0.1200", "quantity": "2083.00"}reconfirm_required
Section titled “reconfirm_required”To the requester, when that accept turned out not to have traded. It carries the quote live now, which may not be the one you accepted, and the token that binds it with confirm.
{"rfq_id": "<uuid>", "market_id": "<uuid>", "confirmation_token": "<uuid>", "quote_id": "<uuid>", "price": "0.1150", "expires_at": 1790000140000000, "reason": "placement_did_not_trade"}reason is placement_did_not_trade, or confirmed_quote_gone when you confirmed a quote that had already gone. With nothing live to confirm, confirmation_token, quote_id and price are null and the request is open again: accept the next quote.
outcome_unresolved
Section titled “outcome_unresolved”To the requester, when the outcome could not be found out, and again if the request expires while frozen. The request stays frozen until it expires. Read your positions and fills.
{"rfq_id": "<uuid>", "market_id": "<uuid>", "quote_id": "<uuid>"}The sequence
Section titled “The sequence”requester server maker |-- request_quote -------------->| | |<------ {rfq_id, market_id} ----| | | | (rfq_id and market_id | | | reach the maker out | | | of band) | | |<-------------- quote -----------| | |------- {quote_id, price} ------>| |<------- push best_quote -------| | |-- accept {quote_id, price} --->| | | | funding checked, both sides | | | combo becomes a real market | | | maker's sell rests, | | | requester's buy takes it | |<-- {market_id, quantity, ...} -| | |<--------- push bound ----------|---------- push bound ---------->| | |-- push negotiation_closed ----->| (other makers)What not to assume
Section titled “What not to assume”- The
market_idfromrequest_quoteis not a market yet. Until a bind it answers no market lookup, has no order book, and takes no orders. - A combo that has bound still takes no ordinary orders. An order sent to it through the order endpoints is refused with
market_not_found. Combos are traded only throughrequest_quoteandcash_out. - That id is held only while a negotiation is open on it. Cancel without binding and it is released. It is a stable identity for those legs and outcomes, not a handle to keep.
- A quote can vanish before you accept it. Expiry is not announced, and a quote is gone at its
expires_at, not after it. A withdrawal, a maker’s balance dropping or a maker disconnecting movesbest_quoteonly when it was the top quote. best_quotecan be up to 250 ms behind the book. An accept at the last pushed price is refused withprice_movedfrom time to time in normal running.- No
best_quotearrives in a request’s first 500 ms. Do not time out there. - The quote you accept is not necessarily the quote that fills. Another maker at the same price may fill it. The price always matches.
- A failed accept does not usually end the request.
no_fundable_quote,place_refused:*,requester_refused:*,account_unreachableand most infrastructure errors leave it open.leg_not_quotablecancels it, andoutcome_unknowneither freezes or ends it. not_founddoes not say how a negotiation ended. The record of a bind is the market, the orders and your positions.- Requests are not broadcast to makers. A maker learns an
rfq_idandmarket_idsome other way.
Worked example
Section titled “Worked example”Broncos to win the Super Bowl, Avalanche to win the Stanley Cup, Rockies to win the World Series: all three, for $250.
Frames use the Phoenix v2 array form, [join_ref, ref, topic, event, payload]. Replies arrive as phx_reply; pushes have null for both refs. The ids below are examples.
| User id | Topic | |
|---|---|---|
| Requester | 9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77 |
rfq:9c2a4e61-… |
| Maker A | 2d8f6b10-4a7c-4e93-b58d-3c1f0e9a7b62 |
combo_quote:2d8f6b10-… |
| Maker B | f60b3d97-8c25-41ea-9fb4-07d1a6e3c852 |
combo_quote:f60b3d97-… |
1. Everyone joins the topic for their side
Section titled “1. Everyone joins the topic for their side”All three are whitelisted here, so the joins carry no geolocation packet. The makers join combo_quote: the same way.
["1","1","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_join",{}]["1","1","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"ok","response":{"external_id":"0b6e2f48-1a9c-4d37-8e25-6f4a9c1d7b03","next_geolocation_at":null}}]2. The requester opens the negotiation
Section titled “2. The requester opens the negotiation”["1","2","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","request_quote",{ "legs":[ {"market_id":"b1e4c7a8-3d92-4f05-8a16-2c7b9e0d4f31","outcome":"won"}, {"market_id":"a7d0f3b2-91c6-4e58-b04a-6f2d8c1e5b93","outcome":"won"}, {"market_id":"c4f18b96-5e2d-4073-91ac-8b6e3d0f2a57","outcome":"won"} ], "stake":"250.00"}]["1","2","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"ok","response":{ "rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38", "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64"}}]5a1d8e02-… is the combo’s identity: any later request for these three legs, all to win, gets the same one. It is not a market yet. The requester passes the rfq_id and market_id to makers.
Each maker’s quote reply below carries the requester’s external_id, 0b6e2f48-…, which the requester’s join reply also showed.
3. Two makers quote
Section titled “3. Two makers quote”Maker A, at 0.1250, good for a minute:
["1","2","combo_quote:2d8f6b10-4a7c-4e93-b58d-3c1f0e9a7b62","quote",{ "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38", "price":"0.1250", "expires_in_ms":60000, "client_order_id":"mm-a-0417"}]["1","2","combo_quote:2d8f6b10-4a7c-4e93-b58d-3c1f0e9a7b62","phx_reply",{"status":"ok","response":{ "quote_id":"7e93c0b5-1f48-4a26-9d07-5b2e8c4f1a90", "price":"0.1250", "external_id":"0b6e2f48-1a9c-4d37-8e25-6f4a9c1d7b03"}}]Maker B, at 0.1200, good for two minutes:
["1","2","combo_quote:f60b3d97-8c25-41ea-9fb4-07d1a6e3c852","quote",{ "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38", "price":"0.1200", "expires_in_ms":120000}]["1","2","combo_quote:f60b3d97-8c25-41ea-9fb4-07d1a6e3c852","phx_reply",{"status":"ok","response":{ "quote_id":"8c21e47f-0b93-4d6a-a175-2f9e3c8b40d6", "price":"0.1200", "external_id":"0b6e2f48-1a9c-4d37-8e25-6f4a9c1d7b03"}}]The requester is pushed both, because each took the top in turn: A as the only quote, then B by undercutting it. Had B quoted 0.1300, only A’s push would have arrived.
[null,null,"rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","best_quote",{ "rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38", "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "quote_id":"7e93c0b5-1f48-4a26-9d07-5b2e8c4f1a90", "price":"0.1250", "expires_at":1790000070000000}][null,null,"rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","best_quote",{ "rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38", "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "quote_id":"8c21e47f-0b93-4d6a-a175-2f9e3c8b40d6", "price":"0.1200", "expires_at":1790000140000000}]4. An accept at a price that moved, then one that did not
Section titled “4. An accept at a price that moved, then one that did not”The requester accepts before B’s push lands, with A’s 0.1250 still on screen:
["1","3","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","accept",{ "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38", "quote_id":"7e93c0b5-1f48-4a26-9d07-5b2e8c4f1a90", "price":"0.1250"}]The live best is B’s 0.1200, so nothing is placed. The refusal carries B:
["1","3","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"error","response":{ "reason":"price_moved", "quote_id":"8c21e47f-0b93-4d6a-a175-2f9e3c8b40d6", "price":"0.1200", "expires_at":1790000140000000}}]0.1200 is cheaper than what the requester sent, and it is refused anyway. The client shows 0.1200 and the requester accepts it:
["1","4","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","accept",{ "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38", "quote_id":"8c21e47f-0b93-4d6a-a175-2f9e3c8b40d6", "price":"0.1200"}]Only quotes at 0.1200 can fill it: B alone. B’s balance is checked for (1 − 0.12) × 2083 = 1833.0400 and covers it. A is never asked, because A is at a different price. At 0.1200 the stake buys floor(250 / 0.12) = 2083 contracts for 249.9600, leaving 0.0400:
["1","4","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"ok","response":{ "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "quantity":"2083.00", "price":"0.1200", "charged":"249.9600", "shortfall":"0.0400"}}]Had B failed funding with no other maker at 0.1200, the answer would have been no_fundable_quote carrying A’s 0.1250 as the new best, the request would have stayed open, and the requester could accept 0.1250 on its own.
The requester and maker B are each pushed bound on their own topics, and maker A is told on its topic that its quote lost:
[null,null,"rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","bound",{ "rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38", "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "quantity":"2083.00","price":"0.1200","charged":"249.9600"}][null,null,"combo_quote:f60b3d97-8c25-41ea-9fb4-07d1a6e3c852","bound",{ "rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38", "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "external_id":"0b6e2f48-1a9c-4d37-8e25-6f4a9c1d7b03", "quote_id":"8c21e47f-0b93-4d6a-a175-2f9e3c8b40d6", "quantity":"2083.00","price":"0.1200","liability":"1833.0400"}][null,null,"combo_quote:2d8f6b10-4a7c-4e93-b58d-3c1f0e9a7b62","negotiation_closed",{ "rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38", "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "external_id":"0b6e2f48-1a9c-4d37-8e25-6f4a9c1d7b03", "quote_id":"7e93c0b5-1f48-4a26-9d07-5b2e8c4f1a90", "reason":"bound_elsewhere"}]Market 5a1d8e02-… is now a real market with two orders and a trade between them. The requester is long 2083 contracts and maker B is short 2083. Any further command on rfq_id 3f7b9c14-… answers not_found. From here the orders, fill, position and settlement appear on the orders, fills, positions and settlements channels like any other market’s. How it settles, including a leg that pushes, is worked through on Combos.
5. Cashing out
Section titled “5. Cashing out”Later, two legs have landed and the third has not started. The requester sells the position:
["1","4","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","cash_out",{ "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64"}]["1","4","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"ok","response":{ "rfq_id":"8b1f2d05-93c7-4e6a-bb12-5a7c0d3e9f41", "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "quantity":"2083.00"}}]Maker B bids 0.4000 with an ordinary quote on the new rfq_id, and the requester is pushed it as best_quote. The requester accepts:
["1","5","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","accept",{ "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "rfq_id":"8b1f2d05-93c7-4e6a-bb12-5a7c0d3e9f41", "quote_id":"c4a7e1b9-2d58-4f03-9e6a-18b7d0c5f3a2", "price":"0.4000"}]["1","5","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"ok","response":{ "market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64", "quantity":"2083.00", "price":"0.4000", "charged":"833.2000", "shortfall":"0.0000"}}]On a cash-out charged is what the seller receives: 2083 × 0.40 = 833.2000. Against the 249.9600 paid to open, that is 583.2400 gross. Maker B’s bound push carries "liability":"833.2000", because a buyer’s exposure is the price itself. Maker B was short 2083 from the first trade and has bought them back, so B is now flat too.
Known limitations
Section titled “Known limitations”- Requests are not broadcast to makers. A maker needs the
rfq_idandmarket_idfrom the requester out of band. boundreports the agreed terms, not a confirmed fill. An order can still be refused after the bind is reported. Confirm the position from the positions and fills channels.- No push when a quote expires. The requester’s last
best_quotecan be stale until something else moves the top;snapshotbefore accepting. - The first accept on a new combo can be refused. Shortly after the first request for a set of legs, an accept can answer
requester_refused:Market does not accept such orders.The request stays open; accept again. - A maker cannot ask whether a quote still stands. Treat a quote as gone once its
expires_in_mshas passed, and do not read a missingnegotiation_closedas proof it is live.

