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

> ## Agent Instructions
> Prices are US dollars per contract that pays $1: 0.46 means 46 cents. Spread is the Yes ask plus the No ask minus $1. Liquidity grades are deep, tradeable and thin; lists hide thin markets unless include_thin=true. Relations are proven from contract terms, never inferred from wording; a lead marked informational is never executable. Every figure carries a graph_run; read /agents/reading-order first, then /concepts/relations and /concepts/liquidity.

# Live stream

> Quotes, trades, books, relations, leads and rule changes pushed as they are recorded, over one resumable connection.

`GET /v1/stream` pushes events as they are recorded, as [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) over one keyed HTTP connection. Every data event has an id; reconnect with the last id you saw and the stream replays what you missed, in order, then continues live. Authenticate as for any request. A browser `EventSource` cannot send the key header, so connect from a server.

```bash theme={null}
curl -N "https://api.routeur.app/v1/stream?topics=trades,leads&min_usd=100" \
  -H "X-API-Key: $ROUTEUR_API_KEY"
```

## Topics

`topics` is required: a comma-separated list of what to receive.

| Topic       | Event                             | When                                                                                                                                                             |
| ----------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `quotes`    | `quote`                           | A market's top-of-book asks changed. Recorded on each crawl of a venue (Kalshi every 15 minutes, Polymarket every 30), so quotes arrive in bursts.               |
| `trades`    | `trade`                           | A public fill was recorded, usually within a minute of execution.                                                                                                |
| `books`     | `book`                            | The top of a market's recorded order book changed (best prices or the size at them), at most once a second per market. See [Order books](/concepts/order-books). |
| `relations` | `relation`, `relations_truncated` | A finished graph run proved relations for the first time. Runs finish hourly.                                                                                    |
| `leads`     | `lead`                            | Every lead of a finished graph run, up to 1,000, in the order of `GET /v1/opportunities`.                                                                        |
| `rules`     | `rule_change`                     | A market's terms changed: a new contract version, a rulebook above it, or the venue's terms document. See [Rules](/concepts/rules).                              |

## Filters

| Parameter | Meaning                                                                                                                       |
| --------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `venue`   | `kalshi` or `polymarket`. Quotes, trades, books and rule changes by their market's venue; relations and leads by either side. |
| `markets` | Up to 50 `venue:id` pairs, comma-separated. Same sides as `venue`.                                                            |
| `min_usd` | Only trades of at least this notional, in whole dollars. Other topics are unaffected.                                         |
| `cursor`  | Resume after this event id. `Last-Event-ID` does the same; `cursor` wins when both are sent.                                  |

`relations_truncated` is sent to every relations stream whatever the filters. An unknown topic, a malformed market or a cursor that is not an id is `400 invalid_parameter`.

## The connection

The response opens with a `retry` line and a `ready` event:

```
retry: 3000

event: ready
data: {"topics":["quotes","rules"],"heartbeat_seconds":15,"reconnect_after_seconds":3113,"replay_window_seconds":3600,"resumed_from":"1400000"}
```

| `ready` field             | Meaning                                                                                                        |
| ------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `topics`                  | The topics you will receive, in canonical order.                                                               |
| `heartbeat_seconds`       | A comment line `: heartbeat` is sent this often, 15 seconds. Treat 45 seconds of silence as a dead connection. |
| `reconnect_after_seconds` | When this stream will end itself with a `reconnect` event.                                                     |
| `replay_window_seconds`   | How old a cursor may be and still resume: 3,600 seconds.                                                       |
| `resumed_from`            | The cursor replay started after, when you sent one.                                                            |

