# Live SSE stream (up to 10 symbols per connection)

`GET https://api.skylit.ai/v1/stream`

API: Heatseeker. Credits: 1 per symbol to open + 1 per symbol per minute open.

A **Server-Sent Events** (`text/event-stream`) feed of live per-strike
heatmaps. Two wire formats:

- **v2** (default with `symbols=`, or `format=v2`): up to 10 symbols on
  one connection, camelCase events matching `SymbolHeatmap`, resumable
  with `Last-Event-ID`.
- **v1** (default with `symbol=`): the original single-symbol format,
  unchanged.

### v2 events

Every event names its `symbol` (except connection-level events).

| Event | `id:` | Payload |
|---|---|---|
| `connected` | — | `{format:"v2", symbols, metric, creditsRemaining, creditsPerMinute, maxDurationSeconds}` |
| `resumed` | yes | Only when `Last-Event-ID` was sent. See *Resume*. |
| `snapshot` | yes | `StreamSnapshot` (= `SymbolHeatmap` + `lagged`). Once per symbol on connect, then on every board change. |
| `velocity` | yes | `StreamVelocity`: per-node velocity for the stream's `metric`, when it changes (checked every ~3s). |
| `symbol_unavailable` | — | `{symbol, reason: "no_live_board" \| "expiration_not_found", message}`; a `snapshot` follows once a board exists. |
| `credits` | — | `{remaining, charged}` after each per-minute debit. |
| `closed` | — | `{reason: "insufficient_credits" \| "account_suspended" \| "key_revoked" \| "monthly_cap_reached" \| "credit_check_failed"}`; stream ends. `key_revoked`: an API key on the account was revoked after this stream opened (streams opened on another still-valid key close too; reconnect to continue). `monthly_cap_reached`: the account's monthly spend cap was reached. |
| `reconnect` | yes | `{reason: "max_duration" \| "server_shutdown"}`; stream ends, reconnect with `Last-Event-ID`. |
| `: ping` | — | SSE comment every 15s so idle proxies (CloudFront: 60s) keep the connection. |

Snapshots differ from `/v1/heatmap` in two ways: `spot` is the board's
own spot, and strikes carry no `velocityPct` (use the `velocity`
event). Within a symbol, `asOf` never goes backwards; a board already
sent is never re-sent.

**Event ids (resume cursor).** The `id:` is the connection's position
in **every** symbol, not just the event's: a comma-separated list of
`SYMBOL:asOfUnixMs`, e.g. `SPY:1790000000123,QQQ:1790000000456`.
`asOfUnixMs` is the board's publish timestamp, identical on every
server, so a cursor resumes on any pod. Per symbol it never decreases
and strictly increases with each `snapshot` for that symbol.

**Resume.** Reconnect with header `Last-Event-ID: <last id>` (browsers'
`EventSource` does this automatically) or `?lastEventId=`. The server
keeps no event history: boards are full state, so it sends the
`resumed` event and then a fresh `snapshot` for every symbol that has a
live board, whether or not it changed. Per symbol `status` is:
`unchanged` (no board was published during the gap — nothing missed),
`advanced` (at least one board was published; intermediate boards are
**not** replayed, only the latest), `unknown` (symbol not in the
cursor), or `unavailable` (no live board right now). `gapCovered` is
true only if every symbol is `unchanged`; `stateRestored` is true when
every symbol received a current snapshot. Velocity is not replayed; the
latest reading is re-sent when this server has one.

**Backpressure.** A client that reads slower than boards are published
is never queued behind: each symbol holds at most one pending board, a
newer board replaces it, and the board that is finally sent carries
`lagged: true`. A client that stops reading for 30s is disconnected.

**Pricing (v2).** `N` = number of symbols. `N` credits on connect
(charged before the SSE upgrade, so an under-funded client gets a clean
`402`), then `N` credits per minute open — 1 credit ($0.001) per symbol
per minute, i.e. $0.06 per symbol-hour, plus $0.001 per symbol to open.
Minutes are charged in advance and a partial minute is not refunded.

