> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kairos.trade/llms.txt
> Use this file to discover all available pages before exploring further.

# Synthetic Book Stream

> Create a weighted formula and consume its live snapshot and delta stream

This tutorial connects a client to a live Synthetic Book stream, subscribes to
one canonical definition, decodes its initial snapshot, applies deltas, and
recovers safely after a sequence gap. Reach for it when you want a weighted
package of prediction-market legs — a spread, a basket, a relative-value pair —
priced as one book instead of assembling the legs yourself.

For formula semantics and worked examples, read the
[Synthetic Books guide](/guides/synthetic-books) first.

## What you need

* A canonical ID such as `syn_0123456789abcdef` from the Synthetic Books
  definition workflow.
* Kairos API credentials. The same credential triple creates definitions and
  authenticates the stream; no second Synthetic Books secret is issued. Reading
  an existing ID also works on the bounded anonymous tier in an environment
  where it is enabled.
* The public [WebSocket control schema](/websocket/protobuf-reference) and
  [synthetic book schema](https://app.kairos.trade/proto/kairos/synthetic/v1/synthetic.proto).

The stream endpoint is:

```text theme={null}
wss://stream.kairos.trade
```

Definitions are created through the public Order Execution API:

```text theme={null}
https://execution.kairos.trade/v1/synthetics
```

The WebSocket itself remains subscription-only: it does not accept raw formulas
and accepts the canonical ID returned by that API. Definition lifecycle details
are managed by Kairos; store and reuse only the opaque IDs returned by the
public control plane.

## 1. Craft the formula

A definition is a weighted sum:

```text theme={null}
S = w1*A + w2*B + ... + wn*N
```

Submit this payload to `POST /v1/synthetics`. This request creates a
50-level decomposed book for `A - B`:

```json theme={null}
{
  "legs": [
    {
      "venue": "polymarket",
      "contract_id": "<market-id-a>",
      "token_id": "<outcome-token-id-a>",
      "weight": "1"
    },
    {
      "venue": "predictfun",
      "contract_id": "<market-id-b>",
      "token_id": "<outcome-token-id-b>",
      "weight": "-1"
    }
  ],
  "output_mode": "decomposed_l2",
  "depth": 50,
  "classification": "relative_value"
}
```

Use market and outcome identifiers returned by Kairos market metadata. A
positive weight buys that outcome when buying the synthetic; a negative weight
sells it. Always encode `weight` as a decimal string so values such as
`"0.333333333"` are represented exactly.

Choose an output mode based on what your client needs:

| `output_mode` | Output |
| - | - |
| `bbo` | Best synthetic bid and ask only |
| `aggregated_l2` | Price levels with equivalent construction paths combined |
| `decomposed_l2` | Price levels plus the source actions that construct them |

For example:

```bash theme={null}
curl -X POST https://execution.kairos.trade/v1/synthetics \
  -H "X-Client-Id: $KAIROS_CLIENT_ID" \
  -H "X-Api-Key: $KAIROS_API_KEY" \
  -H "X-Api-Secret: $KAIROS_API_SECRET" \
  -H "Content-Type: application/json" \
  --data @definition.json
```

The response includes the canonical ID and an opaque, account-owned lease:

```json theme={null}
{
  "synthetic_id": "syn_0123456789abcdef",
  "subscription_id": "01991d37-95f0-7bf3-9e31-17eaeb82f266",
  "created": true,
  "fees_updated": false,
  "sources_subscribed": 2,
  "expires_at_ms": 1787202600000,
  "ttl_ms": 600000
}
```

The values above are illustrative. Use the ID returned by the service; do not
hash the definition or construct the ID yourself. Refresh the lease before
`expires_at_ms` with
`POST /v1/synthetics/subscriptions/{subscription_id}/refresh`. When the caller
no longer needs the definition, release its handle with
`DELETE /v1/synthetics/subscriptions/{subscription_id}`. Send the same three
API-key headers on both calls. A lease lasts 10 minutes; refreshing every five
minutes leaves room for a retry. The account may hold at most 25 active leases,
and definition creation is limited to 30 requests per minute by default.

Only the account that created a lease can refresh, release, or inspect it. An
expired lease cannot be revived: create the definition again and use the newly
returned subscription UUID.

## 2. Compile the protobuf schemas

Download [the control schemas](/websocket/protobuf-reference#getting-the-proto-files) and
the [synthetic schema](https://app.kairos.trade/proto/kairos/synthetic/v1/synthetic.proto), preserving
their directory paths. For Python:

```bash theme={null}
python -m pip install protobuf grpcio-tools websocket-client

mkdir -p generated

python -m grpc_tools.protoc \
  --proto_path=proto \
  --python_out=generated \
  proto/kairos/v1/common.proto \
  proto/kairos/v1/websocket_control.proto \
  proto/kairos/synthetic/v1/synthetic.proto
```

Add `generated` to `PYTHONPATH`, or package the generated modules with your
application.

## 3. Connect and authenticate

Programmatic clients should send all three API-key headers during the WebSocket
upgrade:

```text theme={null}
X-Client-Id: kairos_ck_...
X-Api-Key: <api key>
X-Api-Secret: <api secret>
```

Browser clients use a session JWT in `Sec-WebSocket-Protocol`. For details and
the anonymous public protocol, see [Market Data Stream authentication](/websocket/market-data-websocket#connecting).

For example, a Python client using an enabled anonymous tier can open the same
endpoint with:

```python theme={null}
ws = websocket.create_connection(
    "wss://stream.kairos.trade",
    subprotocols=["kairos.marketdata.public.v1"],
)
```

Never put credentials in query parameters, source control, or logs.

## 4. Subscribe to the synthetic ID

Send a binary `SubscribeRequest` using the ordinary control-message envelope:

```text theme={null}
[0x10] [kairos.v1.SubscribeRequest protobuf]
```

Set the fields exactly as follows:

```text theme={null}
contract_id: "syn_0123456789abcdef"
provider: "synthetic"
topics: ["synthetic"]
```

The ID must match `syn_` followed by 16 lowercase hexadecimal characters. The
`synthetic` topic must be named explicitly; an empty topic list does not select
it. An accepted request produces `SubscribedResponse` (`0x14`). A cached
snapshot may arrive immediately, so dispatch every frame by tag instead of
waiting for the acknowledgment before reading data.

### Choose how much of the book you need

One synthetic ID can be consumed at three weights. Name one or more of these
topics in `topics`; a request may list several, and a later request for the
same ID adds to what the connection already holds.

| Topic | You receive | On subscribe | Use it for |
| - | - | - | - |
| `synthetic` | `SyntheticSnapshot` and every `SyntheticDelta` | The cached snapshot and the deltas since it | An order sheet or a full ladder |
| `synthetic.snapshot` | `SyntheticSnapshot` only, at the publisher's cadence | The latest snapshot | Periodic full states without deltas |
| `synthetic.quote` | `SyntheticQuote` only, when the top of the book changes | The current top of the book | A card showing best bid and ask |

`synthetic.snapshot` and `synthetic.quote` require `provider: "synthetic"`.
A connection that holds both `synthetic` and `synthetic.snapshot` for one ID
receives each snapshot once.

Keep the snapshot with the highest `ownership_epoch`, then the highest
`synthetic_sequence`: the one sent on subscribe and a live one can arrive in
either order. Between snapshots a `synthetic.snapshot` subscriber holds the last
full state, which can be behind the live book by the deltas published since. A
snapshot is published on the first materialization, on every status change, and
then periodically.

## 5. Decode snapshots and deltas

Synthetic data frames have a two-byte header:

```text theme={null}
[1-byte type tag] [1-byte version] [protobuf payload]
```

| Tag | Version | Payload |
| - | - | - |
| `0x50` | `0x01` | `kairos.synthetic.v1.SyntheticSnapshot` |
| `0x52` | `0x01` | `kairos.synthetic.v1.SyntheticDelta` |
| `0x53` | `0x01` | `kairos.synthetic.v1.SyntheticQuote` |

`0x51` is reserved for synthetic status notices and is not sent on this stream.

Control frames such as `SubscribedResponse` still use their one-byte header.
Ignore unknown tags and unsupported versions so future additions do not break
the connection.

This complete Python client subscribes with API-key credentials and maintains a
price-keyed local book:

```python theme={null}
import os
import websocket

from kairos.v1 import websocket_control_pb2
from kairos.synthetic.v1 import synthetic_pb2

URL = "wss://stream.kairos.trade"
SYNTHETIC_ID = os.environ["KAIROS_SYNTHETIC_ID"]

headers = [
    f"X-Client-Id: {os.environ['KAIROS_CLIENT_ID']}",
    f"X-Api-Key: {os.environ['KAIROS_API_KEY']}",
    f"X-Api-Secret: {os.environ['KAIROS_API_SECRET']}",
]
ws = websocket.create_connection(URL, header=headers)

def send_subscribe():
    request = websocket_control_pb2.SubscribeRequest(
        contract_id=SYNTHETIC_ID,
        provider="synthetic",
        topics=["synthetic"],
    )
    ws.send_binary(b"\x10" + request.SerializeToString())

send_subscribe()

book = None
subscribed = False

def decimal_value(value, exponent):
    return value / (10 ** exponent)

def accept_snapshot(snapshot):
    global book
    if book and snapshot.ownership_epoch < book["epoch"]:
        return
    book = {
        "sequence": snapshot.synthetic_sequence,
        "epoch": snapshot.ownership_epoch,
        "status": snapshot.status,
        "price_exponent": snapshot.price_unit_exponent,
        "qty_exponent": snapshot.qty_unit_exponent,
        "bids": {level.price_nano: level for level in snapshot.bids},
        "asks": {level.price_nano: level for level in snapshot.asks},
    }
    best_bid = max(book["bids"], default=None)
    best_ask = min(book["asks"], default=None)
    display_bid = (decimal_value(best_bid, book["price_exponent"])
                   if best_bid is not None else None)
    display_ask = (decimal_value(best_ask, book["price_exponent"])
                   if best_ask is not None else None)
    print("snapshot", book["sequence"], display_bid, display_ask)

def apply_delta(delta):
    global book
    if book is None or delta.ownership_epoch < book["epoch"]:
        return
    if (delta.ownership_epoch == book["epoch"] and
            delta.synthetic_sequence <= book["sequence"]):
        return  # Duplicate or stale delivery after replay
    if (delta.ownership_epoch != book["epoch"] or
            delta.previous_sequence != book["sequence"]):
        book = None
        print("sequence gap; requesting a new snapshot")
        send_subscribe()
        return
    for change in delta.changes:
        if change.side == synthetic_pb2.BOOK_SIDE_BID:
            levels = book["bids"]
        elif change.side == synthetic_pb2.BOOK_SIDE_ASK:
            levels = book["asks"]
        else:
            book = None
            print("invalid side; requesting a new snapshot")
            send_subscribe()
            return
        if change.qty_nano == 0:
            levels.pop(change.price_nano, None)
        else:
            levels[change.price_nano] = change
    book["sequence"] = delta.synthetic_sequence
    book["status"] = delta.status
    book["price_exponent"] = delta.price_unit_exponent
    book["qty_exponent"] = delta.qty_unit_exponent
    print("delta", book["sequence"], len(book["bids"]), len(book["asks"]))

while True:
    frame = ws.recv()
    if not isinstance(frame, bytes) or not frame:
        continue
    tag = frame[0]
    if tag == 0x50 and len(frame) >= 2 and frame[1] == 1:
        snapshot = synthetic_pb2.SyntheticSnapshot()
        snapshot.ParseFromString(frame[2:])
        accept_snapshot(snapshot)
    elif tag == 0x52 and len(frame) >= 2 and frame[1] == 1:
        delta = synthetic_pb2.SyntheticDelta()
        delta.ParseFromString(frame[2:])
        apply_delta(delta)
    elif tag == 0x14:
        response = websocket_control_pb2.SubscribedResponse()
        response.ParseFromString(frame[1:])
        subscribed = True
        print("subscribed", response.contract_id)
    elif tag == 0x17:
        error = websocket_control_pb2.ErrorResponse()
        error.ParseFromString(frame[1:])
        if not subscribed:
            raise RuntimeError(f"{error.action}: {error.message}")
        print("stream warning", error.action, error.message)
```

The example keeps nano-unit integers as dictionary keys. Convert only for
display using the exponent carried by the frame; both exponents are currently 9. Integer keys avoid floating-point collisions between price levels.

## 6. Apply deltas safely

A snapshot replaces the entire local book. A delta applies only when:

1. Its `ownership_epoch` equals the snapshot epoch you currently hold.
2. Its `previous_sequence` equals your current `synthetic_sequence`.
3. Its `synthetic_sequence` is greater than the current sequence.

Ignore a delta from the current epoch when its `synthetic_sequence` is less
than or equal to the sequence already applied. A replay can briefly overlap
with live delivery, so receiving the same delta twice is not a sequence gap.
Recover only when a **newer** delta does not connect to the current sequence, or
when a higher ownership epoch arrives without its snapshot.

Each `SyntheticLevelChange` is addressed by side and `price_nano`. A zero
`qty_nano` removes that price; a positive quantity replaces the complete level.
In decomposed mode, replace the recipes with the recipes on the change as well.

`InputVersionChange.leg_index` addresses the corresponding leg in the canonical
definition echoed by the snapshot. Apply those sparse changes if your client
displays or validates source lineage.

<Note>
  **Gotcha: a failed check has no tolerance — resync fully.** If any sequence
  or epoch check fails, discard the local book and send the same
  `SubscribeRequest` again. That requests a cached snapshot and its contiguous
  deltas without consuming another subscription slot. If no complete replay is
  available, wait for the next snapshot. Never apply a delta to an old
  baseline.
</Note>

The gateway also fences the stream on your behalf. A delta whose
`previous_sequence` does not chain onto the frame the gateway holds is
**dropped rather than forwarded**, as is a delta from a lower `ownership_epoch`
and any duplicate redelivery. So a gap upstream shows up as silence, not as a
broken chain, and it ends at the next snapshot — the producer snapshots every
64 deltas, and the gateway's replay buffer holds at most 256. While the chain
is broken, a re-subscribe replays nothing at all until that snapshot
re-anchors it; a snapshot plus a chain with a hole would leave you
confidently wrong.

The gateway drops frames that fail validation before they ever reach you:
a mismatched `synthetic_id`, a zero `synthetic_sequence`, a
`price_unit_exponent` / `qty_unit_exponent` other than 9, an empty
`publisher_instance_id`, a non-monotonic or crossed ladder, a non-positive
quantity on a snapshot level, a negative `qty_nano` on a change, or a change
whose `side` is neither `BOOK_SIDE_BID` nor `BOOK_SIDE_ASK`.

## 7. Decide whether the quote is usable

Only treat levels as executable when `status` is
`MATERIALIZATION_STATUS_EXECUTABLE`. Empty ladders with `NO_LIQUIDITY`, and all
non-live statuses, are not zero-priced opportunities.

For execution-aware clients:

* Reject or revalidate recipes after `valid_until_ms`.
* Check `max_input_age_ms` and `cross_venue_skew_ms` against your own policy.
* Read the source action on each recipe leg; do not infer it from the sign of
  the formula at execution time.
* Include fees, balances, allowances, and partial-fill risk before submitting
  source orders.

## 8. Read the top of the book only

Subscribe with `topics: ["synthetic.quote"]` to receive one small frame
instead of the ladder:

| Field | Type | Meaning |
| - | - | - |
| `synthetic_id` | string | The subscribed ID |
| `synthetic_sequence` | uint64 | Sequence of the book frame the quote was read at |
| `ownership_epoch` | uint64 | Publisher epoch of that frame |
| `status` | enum | `MaterializationStatus` of the book |
| `best_bid` | `SyntheticLevel` | Dearest bid; absent when the bid side is empty |
| `best_ask` | `SyntheticLevel` | Cheapest ask; absent when the ask side is empty |
| `calculated_at_ms` | int64 | When the book frame was calculated |
| `price_unit_exponent` | uint32 | Always `9` |
| `qty_unit_exponent` | uint32 | Always `9` |

Each level carries `price_nano`, `qty_nano`, and one `recipes` entry per venue
resting at that level, with the venue name, its token, the action, and the
venue's own `price_nano` and `qty_nano`. On a `union_l2` book the level price
already includes that venue's taker fee, and the recipe price is the raw price
the venue shows. Recipe `weight_nano`, `source_seq`, and `source_epoch` are
not set on a quote.

A quote is sent when you subscribe, when the best bid or best ask changes in
price, quantity, or venue, when `status` changes, and with every snapshot of
the book. Changes deeper in the book send nothing.

Apply these rules:

* Keep the quote with the highest `ownership_epoch`, then the highest
  `synthetic_sequence`, and drop anything older. The quote sent on subscribe
  and a live quote can arrive in either order.
* A quote with `MATERIALIZATION_STATUS_UNSPECIFIED` and no sides is a
  withdrawal: the server lost track of the book, because of a sequence gap or
  a rejected frame. It carries the sequence of the quote it withdraws. Clear
  the quote you hold. Nothing is sent on subscribe during that time.
* Only treat the prices as executable when `status` is
  `MATERIALIZATION_STATUS_EXECUTABLE`.
* On a `union_l2` book the best bid can be above the best ask. The two sides
  rest on different venues, so the cross is a real cross-venue quote.
* The next snapshot produces a fresh quote.

## 9. Unsubscribe and reconnect

To stop the stream, send:

```text theme={null}
[0x11] [kairos.v1.UnsubscribeRequest protobuf]
```

with the same `contract_id` and `provider: "synthetic"`. The server responds
with `UnsubscribedResponse` (`0x15`). This drops every topic the connection
holds for that ID.

To drop some synthetic topics and keep the rest, add the topic names as
`repeated string` field number `4` of the same request:

```text theme={null}
contract_id: "syn_0123456789abcdef"
provider: "synthetic"
4: "synthetic.quote"
```

Only the three synthetic topics can be named. Naming the last topic the
connection holds for that ID releases the subscription as a whole.

After any disconnect, clear the local book, reconnect with exponential backoff
and jitter, and send a fresh subscription. Do not carry a snapshot or sequence
across connections.

## Errors and close codes

Subscribe rejections arrive as `ErrorResponse` (`0x17`) with
`action: "subscribe"`. The ones you are most likely to hit here are
`synthetic requires a canonical synthetic id (syn_ followed by 16 hex digits)`,
`synthetic.snapshot and synthetic.quote require provider synthetic`,
`Subscription limit exceeded: max N subscriptions per connection`, and
`Subscription limit exceeded: too many active subscriptions for this account`.
The connection stays open — only the offending request is dropped.

The close codes you will see on this stream:

| Close code | When it happens | What to do |
| - | - | - |
| `4002` | An anonymous session reached its one-hour lifetime | Reconnect as-is; the tier is time-boxed. Clear the local book first. |
| `4003` | The anonymous admission lease was lost (fleet capacity) | Reconnect with backoff; consider authenticating. |

The full catalogue of `ErrorResponse` messages, upgrade-time rejections, and
every close code is on
[Market Data Stream → Errors & Disconnection](/websocket/market-data-websocket#errors-disconnection).

## Stream limits

Formula limits are listed in the [Synthetic Books guide](/guides/synthetic-books#current-limits).
The WebSocket limits apply independently:

| Tier | Relevant limits |
| - | - |
| Anonymous | Up to 2 connections per source IP, 3 subscribed IDs per connection, 10 inbound control messages per second, and a 1-hour connection lifetime (close code `4002`). Fleet capacity also applies (close code `4003` if the admission lease is lost). |
| Authenticated | Server defaults are 100 connections per user, 100 subscriptions per connection, 2000 active subscriptions per account, and 50 inbound messages per second. An API key may carry its own connection and subscription overrides. |

One synthetic ID counts as one subscription however many of the three topics
the connection holds for it. Re-subscribing to the same provider and ID, or
adding another topic to it, does not consume another subscription slot.

For all connection limits, error frames, close codes, and reconnect behavior,
see [Market Data Stream](/websocket/market-data-websocket#connection-limits).

## Troubleshooting

| Symptom | Likely cause | Action |
| - | - | - |
| Definition request returns `401` or `403` | The API-key triple is incomplete, invalid, revoked, or blocked by its IP restriction | Send all three headers and verify the credential in the Kairos dashboard |
| `synthetic requires a canonical synthetic id` | The ID is malformed or was derived locally | Use the lowercase `syn_` ID returned by definition creation |
| Acknowledgment but no snapshot | The definition is not materialized yet, its source books are pending, or no complete cached replay is available | Keep listening for the next snapshot and verify the definition lifecycle |
| Snapshot has no levels | Read `status`; a source may be missing, not live, or have no required-side liquidity | Do not treat an empty ladder as a price |
| Delta sequence does not connect | The local baseline is incomplete | Discard it and resubscribe for a new snapshot |
| Values look one billion times too large | Nano-unit integers were displayed directly | Divide by `10 ** price_unit_exponent` or `10 ** qty_unit_exponent` |
| `ErrorResponse` with `action: "subscribe"` | Invalid request or a subscription quota was reached | Read `message`, correct the request, or release another subscription |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.