Streams are not permanent. Each one ends itself after 45 to 55 minutes (the exact moment is in `ready`, jittered so a deploy's reconnects do not all return at once), and sooner when the server restarts or you fall a full buffer behind. Each ending is announced:

```
event: reconnect
data: {"reason":"lifetime","cursor":"1550787"}
```

| `reason`      | What happened                                                                                                           | What to do                                                      |
| ------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `lifetime`    | The stream reached its planned end.                                                                                     | Reconnect with `cursor`.                                        |
| `shutdown`    | The server is restarting.                                                                                               | Reconnect with `cursor` after the `retry` interval.             |
| `behind`      | You fell more than 1,024 outbox rows behind and the buffer overflowed. What was buffered was delivered first, in order. | Reconnect with `cursor`; consume faster, or narrow the filters. |
| `unavailable` | Replay from the outbox failed.                                                                                          | Reconnect with `cursor` after a short backoff.                  |

Reconnect after the `retry` interval, 3 seconds, backing off on errors.

## Ids and resuming

Every data event carries an `id`. An id is a decimal outbox position, or `position-index` for one of the many events a single graph run expands into:

```
id: 1542091
event: trade
data: {…}

id: 90467-0
event: lead
data: {…}
```

Ids are opaque and ordered; compare them only by keeping the latest. To resume, send the last id you processed as the `Last-Event-ID` header (what every SSE client library does on reconnect) or as `cursor`, with the **same filters**: events after it are replayed in order, then the stream continues live with nothing missed or repeated. Replay is served from the outbox, so a burst of 40,000 replayed quotes takes a few seconds.

A cursor from the last hour always resumes. An older cursor, or one from the future, gets a `reset` event after `ready`, and the stream continues from now:

```
event: reset
data: {"reason":"cursor_expired","message":"The cursor is older than the replay window or unknown; events since it may be missing. Resync from the REST endpoints, then continue from here."}
```

On `reset`, rebuild your state from the REST endpoints (`/v1/opportunities`, `/v1/markets/{venue}/{id}/quotes`, and so on), then carry on with the ids that follow.

## Event shapes

Every event's `data` is one JSON object. Prices and money are decimal strings in USD; times are RFC 3339 in UTC. `recorded_at` on every data event is when Routeur wrote it, which is the order events are delivered in.

### `quote`

```json theme={null}
{"venue":"kalshi","market_id":"KXDPWORLDTOURR1LEAD-BMPC26-VPER","yes_ask_usd":"0.02","observed_at":"2026-09-17T12:31:57.802317Z","recorded_at":"2026-09-17T12:31:58.156939Z"}
```

`yes_ask_usd` and `no_ask_usd` are each omitted when that side has no ask, as here where nothing is offered on No. `observed_at` is when the crawl read the venue.

### `trade`

```json theme={null}
{"venue":"kalshi","id":"072212ed-93a9-b536-e254-595be854ad8f","market_id":"KXBTC15M-26SEP170845-45","outcome":"no","taker_action":"buy","price_usd":"0.9250","quantity":"286.38","notional_usd":"264.90","executed_at":"2026-09-17T12:42:10.537867Z","block":false,"recorded_at":"2026-09-17T12:42:20.466443Z"}
```

| Field                                   | Meaning                                                                                                       |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `id`                                    | The venue's trade id.                                                                                         |
| `outcome`, `taker_action`               | Which contract the taker traded, and whether they bought or sold it.                                          |
| `price_usd`, `quantity`, `notional_usd` | Price per contract, contracts (Kalshi) or shares (Polymarket), and their product, which `min_usd` filters on. |
| `executed_at`                           | The venue's time of the fill. A recording backlog can deliver older fills; compare with `recorded_at`.        |
| `token`                                 | Polymarket only: the outcome token traded.                                                                    |
| `block`                                 | Whether the venue marked it a block trade.                                                                    |

### `book`

```json theme={null}
{"venue":"polymarket","market_id":"nfl-sea-ari-2026-09-20","yes_bid_usd":"0.65","yes_bid_quantity":"5013.88","yes_ask_usd":"0.66","yes_ask_quantity":"20907.13","mid_usd":"0.655","microprice_usd":"0.651934","spread_usd":"0.01","yes_depth_5c_usd":"73711.1337","no_depth_5c_usd":"41917.6905","imbalance":"0.274961","venue_ts":"2026-09-17T12:42:56.414Z","observed_at":"2026-09-17T12:42:56.467352Z","recorded_at":"2026-09-17T12:42:56.637111Z"}
```

The top of the book in Yes terms, as on `GET /v1/markets/{venue}/{id}/book`: `yes_bid_usd`/`yes_bid_quantity` and `yes_ask_usd`/`yes_ask_quantity` (each pair omitted when that side is empty), `mid_usd`, `microprice_usd`, `spread_usd`, the USD within 5¢ of each side's best bid, `imbalance`, Kalshi's `seq`, the venue's `venue_ts`, and `observed_at`, when the change was received. Deeper changes that leave the top alone are not streamed.

### `relation`

```json theme={null}
{"graph_run":65,"relation":"equivalent","cross_venue":true,"left":{"venue":"kalshi","id":"KXMLBTOTAL-26SEP171235MILPIT-9","title":"Over 8.5 runs scored","rules_hash":"73562783085732ec366474cdfe73c71abfc4d9245d76931b9d7ead93920dc399"},"right":{"venue":"polymarket","id":"mlb-mil-pit-2026-09-17-total-8pt5","title":"Milwaukee Brewers vs. Pittsburgh Pirates: O/U 8.5","rules_hash":"0a6ca28b9b1edeeafd4ae20bc984cf43b5f039d6f9c2e08c9d897672540cfebb"},"evidence":["cross-venue: same mlb game scheduled 2026-09-17 ET, matched by team codes and team names; both settle on the game's final score; neither venue restricts periods; postponement and cancellation settle differently"],"proof_path":"/v1/relations/kalshi/KXMLBTOTAL-26SEP171235MILPIT-9/polymarket/mlb-mil-pit-2026-09-17-total-8pt5"}
```

One per relation the run proved for the first time, cross-venue ones first, stated from `left`. `proof_path` is where to open the [proof trail](/concepts/proof-trail). A run that finds more than 10,000 new relations, which happens when a new graph version relates everything anew, sends one `relations_truncated` event instead, with `graph_run`, `new_relations`, `limit` and a message pointing at `/v1/cross-venue` and `/v1/markets/{venue}/{id}/relations`.

### `lead`

```json theme={null}
{"graph_run":812,"relation":"contradicts","cross_venue":true,"left":{"venue":"kalshi","id":"KXNFLSPREAD-26SEP14DENKC-KC21","title":"Kansas City wins by over 20.5 points?","rules_hash":"…"},"right":{"venue":"polymarket","id":"nfl-den-kc-2026-09-15-spread-away-1pt5","title":"Spread: Broncos (-1.5)","rules_hash":"…"},"legs":[{"venue":"kalshi","market_id":"KXNFLSPREAD-26SEP14DENKC-KC21","side":"no","ask_usd":"0.83"},{"venue":"polymarket","market_id":"nfl-den-kc-2026-09-15-spread-away-1pt5","side":"no","ask_usd":"0.14"}],"cost_usd":"0.97","gross_edge_usd":"0.03","estimated_fee_usd":"0.01","net_edge_usd":"0.02","issues":["settle differently across venues"]}
```

The same fields as a lead on `GET /v1/opportunities` (see [Leads](/concepts/leads)), including `depth` where the run sized it against the books. Every lead of the run is sent, not only new ones, so a lead that persists arrives once an hour; key on the pair and legs to tell a repeat from a change.

<Note>
  The `quote`, `trade`, `book`, `rule_change`, `ready`, `reset` and `reconnect` samples on this page were captured from the stream. No graph run finished during the capture, so the `relation` sample is an observed proof as the stream carries it, and the `lead` sample is the reference example from the API spec.
</Note>

### `rule_change`

```json theme={null}
{"venue":"polymarket","market_id":"cs2-gl1-nip-2026-09-17","title":"Counter-Strike: GamerLegion vs NIP (BO1) - Logitech G Play Connect Group A","scope":"contract","from_hash":"ad0f39ece16c…","to_hash":"ce5bf900dc2e…","changed_fields":["closes_at","game_starts_at"],"summary":"Close time moved from 2026-09-17T19:00:00Z to 2026-09-17T20:20:00Z; game start moved from 2026-09-17T13:00:00Z to 2026-09-17T14:20:00Z.","rules_path":"/v1/markets/polymarket/cs2-gl1-nip-2026-09-17/rules","changed_at":"2026-09-17T12:33:34.741235Z","recorded_at":"2026-09-17T12:33:34.844803Z"}
```

`scope` is `contract`, `series`, `event`, `market` or `document`; `rulebook_id` names the rulebook for the last four. `from_hash` and `to_hash` are rules hashes, rulebook content hashes or document hashes by scope. `rules_path` is where to read the versions and diffs.

### `error`

```json theme={null}
{"code":"daily_quota_exceeded","message":"This key's daily request quota is used up."}
```

Sent when the key's daily quota runs out while the stream is open; the stream then ends without a `reconnect`. Reconnect after the quota resets at midnight UTC.

## Limits and usage

| Limit                        | Value                                                                                                                           |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Concurrent streams per key   | One per 60 requests a minute of the key's tier: `free` 1, `trader` 2, `pro` 10, `institutional` 20 (the cap), per API instance. |
| Markets per `markets` filter | 50                                                                                                                              |
| Relations per run            | 10,000; more sends `relations_truncated`.                                                                                       |
| Leads per run                | 1,000                                                                                                                           |
| Replay window                | 1 hour                                                                                                                          |
| Stream lifetime              | 45 to 55 minutes                                                                                                                |

Opening a stream counts as one request against the per-minute limit and the daily quota, and an open stream counts one more request per minute against the daily quota while it is open, so a stream held all day costs about 1,440 requests. Replayed events are not counted individually.

A key over its concurrent streams gets `429 too_many_streams` with `Retry-After: 60`; close a stream first. When no stream capacity is free on the instance reached, or it is shutting down, the answer is `503 stream_unavailable` with `Retry-After: 5`.

## A minimal resumable client

Both clients below keep the last id, reconnect on every ending with it, resync on `reset`, and treat 45 seconds of silence as a dead connection. Neither depends on an SSE library.

<CodeGroup>
  ```python Python theme={null}
  import json, time, urllib.error, urllib.request

  URL = "https://api.routeur.app/v1/stream?topics=trades,leads&min_usd=100"
  KEY = "..."  # your API key

  def handle(name, data):
      if name == "reset":
          resync_from_rest()      # rebuild state from /v1/opportunities etc.
      elif name in ("trade", "lead"):
          print(name, data["market_id"] if name == "trade" else data["net_edge_usd"])

  last_id = None
  while True:
      headers = {"X-API-Key": KEY, "Accept": "text/event-stream"}
      if last_id:
          headers["Last-Event-ID"] = last_id
      req = urllib.request.Request(URL, headers=headers)
      try:
          with urllib.request.urlopen(req, timeout=45) as resp:   # 45 s of silence = dead
              event, data, event_id = None, [], None
              for raw in resp:
                  line = raw.decode().rstrip("\n")
                  if line == "":                                  # blank line ends an event
                      if event and data:
                          payload = json.loads("\n".join(data))
                          if event_id:
                              last_id = event_id
                          if event == "reconnect":
                              last_id = payload["cursor"]
                              break                               # reconnect with the cursor
                          if event == "error":
                              raise SystemExit(payload["message"])
                          handle(event, payload)
                      event, data, event_id = None, [], None
                  elif line.startswith(":"):
                      continue                                    # heartbeat
                  elif line.startswith("id:"):
                      event_id = line[3:].strip()
                  elif line.startswith("event:"):
                      event = line[6:].strip()
                  elif line.startswith("data:"):
                      data.append(line[5:].strip())
      except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
          print("dropped:", e)
      time.sleep(3)                                               # the stream's retry interval
  ```

  ```javascript Node.js theme={null}
  const URL = "https://api.routeur.app/v1/stream?topics=trades,leads&min_usd=100";
  const KEY = "..."; // your API key

  function handle(name, data) {
    if (name === "reset") resyncFromRest();            // rebuild state from /v1/opportunities etc.
    else if (name === "trade" || name === "lead") console.log(name, data);
  }

  let lastId = null;
  for (;;) {
    const headers = { "X-API-Key": KEY, Accept: "text/event-stream" };
    if (lastId) headers["Last-Event-ID"] = lastId;
    try {
      const res = await fetch(URL, { headers });
      if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
      let buffer = "", event = null, data = [], id = null, done = false;
      let timer = null;
      const reader = res.body.getReader();
      const armSilenceTimer = () => {
        clearTimeout(timer);
        timer = setTimeout(() => reader.cancel("45 s of silence"), 45_000);
      };
      armSilenceTimer();
      while (!done) {
        const chunk = await reader.read();
        if (chunk.done) break;
        armSilenceTimer();
        buffer += new TextDecoder().decode(chunk.value);
        let nl;
        while ((nl = buffer.indexOf("\n")) >= 0) {
          const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1);
          if (line === "") {
            if (event && data.length) {
              const payload = JSON.parse(data.join("\n"));
              if (id) lastId = id;
              if (event === "reconnect") { lastId = payload.cursor; done = true; break; }
              if (event === "error") throw new Error(payload.message);
              handle(event, payload);
            }
            event = null; data = []; id = null;
          } else if (line.startsWith(":")) continue;             // heartbeat
          else if (line.startsWith("id:")) id = line.slice(3).trim();
          else if (line.startsWith("event:")) event = line.slice(6).trim();
          else if (line.startsWith("data:")) data.push(line.slice(5).trim());
        }
      }
      clearTimeout(timer);
      reader.cancel();
    } catch (e) {
      console.error("dropped:", e.message);
    }
    await new Promise((r) => setTimeout(r, 3000));               // the stream's retry interval
  }
  ```
</CodeGroup>

<Tip>
  Keep the filters identical across reconnects. A cursor is a position in one shared log, not in your filtered view, so resuming with different filters is valid but delivers the new filter's events from that position, not the old filter's.
</Tip>

## Related

* [Order books](/concepts/order-books): what a `book` event is the top of.
* [Rules](/concepts/rules): what a `rule_change` points at.
* [Leads](/concepts/leads) and [Relations](/concepts/relations): what `lead` and `relation` events carry.
* [Rate limits](/rate-limits): the per-minute limits and daily quotas that streams count against.
* [Webhooks](/webhooks/overview): to be called when a lead becomes executable, without holding a connection.
