# Combos

> Request, quote and accept combinations over the WebSocket.

Source: https://docs.stxapp.io/sdks/typescript/combos/

A combination is one market over two or more legs. As a player you ask for quotes on a set of legs and a stake; makers quote what you win; you accept, and the exchange places one ordinary order for each side on the combination's market. [Combos](/concepts/combos/) explains the product and [Combo negotiation](/websockets/channels/combo-negotiation/) documents every command, push and refusal at the wire level. This page shows the SDK calls and exactly what each one hands you.

Both sides run on the socket, with an API key that has write access.

A complete maker that quotes every request until Ctrl-C and reports how each one ends is [`examples/combo_maker.ts`](https://github.com/stxapp/stx-typescript-demo/blob/main/examples/combo_maker.ts); [Quote combos as a maker](/websockets/combo-maker/) walks through it.

## About the output on this page

Every reply, event and error below was captured from `@stxapp/stx-typescript` 0.8.3 on US demo on 21 October 2026 (UTC), and is shown as the SDK returned it: ids, amounts and timestamps are real and nothing was shortened. Three accounts took part, each with its own `STX` client and socket (`await client.websocket().connect()`):

| | Socket | Session | `externalId` |
| --- | --- | --- | --- |
| Player | `playerWs` | `player = await playerWs.comboPlayer()` | `9c87ec1c-588e-4512-9b91-d71653e41458` |
| Maker A | `makerWs` | `maker = await makerWs.comboMaker()` | `3914a397-b553-4510-8b54-d6a6c0a096b0` |
| Maker B | `makerBWs` | `makerB = await makerBWs.comboMaker()` | |

The legs are two pre-match soccer markets, each to win: `29396ce5-a4f3-4d81-b980-2f637ab3a660` (GAL @ LILL, GAL in regulation) and `663ebfe6-26d5-45e0-a0ac-6859f7087832` (MAN @ COMO, MAN in regulation).

## A buy

### 1. Join

```ts
const player = await playerWs.comboPlayer();
player.channel.reply;
```

```json
{ "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458" }
```

`player.externalId` is the same `external_id`: the player's pseudonym, which makers see on requests and quotes. Nobody sees a maker's.

### 2. Player requests a quote

```ts
const legs = [
  { marketId: "29396ce5-a4f3-4d81-b980-2f637ab3a660", outcome: "won" }, // GAL @ LILL: GAL
  { marketId: "663ebfe6-26d5-45e0-a0ac-6859f7087832", outcome: "won" }, // MAN @ COMO: MAN
] as const;
const { rfq_id } = await player.requestQuote(legs, "5.00");
```

```json
{ "rfq_id": "190058b7-ae66-4f6f-a30b-dc8951960d98" }
```

`amount` is a decimal string in whole cents. A number is never sent: `requestQuote(legs, 5)` throws `TypeError: amount must be a decimal string such as "250.00", got number`.

### 3. Makers receive the request

Every maker session gets `rfq_opened`, often before the player has the `requestQuote()` reply:

```ts
const opened = await maker.waitFor("rfq_opened", { rfqId: rfq_id, timeoutMs: 30_000 });
opened;
```

```json
{
  "event": "rfq_opened",
  "payload": {
    "amount": "5.0000",
    "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458",
    "expires_at": 1792558280540276,
    "market_id": null,
    "quantity": null,
    "max_price": "1.0000",
    "side": "buy",
    "tick_size": "0.0001000",
    "rfq_id": "190058b7-ae66-4f6f-a30b-dc8951960d98",
    "legs": [
      { "short_name": "GAL @ LILL", "market_id": "29396ce5-a4f3-4d81-b980-2f637ab3a660", "outcome": "won" },
      { "short_name": "MAN @ COMO", "market_id": "663ebfe6-26d5-45e0-a0ac-6859f7087832", "outcome": "won" }
    ]
  }
}
```

On a buy `amount` is the stake, and `market_id` and `quantity` are `null` because no combination market exists yet. `expires_at` is microseconds since the Unix epoch, 5 minutes after the request opened. `short_name` is the leg market's short title, which names the game rather than the outcome, so read the outcome from `market_id`; it is `null` for a market that has none. Price against `max_price` and `tick_size`, never a constant.

### 4. Player reads the request

```ts
await player.snapshot(rfq_id);
```

```json
{
  "status": "open",
  "amount": "5.0000",
  "expires_at": 1792558280540276,
  "market_id": null,
  "quantity": null,
  "side": "buy",
  "rfq_id": "190058b7-ae66-4f6f-a30b-dc8951960d98",
  "best_quote": { "amount": null, "expires_at": null, "quote_id": null }
}
```

Nothing is quoted yet, so every `best_quote` field is `null`. `snapshot()` reads the live state at any moment; it is not held back like `best_quote` events.

### 5. Makers quote what the player wins

```ts
await makerB.quote(rfq_id, "8.00", 120_000, { clientOrderId: null });
await maker.quote(rfq_id, "10.00", 60_000, { clientOrderId: "tssdk-capture-buy" });
```

```json
{ "amount": "8.0000", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "quote_id": "f6fdf76e-0c74-44cf-8180-b0f1fb0f09df" }
{ "amount": "10.0000", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "quote_id": "05308411-acfa-4b81-af8c-c390ad41a254" }
```

The reply carries no price or size: the bind sets those. `external_id` is the player's. `expiresInMs` counts from receipt, and `clientOrderId` is copied onto the maker's order if the quote binds.

### 6. Player sees the best quote

```ts
const best = await player.waitFor("best_quote", { rfqId: rfq_id, timeoutMs: 30_000 });
best;
```

```json
{
  "event": "best_quote",
  "payload": {
    "amount": "10.0000",
    "expires_at": 1792558040771767,
    "market_id": null,
    "rfq_id": "190058b7-ae66-4f6f-a30b-dc8951960d98",
    "quote_id": "05308411-acfa-4b81-af8c-c390ad41a254"
  }
}
```

Both quotes arrived inside the request's first 500 ms, so one `best_quote` came, carrying only the better one, 500 ms after `requestQuote()` returned. The player only ever sees the best amount, never who quoted it. A `snapshot()` now shows the same quote under `best_quote`.

### 7. A maker withdraws a quote

```ts
const extra = await maker.quote(rfq_id, "9.00", 60_000);
await maker.withdrawQuote(rfq_id, extra.quote_id);
```

```json
{ "amount": "9.0000", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "quote_id": "4e3056f3-cb9a-4ea0-9866-5b62148c9ea2" }
{ "withdrawn": "4e3056f3-cb9a-4ea0-9866-5b62148c9ea2" }
```

Withdrawing it again, or withdrawing another maker's quote, throws `STXComboRefusedException` with `code` `unknown_quote`. A `$9` quote below the best `$10` changes nothing the player sees, so no `best_quote` followed either call.

### 8. Accepting above the best

`accept()` takes the least amount you will take. Asking for more than anything live throws `STXComboPriceMovedException`, and the request stays open:

```ts
try {
  await player.accept(rfq_id, best.payload.quote_id!, "25.00");
} catch (err) {
  if (err instanceof STXComboPriceMovedException) console.log(err.code, err.best);
}
```

```json
"price_moved"
{ "quote_id": "05308411-acfa-4b81-af8c-c390ad41a254", "amount": "10.0000", "expires_at": 1792558040771767 }
```

`err.best` is the live best quote, ready to accept again.

### 9. Player accepts

```ts
const placed = await player.accept(rfq_id, best.payload.quote_id!, best.payload.amount!);
```

```json
{ "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "quantity": "14.00", "price": "0.3334", "order_id": "fba8fd62-3389-4d3d-ac09-a0b383f6c11e" }
```

The bind created the combination market `c5cde34a-d13c-4849-8135-8ed0baf70ccd`. The price is `max_price × stake / (stake + win)` = 5 / 15 = 0.33333, rounded up to the tick: 0.3334. The size is whole contracts, `floor(5 / 0.3334)` = 14. The player pays at most $4.6676 plus fees and collects $14.00 if both legs win. The reply says what was placed, not what filled.

### 10. Both sides are told

The player gets `placed`: the `accept()` reply plus `rfq_id`. It can arrive before the reply, so `waitFor` also looks at events already received:

```ts
await player.waitFor("placed", { rfqId: rfq_id });
```

```json
{
  "event": "placed",
  "payload": {
    "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd",
    "quantity": "14.00",
    "price": "0.3334",
    "order_id": "fba8fd62-3389-4d3d-ac09-a0b383f6c11e",
    "rfq_id": "190058b7-ae66-4f6f-a30b-dc8951960d98"
  }
}
```

Maker A, whose `$10` bound, gets `quote_accepted` (with `market_id` still `null`, since the market did not exist yet), then `placed` with its own order at the same price and size, then `rfq_closed`:

```json
{ "event": "quote_accepted", "payload": { "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": null, "rfq_id": "190058b7-ae66-4f6f-a30b-dc8951960d98", "quote_id": "05308411-acfa-4b81-af8c-c390ad41a254" } }
{ "event": "placed", "payload": { "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "quantity": "14.00", "price": "0.3334", "order_id": "b24cabe6-9a53-48e3-b891-2a19245690ec", "rfq_id": "190058b7-ae66-4f6f-a30b-dc8951960d98", "quote_id": "05308411-acfa-4b81-af8c-c390ad41a254" } }
{ "event": "rfq_closed", "payload": { "reason": "placed", "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "rfq_id": "190058b7-ae66-4f6f-a30b-dc8951960d98" } }
```

Maker B, whose `$8` did not bind, gets `negotiation_closed` for its quote and the same `rfq_closed`:

```json
{ "event": "negotiation_closed", "payload": { "reason": "placed_elsewhere", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "rfq_id": "190058b7-ae66-4f6f-a30b-dc8951960d98", "quote_id": "f6fdf76e-0c74-44cf-8180-b0f1fb0f09df" } }
{ "event": "rfq_closed", "payload": { "reason": "placed", "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "rfq_id": "190058b7-ae66-4f6f-a30b-dc8951960d98" } }
```

From here the request is gone: `snapshot()`, `accept()` and `quote()` on `190058b7-...` throw `STXComboRefusedException` with `code` `not_found`. `c5cde34a-...` is an ordinary market, and each side follows its fills, positions and settlements on its own socket: `playerWs.fills()`, `playerWs.positions()` and `playerWs.settlements()` for the player, the same on `makerWs` for the maker.

## A cash out

### 1. Player opens the cash out

```ts
const out = await player.cashOut("c5cde34a-d13c-4849-8135-8ed0baf70ccd");
```

```json
{ "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "quantity": "14.00", "rfq_id": "7422de30-8cab-478e-b42c-612eeada2170" }
```

A cash out is a new request with its own `rfq_id`. The exchange sets the size: what you hold less your resting sells. A fill reaches your position a moment after the trade, so a `cashOut()` sent straight after buying can throw `STXComboRefusedException` with `code` `nothing_to_sell`; retry it.

### 2. Makers receive it

```json
{
  "event": "rfq_opened",
  "payload": {
    "amount": null,
    "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458",
    "expires_at": 1792558283797002,
    "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd",
    "quantity": "14.00",
    "max_price": "1.0000",
    "side": "sell",
    "tick_size": "0.0001000",
    "rfq_id": "7422de30-8cab-478e-b42c-612eeada2170",
    "legs": [
      { "short_name": "GAL @ LILL", "market_id": "29396ce5-a4f3-4d81-b980-2f637ab3a660", "outcome": "won" },
      { "short_name": "MAN @ COMO", "market_id": "663ebfe6-26d5-45e0-a0ac-6859f7087832", "outcome": "won" }
    ]
  }
}
```

On `sell` the roles of the fields swap: `market_id` is the combination, `quantity` the size, and `amount` is `null`. Makers quote what they pay for all 14 contracts. `player.snapshot(out.rfq_id)` shows the same: `"side": "sell"`, `"amount": null`, `"quantity": "14.00"`, and a `best_quote` of nulls until someone quotes.

### 3. The only quote is withdrawn

Maker A quotes $2.10 and withdraws it. The player sees the quote, then a `best_quote` with nulls once nothing is live:

```json
{ "event": "best_quote", "payload": { "amount": "2.1000", "expires_at": 1792558044008239, "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "rfq_id": "7422de30-8cab-478e-b42c-612eeada2170", "quote_id": "59157575-1471-4d39-b160-10453c8f72ae" } }
{ "event": "best_quote", "payload": { "amount": null, "expires_at": null, "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "rfq_id": "7422de30-8cab-478e-b42c-612eeada2170", "quote_id": null } }
```

Accepting the withdrawn quote now throws `STXComboPriceMovedException` with every field of `err.best` `null`:

```ts
await player.accept(out.rfq_id, "59157575-1471-4d39-b160-10453c8f72ae", "2.10");
```

```json
{ "quote_id": null, "amount": null, "expires_at": null }
```

### 4. New quotes, and an accept at a floor

Maker B quotes $3.72 and maker A $4.20. Outside the opening window each change is pushed:

```json
{ "event": "best_quote", "payload": { "amount": "3.7200", "expires_at": 1792558045115565, "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "rfq_id": "7422de30-8cab-478e-b42c-612eeada2170", "quote_id": "024f767f-0ec7-4cdc-b270-f2a5b2b3f546" } }
{ "event": "best_quote", "payload": { "amount": "4.2000", "expires_at": 1792558045191937, "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "rfq_id": "7422de30-8cab-478e-b42c-612eeada2170", "quote_id": "0132f947-7bee-4cb7-a939-38d3b8e2aaf4" } }
```

The player accepts naming maker B's quote at `"3.72"`. `accept()` binds the best live quote at that amount or more, whoever made it, so maker A's $4.20 binds:

```ts
const sold = await player.accept(out.rfq_id, "024f767f-0ec7-4cdc-b270-f2a5b2b3f546", "3.72");
```

```json
{ "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "quantity": "14.00", "price": "0.3000", "order_id": "4d1bf79e-7b44-45d3-a10d-53f2897f8e9f" }
```

On a cash out the price is `amount / quantity` = 4.20 / 14 = 0.3000, rounded down to the tick. The player sold 14 at 0.3000 against 0.3334 paid. Maker A gets `quote_accepted` (naming the combination this time, since it exists), then `placed` with its buy of 14 at 0.3000 and `rfq_closed` `placed`; maker B gets `negotiation_closed` `placed_elsewhere` for its $3.72.

## How a request ends

Every ending reaches the player as `placed` or `negotiation_closed`, each maker's quotes as `placed` or `negotiation_closed`, and every maker session as `rfq_closed`.

### Cancelled

```ts
await player.cancel("7e08eef2-d2ae-4247-860d-b2812bb149e0");
```

```json
{ "cancelled": "7e08eef2-d2ae-4247-860d-b2812bb149e0" }
```

Before the reply, the player got `negotiation_closed`, maker B (holding a $1.10 quote) got `negotiation_closed` for it, and both makers got `rfq_closed`:

```json
{ "event": "negotiation_closed", "payload": { "reason": "cancelled", "market_id": null, "rfq_id": "7e08eef2-d2ae-4247-860d-b2812bb149e0" } }
{ "event": "negotiation_closed", "payload": { "reason": "cancelled", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": null, "rfq_id": "7e08eef2-d2ae-4247-860d-b2812bb149e0", "quote_id": "5cf079f5-d7e0-47bf-a72a-b132407601b9" } }
{ "event": "rfq_closed", "payload": { "reason": "cancelled", "market_id": null, "rfq_id": "7e08eef2-d2ae-4247-860d-b2812bb149e0" } }
```

A second `cancel()` throws `STXComboRefusedException` with `code` `not_found`.

### The requesting channel leaves

A request belongs to the channel that opened it. Here a second player socket opened `a166c823-42b0-4438-b987-ba3727ce72a4`, maker A quoted $3.00, and that socket's session called `leave()`. The player's main session, which did not open the request, still heard it end, and so did maker A:

```json
{ "event": "negotiation_closed", "payload": { "reason": "cancelled", "market_id": null, "rfq_id": "a166c823-42b0-4438-b987-ba3727ce72a4" } }
{ "event": "negotiation_closed", "payload": { "reason": "cancelled", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": null, "rfq_id": "a166c823-42b0-4438-b987-ba3727ce72a4", "quote_id": "a9632e81-aa7f-4c88-96b6-fe7d8310f93b" } }
```

### Expired

A request lives 5 minutes. This one, a $1.00 stake on SHK @ INT to win and RCL @ BRU to lose, held maker A's $2.50 and was left to run out:

```json
{ "event": "negotiation_closed", "payload": { "reason": "expired", "market_id": null, "rfq_id": "5326d6fb-066a-44e4-abf7-0cedfdee63b5" } }
{ "event": "negotiation_closed", "payload": { "reason": "expired", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": null, "rfq_id": "5326d6fb-066a-44e4-abf7-0cedfdee63b5", "quote_id": "90fa5ab4-fb86-4da9-88b2-edfac3be4328" } }
{ "event": "rfq_closed", "payload": { "reason": "expired", "market_id": null, "rfq_id": "5326d6fb-066a-44e4-abf7-0cedfdee63b5" } }
```

A quote that expires before its request is not pushed when it expires. Its maker hears `negotiation_closed` `expired` when the next quote on the request arrives. Here maker A's $1.00 quote lived 1 second, and maker B's $1.10 arrived 1.5 seconds later:

```json
{ "event": "negotiation_closed", "payload": { "reason": "expired", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": null, "rfq_id": "7e08eef2-d2ae-4247-860d-b2812bb149e0", "quote_id": "10bd1e24-a051-44ba-a770-49384c6ee057" } }
```

### The player's order is refused

The player opened two cash outs for the same 14 contracts, which nothing prevents, and sold them through the first. Accepting maker A's $3.50 on the second found nothing left to sell:

```ts
await player.accept("dc467ba4-9b5b-4fa1-96f5-59ac7abfd1b7", "9c858517-b60f-4598-a202-4730197faaec", "3.50");
```

```json
{
  "name": "STXComboRefusedException",
  "message": "accept on rfq:c7a6abb5-5e79-489d-951f-c043d4377fac refused: player_refused:nothing_to_sell",
  "code": "player_refused",
  "detail": "nothing_to_sell",
  "command": "accept"
}
```

The request ended for everyone. The player got `negotiation_closed` `player_refused`; maker A got `quote_accepted` first, then `negotiation_closed` `player_refused` and `rfq_closed` `player_refused`:

```json
{ "event": "negotiation_closed", "payload": { "reason": "player_refused", "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "rfq_id": "dc467ba4-9b5b-4fa1-96f5-59ac7abfd1b7" } }
{ "event": "quote_accepted", "payload": { "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "rfq_id": "dc467ba4-9b5b-4fa1-96f5-59ac7abfd1b7", "quote_id": "9c858517-b60f-4598-a202-4730197faaec" } }
{ "event": "negotiation_closed", "payload": { "reason": "player_refused", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": "c5cde34a-d13c-4849-8135-8ed0baf70ccd", "rfq_id": "dc467ba4-9b5b-4fa1-96f5-59ac7abfd1b7", "quote_id": "9c858517-b60f-4598-a202-4730197faaec" } }
```

`err.detail` is the order refusal: here `nothing_to_sell`, elsewhere for example `insufficient_assets` or `loss_limit24_hours`. A `quote_accepted` is not a promise: it can be followed by `negotiation_closed` instead of `placed`. A further `cashOut()` on the combination now throws `code` `nothing_to_sell`.

### A leg closes while the request is open

Leg status is checked when a request opens and again at the bind. This $1.00 request on the two 15-minute crypto markets `6e0bc6bd-5fb5-4323-822e-f930b10da543` (BTC 15 min) and `2cab001b-80a8-4274-a85c-de1d90f90289` (ETH 15 min) opened at 04:58:30 UTC, maker A quoted $2.00, and the player accepted at 05:00:20, 20 seconds after both markets stopped trading:

```ts
await player.accept("d27b9fa2-ba0c-406b-9d63-501d41222fcb", "4bfc0e96-c989-49ba-8536-331f20bda681", "2.00");
```

```json
{
  "name": "STXComboRefusedException",
  "message": "accept on rfq:c7a6abb5-5e79-489d-951f-c043d4377fac refused: leg_not_quotable",
  "code": "leg_not_quotable",
  "command": "accept"
}
```

Maker A heard `quote_accepted` before the bind read the legs again, then the ending; the player got `negotiation_closed` before the error:

```json
{ "event": "quote_accepted", "payload": { "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": null, "rfq_id": "d27b9fa2-ba0c-406b-9d63-501d41222fcb", "quote_id": "4bfc0e96-c989-49ba-8536-331f20bda681" } }
{ "event": "negotiation_closed", "payload": { "reason": "leg_not_quotable", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "market_id": null, "rfq_id": "d27b9fa2-ba0c-406b-9d63-501d41222fcb", "quote_id": "4bfc0e96-c989-49ba-8536-331f20bda681" } }
{ "event": "rfq_closed", "payload": { "reason": "leg_not_quotable", "market_id": null, "rfq_id": "d27b9fa2-ba0c-406b-9d63-501d41222fcb" } }
{ "event": "negotiation_closed", "payload": { "reason": "leg_not_quotable", "market_id": null, "rfq_id": "d27b9fa2-ba0c-406b-9d63-501d41222fcb" } }
```

A new `requestQuote()` on the same legs then throws `code` `leg_not_quotable` straight away.

[Combo negotiation](/websockets/channels/combo-negotiation/#how-a-negotiation-ends) lists every ending, including `maker_refused` (a maker whose side of the bind fails) and the endings that need the exchange itself to fail.

## As a player

`ws.comboPlayer()` joins your player topic. `requestQuote()` opens a request; makers' quotes arrive as `best_quote` events, and `accept()` binds the best one at the amount you name or more.

```ts
import { STX, STXComboPriceMovedException } from "@stxapp/stx-typescript";

const client = new STX();
const ws = await client.websocket().connect();
const player = await ws.comboPlayer();

const { rfq_id } = await player.requestQuote(
  [
    { marketId: "29396ce5-a4f3-4d81-b980-2f637ab3a660", outcome: "won" },
    { marketId: "663ebfe6-26d5-45e0-a0ac-6859f7087832", outcome: "won" },
  ],
  "5.00",
);
const best = await player.waitFor("best_quote", { rfqId: rfq_id, timeoutMs: 30_000 });

try {
  const placed = await player.accept(rfq_id, best.payload.quote_id!, best.payload.amount!);
  console.log(placed); // { market_id: "c5cde34a-d13c-4849-8135-8ed0baf70ccd", quantity: "14.00", price: "0.3334", order_id: "fba8fd62-3389-4d3d-ac09-a0b383f6c11e" }
} catch (err) {
  if (err instanceof STXComboPriceMovedException) console.log("best is now", err.best.amount);
  else throw err;
}
```

Fills, positions and settlements of the combination arrive on `ws.fills()`, `ws.positions()` and `ws.settlements()` like any other market, under the `market_id` from the reply. `cashOut(marketId)` asks makers to quote for what you hold of a combination, and the rest works the same way. `snapshot(rfqId)` reads a request and its best quote now, and `cancel(rfqId)` ends it.

## As a maker

`ws.comboMaker()` joins your maker topic and delivers every request opened after the join as `rfq_opened`, your own account's included. Quote an amount with an expiry; `quote_accepted` and `placed` follow when yours binds.

```ts
const maker = await ws.comboMaker({
  onEvent: async (e) => {
    if (e.event === "rfq_opened" && e.payload.side === "buy") {
      // e.payload.amount is the stake, e.g. "5.0000"; price off max_price and tick_size
      const { quote_id } = await maker.quote(e.payload.rfq_id, "10.00", 60_000, { clientOrderId: "tssdk-capture-buy" });
      console.log("quoted", quote_id); // "05308411-acfa-4b81-af8c-c390ad41a254"
    } else if (e.event === "placed") {
      console.log(e.payload.order_id, e.payload.quantity, e.payload.price); // "b24cabe6-9a53-48e3-b891-2a19245690ec" "14.00" "0.3334"
    } else if (e.event === "negotiation_closed") {
      console.log(e.payload.quote_id, e.payload.reason); // "f6fdf76e-0c74-44cf-8180-b0f1fb0f09df" "placed_elsewhere"
    }
  },
});
```

`withdrawQuote(rfqId, quoteId)` removes one of your quotes. You may hold several quotes on one request; the player is shown only the best.

## Events

Each session hands you typed events, narrowed on `event`:

| Session | Event | Payload fields |
|---|---|---|
| `ComboPlayer` | `best_quote` | `rfq_id`, `market_id`, `quote_id`, `amount`, `expires_at` (the last three `null` when nothing is live) |
| `ComboPlayer` | `placed` | `rfq_id`, `market_id`, `order_id`, `quantity`, `price` |
| `ComboPlayer` | `negotiation_closed` | `rfq_id`, `market_id`, `reason`: `cancelled`, `expired`, `leg_not_quotable`, `unknown_leg`, `player_refused`, `maker_refused` |
| `ComboMaker` | `rfq_opened` | `rfq_id`, `market_id`, `external_id`, `side`, `amount`, `quantity`, `max_price`, `tick_size`, `expires_at`, `legs` |
| `ComboMaker` | `rfq_closed` | `rfq_id`, `market_id`, `reason`: `placed`, `cancelled`, `expired`, `leg_not_quotable`, `unknown_leg`, `player_refused`, `maker_refused`, `ended` |
| `ComboMaker` | `quote_accepted` | `rfq_id`, `market_id`, `external_id`, `quote_id` |
| `ComboMaker` | `placed` | `rfq_id`, `market_id`, `external_id`, `quote_id`, `order_id`, `quantity`, `price` |
| `ComboMaker` | `negotiation_closed` | `rfq_id`, `market_id`, `external_id`, `quote_id`, `reason`: `placed_elsewhere`, `maker_refused`, `cancelled`, `expired`, `leg_not_quotable`, `unknown_leg`, `player_refused` |

Take them with `onEvent`, with `for await (const e of player)`, one at a time with `next(timeoutMs)`, or with `waitFor(event, { rfqId, quoteId, timeoutMs })`. `waitFor` also looks at events already received, so a `placed` that arrives before the `accept()` reply is not missed. `player.events` holds the latest 1000. Payloads keep the exchange's snake_case fields: money is a dollar string, quantities are strings and `expires_at` is microseconds since the Unix epoch. Every socket of an account receives that account's player events, whichever socket opened the request.

## Errors

| Exception | When |
|---|---|
| `STXCombosUnavailableException` | Combos are not available on this environment. Thrown by `comboPlayer()` and `comboMaker()`; nothing else is affected. |
| `STXComboPriceMovedException` | `accept()` found nothing live at your amount or better. `err.best` is the live best quote, all `null` when nothing is live; the request stays open. |
| `STXComboRefusedException` | Any other refusal. `err.code` is the exchange's reason, `err.command` the command, and `err.message` reads `<command> on <topic> refused: <reason>`. For `player_refused`, `err.detail` holds the order refusal. |
| `TypeError` | A money argument that is not a string, thrown before anything is sent. |

All three combo exceptions extend `STXChannelException`, so `err.topic` and `err.reply` (the exchange's reply) are there too. Joining on an exchange without combos:

```ts
try {
  await ws.comboPlayer();
} catch (err) {
  if (err instanceof STXCombosUnavailableException) console.log(err.message, err.reason);
  else throw err;
}
```

```json
"Combos are not available on this environment."
"unmatched topic"
```

Joining with a read-only API key throws `STXComboRefusedException` with the exchange's sentence as the code:

```json
{
  "name": "STXComboRefusedException",
  "message": "phx_join on rfq:bc2d4b34-5d21-4a2a-8af6-d1ad3846ac3f refused: This API key does not have write access",
  "code": "This API key does not have write access",
  "command": "phx_join"
}
```

The refusals captured for this page, each an `STXComboRefusedException` with this `code`:

| Call | `err.code` |
|---|---|
| `requestQuote()` with one leg | `combo_minimum_two_markets_needed` |
| `requestQuote()` with six legs | `combo_maximum_legs_exceeded` |
| `requestQuote()` with a `marketId` that is not a UUID | `malformed_id` |
| `requestQuote()` with an outcome other than `won` or `lost` | `unknown_outcome` |
| `requestQuote()` with a leg id no market has, or a combination's id | `unknown_leg` |
| `requestQuote()` naming one market twice | `duplicate_legs` |
| `requestQuote()` or `accept()` once a leg has stopped trading | `leg_not_quotable` |
| `requestQuote()` with `"1e400"` | `malformed_money` |
| `requestQuote()` with `"0"` or `"-5.00"` | `non_positive_amount` |
| `requestQuote()` with `"5.001"` | `sub_cent_amount` |
| `requestQuote()` with more than your balance | `insufficient_assets` |
| `cashOut()` with an ordinary market or an unknown id | `unknown_combination` |
| `cashOut()` with a combination that is no longer open | `market_not_quotable` |
| `cashOut()` with nothing left to sell | `nothing_to_sell` |
| `snapshot()`, `cancel()`, `accept()` or `quote()` on a request that has ended, or never existed | `not_found` |
| `snapshot()`, `cancel()` or `accept()` from another account | `not_the_requester` |
| any call with an id that is not a UUID | `malformed_id` |
| `quote()` with `expiresInMs` of `0` or `1.5` | `expires_at_required` |
| `quote()` with a `clientOrderId` over 255 bytes | `malformed_client_order_id` |
| `quote()` with `"0"` | `non_positive_amount` |
| `quote()` with `"0.0000001"`, too small to price | `price_out_of_range` |
| `quote()` of `"0.01"` on a `"0.50"` stake, which buys no whole contract | `below_one_contract` |
| `quote()` on your own account's request | `cannot_quote_own_request` |
| `withdrawQuote()` of a quote you no longer hold, or another maker's | `unknown_quote` |

[Combo negotiation](/websockets/channels/combo-negotiation/) lists every reason, including those only a failing exchange sends.

## Connections

A request belongs to the channel that opened it, and a quote to the channel that placed it. Leaving or a dropped socket ends your open requests (`cancelled`) and withdraws your quotes. The SDK rejoins after a reconnect, but the requests and quotes do not come back: open them again.