**Limits.** At most 10 symbols per connection (more → `400`). At most
25 symbols per customer per server across all open streams (a v1
stream counts as 1); over it → `429` `stream_limit_reached` with
`Retry-After: 2` (a just-closed stream frees its symbols within about
a second). v1 also keeps its 5-streams cap. Each server streams a
bounded number of **distinct** symbols across all customers (default
20); a v2 stream that would add a new symbol beyond that gets `503`
`stream_capacity` with `Retry-After: 5` — retry (you may land on
another server). Symbols already being streamed are always admitted.
Streams end after 1 hour with `reconnect`.

### v1 events (single symbol, unchanged)

`connected` `{symbol, creditsRemaining}`, `initial_data`,
`snapshot_update`, `velocity_update` (internal PascalCase board shape),
`credits`, `closed`, `reconnect`, and a `: keepalive` comment every
30s. 1 credit to open + 1 per minute. v1 rejects `expirations`.

> OpenAPI cannot fully model an event stream. See
> [docs/api-credits.md](https://github.com/SkylitAI/skylit-main/blob/main/docs/api-credits.md#live-stream-v1stream).

## Authentication

Send your Skylit API key as a bearer token: `Authorization: Bearer <key>`. No other header is accepted.

## Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `symbols` | query | string | no | Comma-separated tickers, up to 10 (e.g. `SPY,QQQ,IWM`). Selects format v2 by default. Unknown symbols → `404`. |
| `symbol` | query | string | no | Single ticker. Selects the legacy v1 format unless `format=v2`. Send `symbol` or `symbols`, not both. |
| `format` | query | string | no | Wire format. Defaults to `v2` with `symbols=` and `v1` with `symbol=`. `v1` is single-symbol only. (one of `v1`, `v2`) |
| `lastEventId` | query | string | no | v2 resume cursor, for clients that cannot set the `Last-Event-ID` header. The header wins if both are sent. |
| `Last-Event-ID` | header | string | no | v2 resume cursor — the last `id:` received. |
| `metric` | query | string | no | Which Greek exposure to return per strike. (one of `gamma`, `vanna`; default `gamma`) |
| `maxStrikes` | query |  | no | Maximum number of strikes around spot to return: an integer from 1 to 1000, or `all` for every strike the snapshot lists (SPXW lists about 730). Values above 1000 return `400 invalid_parameter`; they are never silently reduced. Values below 1 are treated as 1. The single-symbol stream (`/v1/stream?symbol=`) accepts at most 400 and no `all`. (default `92`) |
| `maxExpirations` | query |  | no | How many of the nearest expirations to net into each strike's `value`: an integer from 1 to 60, or `all`. Values above 60 return `400 invalid_parameter`. Ignored when `expirations` is set. (default `5`) |
| `includeEmpty` | query | boolean | no | By default, an interior strike whose every returned cell is below 50 in absolute value (listed but effectively untraded) is left out, judged on the selected metric alone. So gamma and vanna for the same instant can return different strike lists. The outermost strikes are never removed. `true` keeps every strike in the window, so the list is the snapshot's own contiguous ladder and is identical for gamma and vanna. Not supported on the single-symbol stream (`symbol=`). (default `false`) |
| `expirations` | query | string | no | v2 only — same meaning as on `/v1/heatmap`. Rejected with `400` on v1. |
| `layout` | query | string | no | v2 only — `matrix` adds the per-expiration grid to each snapshot, as on `/v1/heatmap`. (one of `net`, `matrix`; default `net`) |

## Example request

```bash
curl -N "https://api.skylit.ai/v1/stream" \
  -H "Authorization: Bearer $SKYLIT_API_KEY" \
  -H "Accept: text/event-stream"
```

## Responses

### 200

An SSE stream of heatmap events.

`text/event-stream`: v2 frames, e.g. event: snapshot id: SPY:1790000000123,QQQ:1790000000456 data: {StreamSnapshot} `snapshot` data matches #/components/schemas/StreamSnapshot, `velocity` data #/components/schemas/StreamVelocity, and `resumed` data #/components/schemas/StreamResumed.

### 400

Request validation failed.

### 401

Missing API key (`unauthorized`), sent by the gateway.

### 402

Out of credits (`insufficient_credits`).

### 403

Unknown, revoked or expired key (`forbidden`, from the gateway), or an
admin-suspended account (`account_suspended`).

### 404

Unknown symbol, no data available, or none of the requested
`expirations` exist for the symbol (`code: expiration_not_found`).

### 429

Per-minute rate limit exceeded.

Headers: `Retry-After`.

### 503

Heatmap data is temporarily unavailable.
