# DataFirst Derivatives API

**Status:** public reference, revision 1.0.65, 2026-10-29. Endpoints, units and shapes are as served; the Changelog at the end lists what changed for clients.

Dealer gamma exposure, key levels, Greeks exposures, order flow, tape and sweeps for every listed US option, computed from the live options market. Version 1.

**Base URL** `https://api.datafirsttrading.com` · **Auth** `Authorization: Bearer <your key>` (or, for the hosted MCP, the access token of your sign-in: the OAuth section) · **Format** JSON · **Contract** OpenAPI 3.1.0 at `/v1/openapi.json`

## Coverage

**Any symbol, and all of them.** Every listed US option root is available on every plan but Free: every index (SPX, NDX, VIX, RUT), every ETF, every single name, every listed expiration including 0DTE and weeklies. Accounts default to all symbols above Free. An account may have individual ticker restrictions; these apply across its keys and remain separate from its plan. `GET /v1/symbols` returns today's set.

| | |
|---|---|
| Depth | Any number of expirations (1 to 50) or the term-structure set; per strike or per contract |
| Freshness | The `Age` header on every answer states how old its data is, in seconds |
| Stream | One socket for every topic, each topic sent when it changes, with a regular beat (WebSocket `/v1/stream`) |
| Availability | Target 99.9% from 04:00 to 20:00 ET |

## Account ticker permissions

`GET /v1/me` and `GET /v1/status` include `ticker_access`:

```json
{"mode":"allow_only","allowed":["GLD"],"restricted":[]}
```

`allow_all` permits all tickers except `restricted`. `allow_only` permits only `allowed`, excluding anything also in `restricted`; an empty allowed list permits none. These permissions do not replace plan limits, account validity or market-data requirements. Free's existing ticker limits still apply.

A denied ticker returns HTTP 403 `ticker_not_allowed`. A restricted account requesting an unsupported scope returns 403 `ticker_scope_required`, including market-wide aggregates and bulk exports—even when a ticker parameter is added. Use the documented `/v1/symbols/{symbol}/...` routes for individual symbols. Current restricted-account support also includes `/gex`, `/heatmap`, `/expirations`, `/spot`, `/intraday`, `/intraday-live`, `/interval-map`, `/historical`, `/historical-iv`, `/hist/full-fidelity`, `/netflow` and `/candles` with an explicit `ticker` or `symbol` (`/gex` requires `ticker`); `/quotes` with `tickers` or `symbols`; and `/levels-history` with `tickers`. Every requested symbol must be permitted. Other data routes are refused for restricted accounts pending support for their scope.

Streaming checks permissions on subscription and before sending data. Restricted accounts cannot subscribe to the market-wide `core` family. A permission change can close an existing stream with `ticker_policy_changed`; reconnect using the updated permissions. Previously downloaded data cannot be recalled. Index roots SPXW/SPX and NDXP/NDX share their respective permission.

## Standards

| Standard | Version | Used for | Reference |
|---|---|---|---|
| OpenAPI Specification | 3.1.0 | The machine-readable contract at `/v1/openapi.json`; every numeric property carries its unit as `x-unit` | spec.openapis.org/oas/v3.1.0 |
| RFC 9457 Problem Details | 2023 | Every error body, `application/problem+json` | rfc-editor.org/rfc/rfc9457 |
| RFC 9110 and RFC 9111 HTTP | 2022 | `Age`, `ETag`, `Cache-Control`, `If-None-Match`, `Retry-After` | rfc-editor.org/rfc/rfc9110, rfc9111 |
| RFC 3339 | 2002 | Every timestamp, UTC with `Z` | rfc-editor.org/rfc/rfc3339 |
| IETF RateLimit header fields | httpapi working group Internet-Draft | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `RateLimit-Policy` | datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers |
| RFC 6455 WebSocket | 2011 | `/v1/stream` | rfc-editor.org/rfc/rfc6455 |
| ISO 10383 Market Identifier Codes | 2012 | `exchange` on every print, for example `XCBO` | iso20022.org/market-identifier-codes |
| Google API Improvement Proposals 140 and 141 | current | Field naming and the unit-in-the-name rule for durations and quantities | google.aip.dev/140, google.aip.dev/141 |

## Tiers

Access is sold in tiers that differ by how fast and how much you may call and how far back you may read, not by which endpoints you see: every tier gets every endpoint and every expiration, and every tier but Free gets every symbol. Prices are on the price sheet; the limits below are what the API enforces, and `GET /v1/plans` returns them as data.

| Tier | Rate | Burst | API calls a day | History | Symbols | Export | Stream | Compatible APIs | Intended for |
|---|---|---|---|---|---|---|---|---|---|
| **Free** | 1 request/s | 5 | 100 | The current session | limited | no | quotes only: up to 50 quotes, 1 connection | no | Evaluating the API. |
| **Basic** | 1 request/s | 5 | 250 | Today and the four sessions before it | every symbol | no | 5 symbols per connection, 1 connection | no | The dashboard plan; a script against it. |
| **Trader** | 2 requests/s | 10 | 1,000 | 30 calendar days | every symbol | no | 25 symbols per connection, 2 connections | no | One person's dashboard, terminal, scripts and agents. |
| **Developer** | 10 requests/s | 60 | 100,000 | One year (365 calendar days) | every symbol | no | 50 symbols per connection, 2 connections | yes | Active traders, small teams, charting integrations, the compatible APIs. |
| **Quant** | 50 requests/s | 150 | unlimited | Every kept session | every symbol | yes: every finished output of a session as Parquet files, `GET /v1/export` | 50 symbols per connection, 5 connections | yes | Firms, platforms and researchers building on the API. |

Every plan's stream connection also takes up to 50 `quotes` symbols apart from the plan's symbols.

**The day.** The quota counts API calls on the New York calendar day and resets at midnight New York. Requests the DataFirst website makes on your behalf (the dashboard) are never counted, never rate-limited and not history-gated: what a dashboard member sees of past sessions is the website's choice; the tier's reach applies to the key's own API calls. Every answer to an API call on a tier with a quota carries `Quota-Limit`, `Quota-Remaining` and `Quota-Reset` (seconds to the reset); the 101st Free call of the day is `429 quota_reached_day` with `Retry-After` set to the same number of seconds. `GET /v1/me` reports `limits.per_day`, `limits.used_today` and `limits.quota_resets_at`; `used_today` may trail your last few calls by a few seconds.

**History reach** is counted in sessions for Free and Basic (the current session; today and the four before it) and in calendar days for Trader (30) and Developer (365); Quant reads every kept session. A session before the reach is `403 history_out_of_reach` and `/v1/me` reports the first allowed date as `history_from`. **Symbols** on Free: Free reaches a limited set of symbols; a request for any other is `403 symbol_not_in_tier` ("your plan doesn't include this symbol"); market-wide endpoints (`/v1/tape`, `/v1/sweeps`, `/v1/sessions`) answer with the tier's names only where a symbol applies. **Stream and compatible APIs** follow the plan too (`GET /v1/plans` `products`): the WebSocket's symbols per connection and connections open at once per key (the WebSocket section: a family other than quotes on a plan without a stream is `err 4003 not_on_plan`, one connection too many closes `4003 connection_limit`, one symbol too many is `err 4290 stream_symbols_over_plan`), and whether the compatible APIs answer the key at all (`403 compatible_api_not_in_plan` there; the key keeps its rights on `/v1`). Every number in this section binds every key on the plan.

Rates are per key and apply to the whole key, whichever endpoint it calls; the limit and what remains of it are returned on every response (see Conventions). Higher limits can be arranged.

## Units

One policy, applied to every field. If a number's unit is not clear from this table and the field's name, that is a defect; tell us.

| Quantity | Unit | Rule |
|---|---|---|
| Money | USD | Never scaled: no thousands, millions or billions in any answer. `net_gex: 520000000` is 520 million dollars. The one exception is a file, not an answer: the bulk export's `interval_map` dataset keeps scaled units, stated per column in the export section. |
| Gamma exposure (`gex`, `net_gex`, walls, heatmap, `true_flow_gex`, `momentum_value`) | USD per 1% move in spot | Dealer perspective: calls positive, puts negative. |
| Delta exposure | shares | Share-equivalent delta exposure. |
| Vanna exposure | shares per 1 vol point | Shares of delta created by implied volatility rising one percentage point. |
| Charm exposure | shares per day | Shares of delta created by one calendar day of decay. |
| Prices (`spot`, `strike`, `bid`, `ask`, `mid`, `price`, `avg_price`, every key level and wall) | USD per share | An option price of 1.05 is $105 per contract. |
| Premium | USD | size × price × 100. |
| Quantities (`size`, `volume`, `open_interest`, `dealer_flow`, `classified_volume`) | contracts | |
| Implied volatility (`iv`, `atm_iv`) | fraction | 0.18 is 18%. There are no percent fields in this API. |
| Ratios and shares (`gex_ratio`, `flow_ratio`, `coverage`, `moneyness`) | fraction | `moneyness` is (strike − spot) / spot: 0.02 is 2% above spot. |
| Raw Greeks (`greeks`) | per unit of the input | delta per $1 of spot; gamma per $1 per $1; vega, vanna, vomma, zomma, ultima per 1.00 of vol; theta, color, veta per year; charm per year of elapsed time; rho per 1.00 of rate; speed per $1 of spot; omega dimensionless (elasticity). Divide vega by 100 for per vol point and theta by 365 for per day. |
| Time | RFC 3339 UTC | `as_of`, `ts` end in `Z`. Where clients sort or chart, a `_ms` twin carries epoch milliseconds. |
| Durations | named by unit | `window_ms`, `interval_minutes`, `age_s`, `dte` (calendar days to expiration). |
| Signed flow | buys minus sells | A negative `signed_put_premium` means more put premium was sold than bought. |
| Precision | fixed per unit | Aggregate exposures and GEX are whole USD or shares; premiums are cents; prices, volatilities and ratios carry four decimals. Per-contract Greeks, GEX and IV (`/contracts`) carry eight significant digits, so sums built from contracts stay exact. Contract counts are integers. `-0` never appears. |
| Missing | `null` | A value that could not be computed is `null`, never zero: an undetermined `gamma_flip` and a wall with no candidate strike are `null`. |

The same units appear on every numeric property of the OpenAPI document as `x-unit`, so generated clients and documentation tools can show them.

## Conventions

- Every response is `{"data": ..., "meta": ...}`. `meta` carries `symbol`, `as_of` and `as_of_ms` (the moment of the market data), `computed_at`, `spot`, and the `expirations` the result was built from. The standard `Age` response header is the seconds since `as_of`: how old the data is.
- **Freshness.** Every live answer carries `Age`, and `Cache-Control: max-age` says how long it is served unchanged; `ETag` changes with the answer. The core names (SPY, QQQ, SPX, NDX, IWM, DIA, AAPL, TSLA, NVDA, AMD, META, MSFT, GOOGL, AMZN, AVGO, MU, PLTR, HOOD, COIN, IBIT, VIX, GLD, SLV, GDX) are always live; any other symbol is live while it is being read, and the first answer after a pause carries its real age.
- **Expirations.** `?expirations=N` selects the nearest N listed expirations (1 to 50, default 4). `?expirations=term` selects the term-structure set: the listed expiration nearest each of 5, 7, 14, 30, 60, 90, 120, 180, 270 and 365 days out.
- **Symbols.** Use the familiar ticker: `SPY`, `SPX`, `NDX`, `AAPL`. Weekly index roots (SPXW, NDXP) are covered under `SPX` and `NDX`.
- **Names** are `snake_case`. `right` is `C` or `P`. Dates are `YYYY-MM-DD`.
- **Ranges.** The history endpoints that answer rows per session (`history/levels`, `history/flow`, `history/replay` with `part=levels`, `history/iv`) and `/v1/sessions` take `from` and `to` (dates, inclusive) instead of `date`: one answer holds every finished session in the range, oldest first, and `data.sessions` lists the sessions it holds. `to` defaults to the last finished session; today is never part of a range (it is on the live endpoints). At most 31 sessions per call (`422` beyond that); the tier's depth applies to `from`. The grid (`history/heatmap`), the chain file and the trades stay one session per call; for many sessions of prints, use `/v1/export`.
- **Caching and revisions** (1.0.52). Every answer for a finished session (the `history/*` endpoints and `/v1/symbols/{symbol}/intraday` with a past `date`) carries a strong `ETag` and `Cache-Control: private, max-age=300, must-revalidate`. Send it back as `If-None-Match` and an unchanged answer is `304 Not Modified`; a session that is rebuilt (any session a range covers) gives the answer a new `ETag`. Today's answers keep their own short `max-age`.
- **Resolution** (1.0.17). In one sentence: **sessions before 2026-10-19 are one-minute history; from 2026-10-19 on, the key levels, totals and spot are recorded every second for the core and most-read names and on every change for every other name, the core names' strike profile and the most-read names' flow every second too, the per-expiration grid per minute.** Every time series has a native resolution, the finest at which it was recorded: one second for the key levels, totals and spot from 2026-10-19, one minute before; for the exposure grid (`history/heatmap`) one minute per expiration, and from 2026-10-19 one second for the core names' default view (the 4 nearest expirations summed, read in windows of at most 15 minutes, 1.0.18); for the flow (`history/flow`) five seconds, and from 2026-10-19 one second for the most-read names (1.0.18). `?resolution=` on `history/levels`, `history/flow`, `history/heatmap`, `history/replay` (`part=levels`) and `intraday` asks for a step: `1s`, `5s`, `15s`, `30s`, `1m`, `5m`, `15m`, `30m`; **without `resolution`, each endpoint keeps the step it always had**: `history/levels` and `history/heatmap` one row every `interval` (default 5 minutes), `history/flow` every `interval` (default 5 seconds), `history/replay` (`part=levels`) and `intraday` one minute; when `interval` is given it wins over `resolution`. A step coarser than or equal to the native one is consolidated (levels: the last row of each step; bars: open, high, low, close; flow: the running totals at the step's end); a step finer than the native one is answered at the native resolution, with `meta.resolution` the native step, `meta.resolution_requested` the step asked for and a `note`. Inside a one-second session, a second in which a name did not change repeats the last real row and carries that row's `as_of`, so a carried value is visible as such; while the session runs, rows stop at the current second. Each session's native resolution is in `/v1/sessions` (`sessions[].resolution`). `history/levels` rows carry the spot of each row.
- **Errors** are RFC 9457 problem documents, `application/problem+json`, and every one carries a stable machine-readable `code` beside its `title` (1.0.23): `401` `unauthorized`, `403` `attestation_required` (no current OPRA subscriber attestation, or one on an older release of the agreement), `attestation_pending` (live data asked for during support's grace: finished sessions only), `plan_ended` (a professional subscriber's plan ran out; renew it), `key_revoked` (the key was used from too many addresses or devices; a connector counts as one device and no address; contact support), `history_out_of_reach` (beyond the tier) or `export_not_in_tier`, `404` `unknown_symbol`, `no_data`, `no_history`, `not_built` (with `ready_by` and `Retry-After`: when it is expected), `not_available`, `422` `invalid_parameter` and its kin, `429` `rate_limited`, `address_rate_limited`, `too_many_in_flight` (with `Retry-After`), `503` `temporarily_unavailable` (with `Retry-After`: retry after it), `504` `timeout` (narrow the request). The Errors section lists every code with its retry rule.
- **Retrying** (1.0.23). Retry `429`, `503`, `504` and network errors, never an unchanged `4xx`: wait `Retry-After` when it is sent, else back off exponentially with jitter and stop after a few attempts. The `datafirst` client and the MCP do this. A `202` is not an error: ask again after its `Retry-After` (Sessions not ready yet, below).
- **OPRA attestation and grace** (1.0.39). Live OPRA data needs the account's current subscriber attestation, on the current release of the subscriber agreement (1.0.40): an account that signed an older release signs the current one before live data again (`403 attestation_required`, the `detail` naming the release; `/v1/me` still shows the old `status`). An account stuck at that step can be given grace by DataFirst support, until a date at most 14 days ahead (`grace_until` on `/v1/me`): until then it reads finished sessions only, meaning a request on a history, replay, trades or export endpoint (the `/v1/symbols/{symbol}/history/*` family, `/v1/symbols/{symbol}/flow/baseline` and `/flow/history`, `/v1/flow/*`, `/v1/sessions`, `/v1/trades`, `/v1/tape`, `/v1/sweeps`, `/v1/export/{date}`, and since 1.0.57 `/v1/symbols/{symbol}/intraday` at `1m`) that names its sessions and names none from today on (`date=`, or `from=` with `to=`). Anything else (a live endpoint, the stream, a request with no date or a range left open at the top) is `403 attestation_pending`. Grace ends by itself; after it the answer is `attestation_required` again. `GET /v1/me` answers whether or not the account is attested, so a client can show why it is refused.
- **Plan end** (1.0.44). Every key carries the end of its paid period: `plan_until` on `GET /v1/status` and `/v1/me` (`null` = open-ended), kept by the DataFirst site with every subscription event (a payment, a renewal, a cancellation, a comp). The API enforces the date itself, one day after it, when no renewal has moved it: a non-professional's key reads as the Free plan from then on (`plan` says `free`, `plan_until` `null`, the Free limits apply); a professional subscriber's key is refused `403 plan_ended` on every data endpoint and the stream (`mode` `ended`; `/v1/status` and `/v1/me` still answer) until the plan is renewed. A change on the site takes effect shortly.
- **Time limits** (1.0.23). Every answer has a time limit, live answers a shorter one than history, the trades, the sessions catalog and the flow aggregates; streaming answers (the chain file, exports, the WebSocket) are bounded by progress, not by a total. Past it the answer is `504 timeout`: narrow the request (fewer sessions, strikes or expirations). `/v1/me` reports your key's limits as `limits.time_limit_s`.
- **Heavy reads in flight** (1.0.23). A key may run only a few heavy reads at once (chain files, session ranges with `from`/`to`, export files); one more is `429 too_many_in_flight` with `Retry-After`: wait for one to finish. `/v1/me` reports your key's limits as `limits.in_flight`.
- **Per-address ceiling** (1.0.23; 1.0.52). One address has one ceiling across every key: the larger of a shared floor and its own key's plan rate. So one server address can use its key's whole plan rate, while many keys on lower plans from one address share the floor. `limits.address_rate` reports the ceiling your key gets; beyond it `429 address_rate_limited` with `Retry-After`: wait that long. The website's own requests for its members are never limited. It complements the rule that revokes a key used from too many addresses or devices.
- **Rate limits** are per key and returned on every response in four headers: `RateLimit-Limit` (the requests your key may make at once, its burst), `RateLimit-Remaining` (how many of them you may make right now), `RateLimit-Reset` (seconds until the allowance is full again) and `RateLimit-Policy` (the allowance and the window it refills over). On a tier with a daily quota (1.0.27) the day's allowance rides beside them as `Quota-Limit`, `Quota-Remaining` and `Quota-Reset` (seconds to midnight New York). Neither applies to requests the website makes for a signed-in user.
- **Compression.** Send `Accept-Encoding: gzip` and larger responses are compressed.

## Endpoints

This is the complete catalog of `/v1`. The compatible APIs are documented separately for their customers: ask support for the base that matches your client.

Everything is `GET` over HTTPS with the key in the `Authorization` header, except the stream: a WebSocket on the same host, opened after `POST /v1/stream/negotiate` with the key or with a one-use ticket from `POST /v1/stream/ticket`, so a browser never holds the key. Every plan sees every endpoint, and every plan but Free every symbol; the **Depth** column says how far back a plan may ask (Free: the current session; Basic: 5 sessions; Trader: 30 days; Developer: 365 days; Quant: everything kept; the Tiers section).

| Method | Path | Depth | Returns |
|---|---|---|---|
| GET | `/v1/symbols` | live | Symbols with listed options. |
| GET | `/v1/symbols/{symbol}/expirations` | live | Listed expirations and the term selection. |
| GET | `/v1/symbols/{symbol}/spot` | live | Spot price (VIX: the VIX index). |
| GET | `/v1/symbols/{symbol}/levels` | live | Key levels, walls, totals, flow summary, classification quality. |
| GET | `/v1/symbols/{symbol}/strikes` | live | Per-strike GEX, exposures, Greeks, OI and volume. Includes everything in `levels`. |
| GET | `/v1/symbols/{symbol}/contracts` | live | Per-contract quotes, IV, Greeks, GEX and flow classification. |
| GET | `/v1/symbols/{symbol}/vol` | live | (1.0.19) The chain's volatility summary: constant-maturity tenors, term slope, expected moves, 30-day skew, per-expiration ATM, 25-delta, risk reversal, butterfly and strip vol, the smiles (`expirations=` default `term`). |
| GET | `/v1/symbols/{symbol}/heatmap` | live | Net GEX by strike × expiration around spot. |
| GET | `/v1/symbols/{symbol}/flow` | live | Signed order flow: totals, one-minute buckets, classification. |
| GET | `/v1/symbols/{symbol}/intraday` | live | 1-minute spot bars for the session. |
| GET | `/v1/tape` | live | Recent prints (last 60 minutes). |
| GET | `/v1/sweeps` | live | Multi-exchange sweeps (last 60 minutes). |
| **WS** | `/v1/stream` | live; quotes on every plan, the other families Basic and up | Push stream (1.0.61): one socket, topics `{family}:{TICKER}:{scope}:-`; each message carries its REST twin's body (`snap`) or what is new in it (`app`), sent when it changed; a fixed set of shapes (the scopes); the 24 core names always live; the plan sets symbols per connection and connections at once (1.0.57), quotes 50 a connection on every plan. Section "WebSocket `/v1/stream`". |
| POST | `/v1/stream/negotiate` | live | (1.0.61) Before every socket: the topics on offer, your limits and the `neg` token the socket requires. |
| POST | `/v1/stream/ticket` | live | (1.0.39) A one-use ticket, good for 60 s, that opens the stream as your key from a web page: the socket's `token` (1.0.61). |
| GET | `/v1/sessions` | by plan (Tiers) | The catalog of finished sessions you may read: each one's names with levels and what it holds (`from=`, `to=`). |
| GET | `/v1/sessions/{date}` | by plan (Tiers) | (1.0.29) The names one finished session holds, by product: levels, playback, closing, with the catalog row's holds. |
| GET | `/v1/symbols/{symbol}/history/levels` | by plan (Tiers) | Levels per minute for any kept session (`date=`), or for a range of finished sessions (`from=`, `to=`). |
| GET | `/v1/symbols/{symbol}/history/flow` | by plan (Tiers) | Net flow through a session (`date=`, `interval=`, `resolution=`), or through a range of sessions (`from=`, `to=`). |
| GET | `/v1/flow/leaders` | by plan (Tiers) | (1.0.22) Leaders over a finished session or its week: underlyings or single contracts ranked by premium, volume, trades, sweeps or direction, each against its own baseline. |
| GET | `/v1/flow/sectors` | by plan (Tiers) | (1.0.22) Flow by sector over a session: premium, volume, imbalance and premium against the sector's baseline. |
| GET | `/v1/flow/breadth` | by plan (Tiers) | (1.0.22) Market breadth over a session: advancing, declining and neutral names by premium imbalance, the market's imbalance, the majors. |
| GET | `/v1/flow/unusual` | by plan (Tiers) | (1.0.22) Contracts whose session volume is a multiple of their own trailing mean. |
| GET | `/v1/symbols/{symbol}/flow/baseline` | by plan (Tiers) | (1.0.22) A symbol's session against its trailing baseline: the day's totals and relatives, and every 5-minute slot against the same slot over the baseline sessions. |
| GET | `/v1/symbols/{symbol}/flow/history` | by plan (Tiers) | (1.0.22) A symbol's flow totals per session over the trailing sessions kept. |
| GET | `/v1/symbols/{symbol}/history/heatmap` | by plan (Tiers) | The exposure grid through a session: call, put and net GEX per strike at each interval (`date=`, `expiration=`); the core names' strike profile every second in 15-minute windows (`resolution=1s`, `start=`, `end=`). |
| GET | `/v1/symbols/{symbol}/history/replay` | by plan (Tiers) | Playback of a finished session: per-minute key levels, walls and strike profile (`part=levels`), or per-minute surfaces with the closing per-strike aggregate and totals (`part=surfaces`); `part=levels` over a range of sessions (`from=`, `to=`). |
| GET | `/v1/symbols/{symbol}/history/chain` | by plan (Tiers) | The chain every 5 minutes of a finished session, every contract with quote, IV and Greeks (NDJSON). |
| GET | `/v1/symbols/{symbol}/history/iv` | by plan (Tiers) | Daily 30-day at-the-money implied volatility and 25-delta skew, one row per session (`days=`, or `from=`, `to=`). |
| GET | `/v1/symbols/{symbol}/history/oi` | by plan (Tiers) | Open interest per contract for one session (`date=`, `expiration=`): open interest as of that morning, with the session's volume. |
| GET | `/v1/trades` | by plan (Tiers) | Every print of a session (or orders: sweeps, splits, blocks), filtered, sorted, paged (`date=`). |
| GET | `/v1/trades/stats` | by plan (Tiers) | Grouped totals over the same prints: by type, side, ticker, exchange, expiration, strike, contract, bucket. |
| GET | `/v1/export` | **Quant** | Sessions available for bulk export. |
| GET | `/v1/export/{date}` | **Quant** | That session's manifest: datasets, rows, bytes, checksums, columns with each column's unit (1.0.56). |
| GET | `/v1/export/{date}/{dataset}` | **Quant** | The file: `levels`, `interval_map`, `netflow`, `trades`, `orders` (Parquet, zstd). |
| GET | `/v1/reference` | — | Reference data: the rate curve, dividend yields, multiplier and expiry conventions, sectors. |
| GET | `/v1/symbols/{symbol}/reference` | — | Reference data for one symbol: roots, adjusted flag, index flag, dividend yield, expiry convention, sector, industry. |
| GET | `/v1/me` | — | Your key: name, tier, rate, burst, `history_from`, `export`, the account's platform `addons` and `multi_indicator` licence, OPRA `status` and `grace_until`; answered whether or not the account is attested. |
| GET | `/v1/status` | — | Your plan, mode and usage in one free read (1.0.43): `plan`, `plan_until` (1.0.44), `mode`, `account`, `usage` (api, rate, chat, stream), `reach`, `products`; never counted, never rate-limited. |
| GET | `/v1/plans` | — | (1.0.56) The plans and their prices, what each includes and opens, and the limits that apply to it; no key needed, never counted. |
| GET | `/v1/openapi.json` | — | This API as OpenAPI 3.1. |

A request past the tier's depth is answered `403` with `X-History-Earliest`; `GET /v1/me` reports the same date as `history_from`, so a client can grey out what it cannot ask for.

### `meta` on every response

| Field | Type | Unit | Description |
|---|---|---|---|
| `symbol` | string | | The symbol as requested, upper-cased. |
| `as_of`, `as_of_ms` | timestamp | RFC 3339, epoch ms | The moment of the market data the result was computed from: the newest quote or print in it. `Age` counts from here. |
| `computed_at` | timestamp | RFC 3339 | When the result was computed. |
| `spot.price` | number | USD | Spot the result was priced against; for VIX, the VIX index (the result is priced against `spot.forward`). |
| `spot.forward.price` | number | USD | VIX only (1.0.35): the price VIX's options are valued from; levels, walls, Greeks, IVs and strike windows use it. Absent on every other answer: those are valued from `spot.price`. |
| `oi_floor` | integer | contracts | The open-interest floor the result applied: 1 since 1.0.54 (every contract with open interest counts). Results computed before 2026-10-25 read 100 (1 for NDX and thin names): contracts under 100 were left out then. |
| `stale` | boolean | | `true` when the answer could not be refreshed from current data and is the last good one; `as_of` says how old. |
| `time_to_expiry_years` | object | years | Per expiration (`YYYY-MM-DD`: years), the time left to that expiration as of `computed_at` (1.0.8). |
| `settlement` | object | | Per expiration (`YYYY-MM-DD`: `am` or `pm`): `am` settles on the opening print at 09:30 New York and is priced to it; `pm` the 16:00 close (1.0.15). |
| `expirations.mode` | string | | `nearest` or `term`. |
| `expirations.count` | integer | | Expirations actually in the result. |
| `expirations.dates` | date[] | | The expirations in the result. |

### `GET /v1/symbols/{symbol}/levels`

Key levels, walls, totals and the flow summary for one symbol.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `symbol` | path | string | | Ticker, for example `SPY` or `SPX`. |
| `expirations` | query | integer or `term` | 4 | Nearest N expirations (1 to 50), or the term-structure set. |

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `key_levels.gamma_flip` | number | USD | Spot level where net dealer gamma changes sign; `null` when there is none (1.0.8). |
| `key_levels.call_wall` | number | USD | Strike with the largest positive net GEX. `null` when no strike has positive net GEX (1.0.8). |
| `key_levels.put_wall` | number | USD | Strike with the largest negative net GEX. `null` when no strike has negative net GEX (1.0.8). |
| `key_levels.max_pain` | number | USD | The expiration price at which option holders' total intrinsic value is smallest. |
| `key_levels.vol_trigger` | number | USD | The volatility trigger strike. |
| `key_levels.momentum_wall` | number | USD | The momentum wall strike. |
| `key_levels.momentum_value` | number | USD per 1% move | The exposure at the momentum wall. |
| `walls.calls[]`, `walls.puts[]` | object[] | | The strongest positive and negative strikes near spot: `strike` (USD), `gex` (USD per 1% move). |
| `totals.net_gex`, `call_gex`, `put_gex` | number | USD per 1% move | Sum over every strike in the selected expirations; net = call + put. |
| `totals.net_volume_gex` | number | USD per 1% move | The same exposure for today's traded volume instead of open interest. |
| `totals.gex_ratio` | number | fraction | Call GEX's share of total absolute GEX; 0.5 is balanced. |
| `totals.delta_exposure` | number | shares | Share-equivalent dealer delta. |
| `totals.vanna_exposure` | number | shares per vol point | Delta created by a one-point rise in implied volatility. |
| `totals.charm_exposure` | number | shares per day | Delta created by one calendar day of decay. |
| `totals.atm_iv` | number | fraction | At-the-money implied volatility. |
| `flow.call_premium`, `put_premium` | number | USD | Premium traded today in the selected expirations' calls and puts. |
| `flow.net_flow` | number | USD | Call premium minus put premium. |
| `flow.flow_ratio` | number | fraction | The calls' share of the flow. |
| `flow.signed_call_premium`, `signed_put_premium` | number | USD | Buys minus sells per side, from classified prints. |
| `flow.bullish_premium`, `bearish_premium` | number | USD | Call buys + put sells; call sells + put buys. |
| `classification.available` | boolean | | False until a print has been classified today. |
| `classification.matched_contracts` | integer | | Contracts in the result with classified prints today. |
| `classification.classified_volume`, `unclassified_volume` | number | contracts | |
| `classification.coverage` | number | fraction | Share of today's volume the classifier could sign. |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/levels?expirations=4"
```

```json
{
 "data": {
  "key_levels": {
   "gamma_flip": 765.0866,
   "call_wall": 770.0,
   "put_wall": 760.0,
   "max_pain": 764.0,
   "vol_trigger": 770.0,
   "momentum_wall": 772.0,
   "momentum_value": 282722120
  },
  "walls": {
   "calls": [{"strike": 770.0, "gex": 999867299}, {"strike": 772.0, "gex": 883223628}, ...],
   "puts": [{"strike": 760.0, "gex": -340630971}, {"strike": 745.0, "gex": -181762342}, ...]
  },
  "totals": {
   "net_gex": 3849708997,
   "call_gex": 8854132310,
   "put_gex": -5004423314,
   "net_volume_gex": -119165879,
   "gex_ratio": 0.6389,
   "delta_exposure": 11021680,
   "vanna_exposure": -177398,
   "charm_exposure": 8690425,
   "atm_iv": 0.123
  },
  "flow": {
   "net_flow": 4195686,
   "flow_ratio": 0.5261,
   "call_premium": 9923768,
   "put_premium": 5728082,
   "signed_call_premium": -4764388,
   "signed_put_premium": 357681.0,
   "bullish_premium": 13864323,
   "bearish_premium": 18986392
  },
  "classification": {
   "available": true,
   "matched_contracts": 336,
   "classified_volume": 124154,
   "unclassified_volume": 4331,
   "coverage": 0.9663
  }
 },
 "meta": {
  "symbol": "SPY",
  "as_of": "2026-10-02T13:31:49.549Z",
  "as_of_ms": 1790947909549,
  "spot": {"price": 769.57},
  "expirations": {"mode": "nearest", "count": 4, "dates": ["2026-10-02", "2026-10-05", ...]}
 }
}
```

Response headers of that call:

```
Age: 0
Cache-Control: private, max-age=9
ETag: "1790947919582"
RateLimit-Limit: …
RateLimit-Remaining: …
RateLimit-Reset: …
RateLimit-Policy: …
Content-Type: application/json
```

### `GET /v1/symbols/{symbol}/strikes`

Everything in `levels` plus one row per strike across the selected expirations.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `expirations` | query | string | 4 | As on `levels`: the nearest N listed expirations (1 to 50) or `term`. |
| `strikes` | query | string | all | (1.0.24) The strike window by count: the N strikes nearest spot (1 to 1000) or `all`. |

**Returns** the `levels` blocks, `count`, and `strikes[]`:

| Field | Type | Unit | Description |
|---|---|---|---|
| `strike` | number | USD | |
| `gex.net`, `call`, `put` | number | USD per 1% move | Open-interest GEX summed over the strike's contracts. |
| `gex.abs` | number | USD per 1% move | \|net\|. |
| `gex.net_volume`, `call_volume`, `put_volume` | number | USD per 1% move | The same exposure for today's traded volume instead of open interest. |
| `exposures.delta` | number | shares | |
| `exposures.vanna` | number | shares per vol point | |
| `exposures.charm` | number | shares per day | |
| `open_interest.call`, `put`, `total` | number | contracts | |
| `volume.call`, `put` | number | contracts | Today. |
| `iv` | number | fraction | Implied volatility at the strike (its out-of-the-money side). |
| `moneyness` | number | fraction | (strike − spot) / spot. |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/strikes?expirations=4"
```

```json
{
 "data": {
  "strikes": [
   {
    "strike": 770.0,
    "gex": {
     "net": 999867299,
     "call": 1377691867,
     "put": -377824568,
     "abs": 999867299,
     "net_volume": -112171392,
     "call_volume": 345017010,
     "put_volume": -457188402
    },
    "exposures": {"delta": 776923, "vanna": 6892, "charm": -205342},
    "open_interest": {"call": 25984, "put": 8345, "total": 34329},
    "volume": {"call": 6329, "put": 7953},
    "iv": 0.1195,
    "moneyness": 0.0006
   }
  ],
  "count": 125,
  "key_levels": ...,
  "walls": ...,
  "totals": ...,
  "flow": ...,
  "classification": ...
 },
 "meta": {
  "symbol": "SPY",
  "as_of": "2026-10-02T13:31:49.549Z",
  "as_of_ms": 1790947909549,
  "spot": {"price": 769.57},
  "expirations": {"mode": "nearest", "count": 4, "dates": ["2026-10-02", ...]}
 }
}
```

`strikes[]` cut to the at-the-money row; the `levels` blocks ride along unchanged.

### `GET /v1/symbols/{symbol}/contracts`

One row per listed contract in the selected expirations. This is the largest response (hundreds of KB for an index at 20 expirations); use `levels` or `strikes` unless you need per-contract data.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `expirations` | query | string | 4 | As on `levels`: the nearest N listed expirations (1 to 50) or `term`. |

**Returns** `count` and `contracts[]`:

| Field | Type | Unit | Description |
|---|---|---|---|
| `expiration` | date | | |
| `strike`, `bid`, `ask`, `mid` | number | USD per share | The best bid and offer when computed; `mid` is the price the contract was valued at. |
| `right` | string | | `C` or `P`. |
| `dte` | number | calendar days | Days to expiration; 0 on expiration day. |
| `open_interest`, `volume` | number | contracts | |
| `iv.value` | number | fraction | |
| `quote_as_of` | timestamp | | The moment the quote this row was valued from was last updated; compare with `meta.as_of` for its age (1.0.8). |
| `time_to_expiry_years` | number | years | The time left to expiration as of `meta.computed_at` (1.0.8). |
| `settlement` | string | | `am` (settles on the opening print, priced to 09:30 New York on the expiration day) or `pm` (the close); `null` on answers stored before 1.0.15. |
| `greeks.*` | number | textbook | delta per $1; gamma per $1 per $1; vega, vanna, vomma, zomma per 1.00 of vol; theta, charm, color per year; rho per 1.00 of rate; speed per $1. Also `omega` (elasticity, dimensionless), `veta` (per year of time to expiry) and `ultima` (per 1.00 of vol). |
| `gex.call`, `put` | number | USD per 1% move | The contract's open-interest GEX, signed by side (calls positive, puts negative). |
| `gex.call_volume`, `put_volume` | number | USD per 1% move | Same with today's volume. |
| `flow.true_flow_gex` | number | USD per 1% move | Gamma dealers took on through today's classified prints in this contract. |
| `flow.dealer_flow` | number | contracts | Customer sells minus buys today. |
| `flow.coverage` | number | fraction | |
| `flow.classified_volume`, `unclassified_volume` | number | contracts | |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/contracts?expirations=1"
```

```json
{
 "data": {
  "contracts": [
   {
    "expiration": "2026-10-02",
    "strike": 770.0,
    "right": "C",
    "dte": 0,
    "bid": 1.6,
    "ask": 1.61,
    "mid": 1.605,
    "open_interest": 18665,
    "volume": 7691,
    "iv": {"value": 0.1885},
    "greeks": {
     "delta": 0.507803,
     "gamma": 0.101266,
     "vega": 8.336101,
     "theta": -1078.040123,
     "rho": 0.28686,
     "charm": 1.568247,
     "vanna": -0.030598,
     "vomma": 0.012517,
     "zomma": -0.537178,
     "speed": -0.000635,
     "color": -68.730244
    },
    "gex": {"call": 1120798290, "put": 0, "call_volume": 461830145, "put_volume": 0},
    "flow": {
     "true_flow_gex": 32425989,
     "dealer_flow": 540,
     "coverage": 0.9837,
     "classified_volume": 7566,
     "unclassified_volume": 125
    }
   }
  ],
  "count": 183
 },
 "meta": {
  "symbol": "SPY",
  "as_of": "2026-10-02T13:32:50.598Z",
  "as_of_ms": 1790947970598,
  "spot": {"price": 770.05},
  "expirations": {"mode": "nearest", "count": 1, "dates": ["2026-10-02"]}
 }
}
```

`contracts[]` cut to one row.

### `GET /v1/symbols/{symbol}/heatmap`

Net GEX per strike and expiration for the strikes around spot, or for a window of N strikes nearest spot with `strikes=` (1.0.24): a count window drops interior strikes whose every cell is under 50 USD in absolute value, never the outermost two, so a dense request stays readable.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `expirations` | query | string | 4 | As on `levels`: the nearest N listed expirations (1 to 50) or `term`. |
| `strikes` | query | string | the strikes around spot | (1.0.24) The strike window by count: the N strikes nearest spot (1 to 1000) or `all`, instead of the default band. |

| Field | Type | Unit | Description |
|---|---|---|---|
| `strikes[]` | number[] | USD | |
| `expirations[]` | date[] | | |
| `net_gex[i][j]` | number[][] | USD per 1% move | Net GEX at `strikes[i]`, `expirations[j]`. |
| `spot` | number | USD | |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/heatmap?expirations=4"
```

```json
{
 "data": {
  "strikes": [710.0, 715.0, ...],
  "expirations": ["2026-10-02", "2026-10-05", ...],
  "net_gex": [[-5078680, -115, ...], [-3136867, -309, ...], ...],
  "spot": 769.57
 },
 "meta": {
  "symbol": "SPY",
  "as_of": "2026-10-02T13:31:49.549Z",
  "as_of_ms": 1790947909549,
  "spot": {"price": 769.57},
  "expirations": {"mode": "nearest", "count": 4, "dates": ["2026-10-02", "2026-10-05", ...]}
 }
}
```

### `GET /v1/symbols/{symbol}/flow`

Signed order flow for the session: totals, one-minute buckets and classifier coverage.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `series` | query | 0/1 | 0 | `1`: add `series`: `session` (`current`, or `previous` before today's first classified trade) and `samples[]` (`ts_ms`, `spot`, `signed_call_premium_cum`, `signed_put_premium_cum` USD), the cumulative signed-premium path (net drift): the last 15 minutes at full resolution, older samples thinned. |
| `buckets` | query | integer or `all` | `all` | (1.0.61) Only the newest N one-minute buckets, `0` to `1440` (`0`: none, totals and classification only). The stream's `flow` family carries `?buckets=1` (the minute that changes) and its `drift` family `?buckets=0&series=1` (1.0.61); the day's buckets are this GET once. |

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `date` | date | | Trading date, Eastern. |
| `totals.signed_call_premium`, `signed_put_premium` | number | USD | Buys minus sells per side, today. |
| `totals.bullish_premium`, `bearish_premium` | number | USD | Call buys + put sells; call sells + put buys. |
| `totals.net_premium` | number | USD | bullish − bearish. |
| `buckets[].ts`, `ts_ms` | timestamp | | Start of the minute. |
| `buckets[].spot` | number | USD | Spot during the minute. |
| `buckets[].classified_trades` | integer | | Prints the classifier signed in the minute. |
| `buckets[].all`, `itm`, `atm`, `otm` | object | | `signed_call_premium`, `signed_put_premium` (USD) and `signed_call_contracts`, `signed_put_contracts` (contracts), buys minus sells, for all prints and by moneyness; ATM is within 0.5% of spot. |
| `classification.classified_trades`, `unclassified_trades` | integer | | Today. |
| `classification.coverage` | number | fraction | classified / (classified + unclassified). |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/flow"
```

```json
{
 "data": {
  "date": "2026-10-02",
  "totals": {
   "signed_call_premium": -5321933,
   "signed_put_premium": 391354.0,
   "bullish_premium": 14905574,
   "bearish_premium": 20618861,
   "net_premium": -5713287
  },
  "buckets": [
   {
    "ts": "2026-10-02T13:30:00.000Z",
    "ts_ms": 1790947800000,
    "spot": 770.12,
    "classified_trades": 14492,
    "all": {
     "signed_call_premium": -4105255,
     "signed_put_premium": 35111.0,
     "signed_call_contracts": -14837,
     "signed_put_contracts": -3316
    },
    "itm": {
     "signed_call_premium": -2288161,
     "signed_put_premium": 130578.0,
     "signed_call_contracts": -1626,
     "signed_put_contracts": 148
    },
    "atm": {
     "signed_call_premium": -551476.0,
     "signed_put_premium": 2897.0,
     "signed_call_contracts": -2844,
     "signed_put_contracts": 843
    },
    "otm": {
     "signed_call_premium": -1266436,
     "signed_put_premium": -109328.0,
     "signed_call_contracts": -10499,
     "signed_put_contracts": -4337
    }
   }
  ],
  "classification": {"classified_trades": 28283, "unclassified_trades": 1302, "coverage": 0.956}
 },
 "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:31:59.247Z"}
}
```

`buckets[]` cut to one minute.

### `GET /v1/symbols/{symbol}/history/levels`

Key levels through a session, one row per interval (or per `resolution` step).

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `date` | query | date | today | Session to read. |
| `interval` | query | integer | 5 | Minutes between rows, 1 to 30. |
| `resolution` | query | string | | (1.0.17) The step between rows: `1s`, `5s`, `15s`, `30s`, `1m`, `5m`, `15m`, `30m`; see Conventions, "Resolution". When neither `resolution` nor `interval` is given, `interval`'s 5 minutes apply; with `interval` given, `interval` wins. |
| `from`, `to` | query | date | | A range of finished sessions instead of `date` (Conventions, "Ranges"): every finished session from `from` to `to` inclusive, at most 31; `to` defaults to the last finished session. |

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `date` | date | | The session (one-session answer). |
| `from`, `to`, `sessions[]` | date | | Range answer: the bounds asked for and the sessions the rows come from, oldest first (a session with no levels recorded for the symbol is left out). |
| `interval_minutes` | integer | minutes | Interval applied. |
| `rows[].as_of` | timestamp | | |
| `rows[].spot` | number | USD | |
| `rows[].expirations` | integer | | Expirations the levels were computed over. |
| `rows[].key_levels` | object | USD | `gamma_flip`, `call_wall`, `put_wall`, `max_pain`, `vol_trigger`, `momentum_wall`. |
| `count` | integer | | |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/history/levels?interval=1"
```

```json
{
 "data": {
  "date": "2026-10-02",
  "interval_minutes": 1,
  "rows": [
   {
    "as_of": "2026-10-02T13:30:49.336Z",
    "spot": 770.32,
    "expirations": 4,
    "key_levels": {
     "gamma_flip": 765.0906,
     "call_wall": 770.0,
     "put_wall": 760.0,
     "max_pain": 764.0,
     "vol_trigger": 770.0,
     "momentum_wall": 772.0
    }
   },
   {
    "as_of": "2026-10-02T13:31:49.806Z",
    "spot": 769.58,
    "expirations": 4,
    "key_levels": {
     "gamma_flip": 765.0866,
     "call_wall": 770.0,
     "put_wall": 760.0,
     "max_pain": 764.0,
     "vol_trigger": 770.0,
     "momentum_wall": 772.0
    }
   },
   ...
  ],
  "count": 5
 },
 "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:31:59.249Z"}
}
```

Over a range of sessions, one answer, the rows of every session in order (the three sessions here are 42 rows at 30 minutes; a 31-session range at `interval=1` is about 12,000 rows):

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/history/levels?from=2026-10-07&to=2026-10-09&interval=30"
```

```json
{
 "data": {
  "from": "2026-10-07",
  "to": "2026-10-09",
  "sessions": [
   "2026-10-07",
   "2026-10-08",
   "2026-10-09"
   ... 40 more rows
  ],
  "interval_minutes": 30,
  "rows": [
   {
    "as_of": "2026-10-07T13:59:03.016Z",
    "spot": 774.785,
    "expirations": 4,
    "key_levels": {
     "gamma_flip": 779.2393,
     "call_wall": 785.0,
     "put_wall": 767.0,
     "max_pain": 775.0,
     "vol_trigger": 775.0,
     "momentum_wall": 774.0
    }
   },
   {
    "as_of": "2026-10-07T14:29:02.693Z",
    "spot": 774.28,
    "expirations": 4,
    "key_levels": {
     "gamma_flip": 779.1894,
     "call_wall": 785.0,
     "put_wall": 767.0,
     "max_pain": 775.0,
     "vol_trigger": 775.0,
     "momentum_wall": 774.0
    }
   }
  ],
  "count": 42
 },
 "meta": {
  "symbol": "SPY",
  "from": "2026-10-07",
  "to": "2026-10-09",
  "as_of": "2026-10-15T14:16:48.513Z"
 }
}
```

### `GET /v1/symbols/{symbol}/history/flow`

Net order flow through a session, as points at the session's native resolution (Conventions, "Resolution"). `interval` thins the series to one row per step (the last point in each); `resolution` reads the one-second points when the symbol has them. Premiums are USD; signed values follow the sign convention of `/v1/symbols/{symbol}/flow` (buys positive, sells negative).

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `date` | date | current session | Session to read. Beyond the tier's reach: `403`. |
| `from`, `to` | date | | A range of finished sessions instead of `date` (Conventions, "Ranges"): the samples of every finished session from `from` to `to`, at most 31, in one answer; not with `buckets=1` (`422`). |
| `interval` | integer | 5 | Seconds between rows, 5–300. |
| `resolution` | string | | (1.0.17, one second 1.0.18) The step between rows as a named resolution (`1s` … `30m`): the last point of each step. For a name with one-second points (sessions from 2026-10-19) the rows come from them (`meta.resolution` `1s` … as asked); for other names the 5-second series answers, and `1s` is answered at `5s` with `meta.resolution_requested` and a note. One session per call below `5s`. `interval`, when given, wins; see Conventions, "Resolution". |
| `buckets` | 0/1 | 0 | `1`: add `buckets[]`, one per session minute (1.0.11): `ts`, `ts_ms`, `spot` (USD), `classified_trades`, and for `calls` and `puts` each `gross` (`premium` USD, `volume` contracts, `trades`), `by_side` (the same per `ABOVE_ASK`, `ASK`, `MID_MARKET`, `BID`, `BELOW_BID`) and `by_moneyness` (per `itm`, `atm`, `otm`; ATM is within 0.5 % of spot). Omitted for a session without minute buckets. |

| Field | Type | Unit | Meaning |
|---|---|---|---|
| `date`, `interval_seconds`, `count` | | | The session, the step, the number of rows. `meta.resolution` names the step served (1.0.17). |
| `from`, `to`, `sessions[]` | date | | Range answer: the bounds asked for and the sessions the rows come from, oldest first. |
| `rows[].as_of` | string | UTC | Time of the sample. |
| `rows[].spot` | number | USD | Underlying price at the sample. |
| `rows[].call_premium`, `put_premium` | number | USD | Premium traded so far by leg. |
| `rows[].signed_call_premium`, `signed_put_premium` | number | USD | Signed premium so far by leg (buys − sells). |
| `rows[].bullish_premium`, `bearish_premium` | number | USD | Premium so far by direction. |
| `rows[].signed_call_volume`, `signed_put_volume` | integer | contracts | Signed contracts so far by leg. |

```json
{"data": {"date": "2026-10-09", "interval_seconds": 60, "count": 391,
          "rows": [{"as_of": "2026-10-09T13:30:55Z", "spot": 787.91, "call_premium": 6236100.0, "put_premium": 7775620.0,
                    "signed_call_premium": -303008.0, "signed_put_premium": -149246.0, "bullish_premium": 5102000.0, "bearish_premium": 8909720.0,
                    "signed_call_volume": -204, "signed_put_volume": -70}, "…"]},
 "meta": {"symbol": "SPY", "as_of": "2026-10-11T15:40:02Z"}}
```

Over a range of sessions (the same rows, every session in order, `sessions` says which):

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/history/flow?from=2026-10-07&to=2026-10-09&interval=300"
```

```json
{
 "data": {
  "from": "2026-10-07",
  "to": "2026-10-09",
  "sessions": [
   "2026-10-07",
   "2026-10-08",
   "2026-10-09"
   ... 233 more
  ],
  "interval_seconds": 300,
  "rows": [
   {
    "as_of": "2026-10-07T13:34:59.999Z",
    "spot": 775.94,
    "call_premium": 27178292.0,
    "put_premium": 22800400.0,
    "signed_call_premium": -794846.0,
    "signed_put_premium": -5172738.0,
    "bullish_premium": 27178292.0,
    "bearish_premium": 22800400.0,
    "signed_call_volume": 777,
    "signed_put_volume": -9936
   },
   {
    "as_of": "2026-10-07T13:39:59.999Z",
    "spot": 774.5,
    "call_premium": 53107390.0,
    "put_premium": 52431507.0,
    "signed_call_premium": -1119052.0,
    "signed_put_premium": -1794935.0,
    "bullish_premium": 53107390.0,
    "bearish_premium": 52431507.0,
    "signed_call_volume": -4147,
    "signed_put_volume": 18538
   }
  ],
  "count": 235
 },
 "meta": {
  "symbol": "SPY",
  "from": "2026-10-07",
  "to": "2026-10-09",
  "as_of": "2026-10-15T20:26:41.313Z"
 }
}
```

With `buckets=1`, one bucket per session minute:

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/history/flow?date=2026-10-09&interval=300&buckets=1"
```

```json
{
 "data": {
  "date": "2026-10-09",
  "interval_seconds": 300,
  "rows": [
   {
    "as_of": "2026-10-09T13:34:55.818Z",
    "spot": 776.555,
    "call_premium": 19259772.0,
    "put_premium": 19891554.0,
    "signed_call_premium": -700442.0,
    "signed_put_premium": -68660.0,
    "bullish_premium": 19259772.0,
    "bearish_premium": 19891554.0,
    "signed_call_volume": 11323,
    "signed_put_volume": -16005
   }
  ],
  "count": 79,
  "buckets": [
   {
    "ts": "2026-10-09T13:30:00.000Z",
    "ts_ms": 1791552600000,
    "spot": 787.915,
    "classified_trades": 9778,
    "calls": {
     "gross": {
      "premium": 9006006.0,
      "volume": 32926,
      "trades": 5948
     },
     "by_side": {
      "ABOVE_ASK": {
       "premium": 85327.0,
       "volume": 784,
       "trades": 129
      },
      "ASK": {
       "premium": 2713228.0,
       "volume": 16798,
       "trades": 2655
      },
      "MID_MARKET": {
       "premium": 3608040.0,
       "volume": 3691,
       "trades": 693
      },
      "BID": {
       "premium": 2402844.0,
       "volume": 10176,
       "trades": 2163
      },
      "BELOW_BID": {
       "premium": 129195.0,
       "volume": 877,
       "trades": 177
      }
     },
     "by_moneyness": {
      "itm": {
       "premium": 8231047.0,
       "volume": 26225,
       "trades": 5206
      },
      "atm": {
       "premium": 341879.0,
       "volume": 3327,
       "trades": 449
      },
      "otm": {
       "premium": 433080.0,
       "volume": 3374,
       "trades": 293
      }
     }
    },
    "puts": {
     "gross": {
      "premium": 4210106.0,
      "volume": 32319,
      "trades": 4616
     },
     "by_side": {
      "ABOVE_ASK": {
       "premium": 52141.0,
       "volume": 389,
       "trades": 100
      },
      "ASK": {
       "premium": 1339863.0,
       "volume": 11464,
       "trades": 1821
      },
      "MID_MARKET": {
       "premium": 980560.0,
       "volume": 2759,
       "trades": 325
      },
      "BID": {
       "premium": 1697707.0,
       "volume": 16750,
       "trades": 2180
      },
      "BELOW_BID": {
       "premium": 61638.0,
       "volume": 444,
       "trades": 101
      }
     },
     "by_moneyness": {
      "itm": {
       "premium": 11492.0,
       "volume": 4,
       "trades": 4
      },
      "atm": {
       "premium": 148515.0,
       "volume": 130,
       "trades": 9
      },
      "otm": {
       "premium": 4050099.0,
       "volume": 32185,
       "trades": 4603
      }
     }
    }
   }
   ... 404 more buckets
  ]
 },
 "meta": {
  "symbol": "SPY",
  "as_of": "2026-10-15T20:26:41.942Z"
 }
}
```

### `GET /v1/flow/leaders`

The day's flow leaders over a finished session, or over the sessions of its week so far (1.0.22): every print of the
session totalled per underlying and per contract at the close, ranked, with each underlying's trailing baseline (its mean
over up to 20 earlier sessions).

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `date` | date | newest session | An exact finished session. If its table is unavailable: `404`; no older session is substituted. Omit `date` for the newest available completed session. `meta.table_date` names the session served. Beyond the tier's reach: `403`. |
| `window` | string | `day` | `day`: the one session; `week`: the sessions from that week's Monday to `date`, summed. |
| `by` | string | `premium` | The ranking key: `premium`, `volume`, `trades`, `sweeps` (sweep premium), `bullish` or `bearish` (premium by direction). |
| `kind` | string | `underlyings` | `underlyings`, or `contracts` for single contracts (`window=day`; `by` premium, volume, trades, bullish or bearish). |
| `limit` | integer | 25 | Rows, 1–200. |

| Field | Type | Unit | Meaning |
|---|---|---|---|
| `date`, `window`, `sessions[]`, `by`, `kind`, `count` | | | The session, the window and the sessions it covers, the ranking, the kind, the number of rows. |
| `rows[].symbol` | string | | The underlying. |
| `rows[].premium`, `call_premium`, `put_premium` | number | USD | Premium traded in the window, and by leg. |
| `rows[].volume`, `call_volume`, `put_volume`, `trades` | integer | contracts, prints | Contracts traded, by leg, and the number of prints. |
| `rows[].bullish_premium`, `bearish_premium` | number | USD | Premium by direction. |
| `rows[].imbalance` | number | fraction | Premium imbalance, −1 (all bearish) to 1 (all bullish); null with no classified premium. |
| `rows[].sweep_trades`, `sweep_premium` | integer, number | prints, USD | The sweeps among them. |
| `rows[].spot` | number | USD | The underlying at the close. |
| `rows[].premium_relative`, `volume_relative` | number | fraction | The day's premium and volume over the symbol's baseline mean (1.0 = its average session); null in a week window or with no baseline. |
| `rows[].baseline_sessions` | integer | sessions | How many sessions the baseline averages. |
| contracts: `rows[].contract`, `expiration`, `right`, `strike` | string, date, string, number | USD (strike) | The OSI symbol and its parts; `premium`, `volume`, `trades`, `bullish_premium`, `bearish_premium`, `imbalance`, `spot` as above. |
| `meta.table_date`, `meta.table_sessions` | date, integer | | The session served and how many sessions are available. |

```json
{"data":{"date":"2026-10-16","window":"day","sessions":["2026-10-16"],"by":"premium","kind":"underlyings","count":2,
  "rows":[{"symbol":"SPX","premium":27470807003.0,"volume":7264302,"trades":2102867,"call_premium":16061260317.0,"put_premium":11409546686.0,
           "call_volume":2949808,"put_volume":4314494,"bullish_premium":16325118313.0,"bearish_premium":9703965763.0,"imbalance":0.2544,
           "sweep_trades":0,"sweep_premium":0.0,"spot":7755.3501,"premium_relative":1.5122,"volume_relative":1.3112,"baseline_sessions":1},
          {"symbol":"QQQ","premium":3953707792.0,"volume":12638353,"trades":1813600,"call_premium":1533825887.0,"put_premium":2419881905.0,
           "call_volume":6152044,"put_volume":6486309,"bullish_premium":1727399917.0,"bearish_premium":1972441065.0,"imbalance":-0.0662,
           "sweep_trades":121613,"sweep_premium":1046538809.0,"spot":722.76,"premium_relative":1.2375,"volume_relative":1.2293,"baseline_sessions":1}]},
 "meta":{"as_of":"2026-10-18T13:53:02.554Z","table_date":"2026-10-16","table_sessions":2}}
```

Single contracts by volume (`kind=contracts&by=volume&limit=2`):

```json
{"data":{"date":"2026-10-16","window":"day","by":"volume","kind":"contracts","count":2,
  "rows":[{"contract":"SPY261016P00762000","symbol":"SPY","expiration":"2026-10-16","right":"put","strike":762.0,"premium":35073718.0,"volume":526209,
           "trades":65636,"bullish_premium":18226043.0,"bearish_premium":16164652.0,"imbalance":0.0599,"spot":773.245},
          {"contract":"SPY261016P00765000","symbol":"SPY","expiration":"2026-10-16","right":"put","strike":765.0,"premium":40333233.0,"volume":495099,
           "trades":57800,"bullish_premium":19571721.0,"bearish_premium":18988837.0,"imbalance":0.0151,"spot":773.245}]},
 "meta":{"as_of":"2026-10-18T13:53:03.447Z","table_date":"2026-10-16","table_sessions":2}}
```

### `GET /v1/flow/sectors`

Flow by sector over a finished session (1.0.22): the underlyings' totals summed by sector (each symbol's sector as
`/v1/symbols/{symbol}/reference` gives it), with each sector's baseline.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `date` | date | newest session | An exact finished session. If its table is unavailable: `404`; no older session is substituted. Omit `date` for the newest available completed session. `meta.table_date` names the session served. Beyond the tier's reach: `403`. |

| Field | Type | Unit | Meaning |
|---|---|---|---|
| `date`, `count` | | | The session and the number of sectors. |
| `rows[].sector` | string | | The sector name as `/v1/symbols/{symbol}/reference` gives it. |
| `rows[].symbols` | integer | names | Underlyings of the sector that traded options that session. |
| `rows[].premium`, `call_premium`, `put_premium`, `bullish_premium`, `bearish_premium` | number | USD | The sector's premium, by leg and by direction. |
| `rows[].volume` | integer | contracts | Contracts traded. |
| `rows[].imbalance` | number | fraction | Premium imbalance, −1 (all bearish) to 1 (all bullish). |
| `rows[].baseline_symbols` | integer | names | How many of the sector's members have a baseline. |
| `rows[].premium_relative` | number | fraction | The sector's premium over its baseline (1.0 = average). |

```json
{"data":{"date":"2026-10-16","count":11,
  "rows":[{"sector":"TECHNOLOGY","symbols":596,"premium":12826019258.0,"volume":22345586,"call_premium":6865487952.0,"put_premium":5960531306.0,
           "bullish_premium":5686695482.0,"bearish_premium":6269117209.0,"baseline_symbols":578,"imbalance":-0.0487,"premium_relative":0.6673},
          {"sector":"CONSUMER_CYCLICAL","symbols":332,"premium":2172050559.0,"volume":6710047,"call_premium":1391097609.0,"put_premium":780952950.0,
           "bullish_premium":944602242.0,"bearish_premium":1067642235.0,"baseline_symbols":314,"imbalance":-0.0611,"premium_relative":0.8703},"…"]},
 "meta":{"as_of":"2026-10-18T13:53:03.464Z","table_date":"2026-10-16","table_sessions":2}}
```

### `GET /v1/flow/breadth`

Market breadth over a finished session (1.0.22): every underlying that traded options, counted as advancing, declining or
neutral by its premium imbalance against a threshold, with the market's totals and the four majors.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `date` | date | newest session | An exact finished session. If its table is unavailable: `404`; no older session is substituted. Omit `date` for the newest available completed session. `meta.table_date` names the session served. Beyond the tier's reach: `403`. |
| `threshold` | number | 0.2 | Imbalance beyond which a name counts as advancing (above) or declining (below the negative), a fraction 0–1. |

| Field | Type | Unit | Meaning |
|---|---|---|---|
| `date`, `threshold`, `symbols` | | | The session, the threshold used, the underlyings counted. |
| `advancing`, `declining`, `neutral` | integer | names | The counts; names with no classified premium are not counted. |
| `advance_decline_ratio` | number | ratio | Advancing names per declining name; null with no decliners. |
| `premium`, `bullish_premium`, `bearish_premium` | number | USD | The whole market's premium and its split by direction. |
| `imbalance` | number | fraction | The market's premium imbalance, −1 (all bearish) to 1 (all bullish). |
| `majors.{SPY,QQQ,IWM,DIA}.premium`, `.imbalance` | number | USD, fraction | The four index products' day. |

```json
{"data":{"date":"2026-10-16","threshold":0.2,"symbols":4777,"advancing":1375,"declining":1762,"neutral":1484,"advance_decline_ratio":0.7804,
  "premium":57870338227.0,"bullish_premium":29922796065.0,"bearish_premium":24222817608.0,"imbalance":0.1053,
  "majors":{"SPY":{"premium":2818930657.0,"imbalance":-0.0304},"QQQ":{"premium":3953707792.0,"imbalance":-0.0662},"IWM":{"premium":373035201.0,"imbalance":0.0625},"DIA":{"premium":33829377.0,"imbalance":0.1426}}},
 "meta":{"as_of":"2026-10-18T13:53:03.468Z","table_date":"2026-10-16","table_sessions":2}}
```

### `GET /v1/flow/unusual`

Contracts whose session volume is a multiple of their own trailing mean (1.0.22): each contract's day against its
baseline over up to 10 earlier sessions it traded in. A contract needs two baseline sessions before it can appear.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `date` | date | newest session | An exact finished session. If its table is unavailable: `404`; no older session is substituted. Omit `date` for the newest available completed session. `meta.table_date` names the session served. Beyond the tier's reach: `403`. |
| `min_volume_relative` | number | 3 | Session volume over the contract's trailing mean volume, at least. |
| `min_volume` | integer | 500 | Session volume, contracts, at least. |
| `limit` | integer | 100 | Rows, 1–500, by `volume_relative` descending. |

| Field | Type | Unit | Meaning |
|---|---|---|---|
| `date`, `min_volume_relative`, `min_volume`, `count` | | | The session, the filters, the rows. |
| `rows[].contract`, `symbol`, `expiration`, `right`, `strike` | string, string, date, string, number | USD (strike) | The contract. |
| `rows[].volume`, `trades` | integer | contracts, prints | The session's volume and prints. |
| `rows[].premium` | number | USD | The session's premium. |
| `rows[].imbalance` | number | fraction | Premium imbalance of the session's prints. |
| `rows[].spot` | number | USD | The underlying at the close. |
| `rows[].baseline_sessions` | integer | sessions | Sessions in the contract's trailing baseline. |
| `rows[].volume_relative` | number | fraction | Session volume as a multiple of its trailing baseline. |

```json
{"data":{"date":"2026-10-16","min_volume_relative":3.0,"min_volume":500,"count":2,
  "rows":[{"contract":"XSP270115P00735000","symbol":"XSP","expiration":"2027-01-15","right":"put","strike":735.0,"volume":48539,"premium":50152357.0,"trades":13,"imbalance":-0.9867,"spot":0.0,"baseline_sessions":7,"volume_relative":17911.0701},
          {"contract":"TQQQ270115C00087000","symbol":"TQQQ","expiration":"2027-01-15","right":"call","strike":87.0,"volume":19640,"premium":4298550.0,"trades":266,"imbalance":0.9574,"spot":0.0,"baseline_sessions":3,"volume_relative":11760.479}]},
 "meta":{"as_of":"2026-10-18T14:21:06.047Z","table_date":"2026-10-16","table_sessions":150}}
```

### `GET /v1/symbols/{symbol}/flow/baseline`

A symbol's finished session against its trailing baseline (1.0.22): the day's totals with their relatives, and every
5-minute slot of the session against the same slot over the baseline sessions, so a reader sees where the day
ran hot or cold against the symbol's own habit.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `date` | date | newest session | An exact finished session. If its table is unavailable: `404`; no older session is substituted. Omit `date` for the newest available completed session. `meta.table_date` names the session served. Beyond the tier's reach: `403`. |

| Field | Type | Unit | Meaning |
|---|---|---|---|
| `symbol`, `date` | | | The symbol and the session. |
| `baseline.sessions` | integer | sessions | Sessions in the baseline (up to 20 before `date`). |
| `session` | object | | The day's row as `/v1/flow/leaders` gives it, `premium_relative` and `volume_relative` against this baseline. |
| `slots[].time` | string | ET | The 5-minute slot's start, `HH:MM`. |
| `slots[].premium`, `bullish_premium`, `bearish_premium` | number | USD | Premium in the slot, and by direction. |
| `slots[].volume`, `trades` | integer | contracts, prints | Contracts and prints in the slot. |
| `slots[].imbalance` | number | fraction | The slot's premium imbalance, −1 (all bearish) to 1 (all bullish). |
| `slots[].premium_relative` | number | fraction | The slot's premium relative to the same slot over the baseline sessions: 1 is a usual day. |

```json
{"data":{"symbol":"SPY","date":"2026-10-16",
  "baseline":{"sessions":20},
  "session":{"symbol":"SPY","premium":2818930657.0,"volume":16617125,"trades":2090677,"call_premium":1197672186.0,"put_premium":1621258471.0,"call_volume":6838428,"put_volume":9778697,"bullish_premium":1294394954.0,"bearish_premium":1375688323.0,"imbalance":-0.0304,"sweep_trades":153069,"sweep_premium":1040354213.0,"spot":773.245,"premium_relative":1.3751,"volume_relative":1.3103,"baseline_sessions":20},
  "slots":[{"time":"09:30","premium":37559549.0,"volume":237155,"trades":31640,"bullish_premium":17654589.0,"bearish_premium":18247593.0,"imbalance":-0.0165,"premium_relative":0.7426},{"time":"09:35","premium":39138711.0,"volume":248544,"trades":35998,"bullish_premium":17519228.0,"bearish_premium":19416173.0,"imbalance":-0.0514,"premium_relative":0.9382},"\u2026"]},
 "meta":{"as_of":"2026-10-18T14:21:05.781Z","table_date":"2026-10-16","table_sessions":150,"symbol":"SPY"}}
```

### `GET /v1/symbols/{symbol}/flow/history`

A symbol's flow totals per session over its trailing sessions (1.0.22), newest last.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `date` | date | newest session | An exact finished session. If its table is unavailable: `404`; no older session is substituted. Omit `date` for the newest available completed session. `meta.table_date` names the session served. Beyond the tier's reach: `403`. |
| `days` | integer | 20 | Sessions, 1–60; one answer carries at most the 20 sessions up to `date` (ask with an earlier `date` for older ones). |

| Field | Type | Unit | Meaning |
|---|---|---|---|
| `symbol`, `date`, `count` | | | The symbol, the session the rows end at, the rows. |
| `rows[].date` | date | | The session. |
| `rows[].premium`, `call_premium`, `put_premium` | number | USD | Premium traded, and by leg. |
| `rows[].volume`, `call_volume`, `put_volume` | integer | contracts | Contracts traded, and by leg. |

```json
{"data":{"symbol":"SPY","date":"2026-10-16","count":2,
  "rows":[{"date":"2026-10-15","premium":2319046117.0,"volume":14067216,"call_premium":1189843173.0,"put_premium":1129202944.0,"call_volume":6490775,"put_volume":7576441},
          {"date":"2026-10-16","premium":2818930657.0,"volume":16617125,"call_premium":1197672186.0,"put_premium":1621258471.0,"call_volume":6838428,"put_volume":9778697}]},
 "meta":{"as_of":"2026-10-18T13:53:04.187Z","table_date":"2026-10-16","table_sessions":2,"symbol":"SPY"}}
```

### `GET /v1/symbols/{symbol}/history/heatmap`

The exposure grid through a session: for each interval, call, put and net gamma exposure per strike, for one expiration or the nearest few, per minute. This is the history behind the live `heatmap`; a day's grid for one symbol is a few hundred kilobytes at 5-minute intervals.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `date` | date | current session | Session to read. Beyond the tier's reach: `403`. |
| `interval` | integer | 5 | Minutes per snapshot: 1, 2, 3, 4, 5, 10, 15 or 30. |
| `resolution` | string | `1m` | (1.0.17; sub-minute 1.0.18) The step between snapshots as a named resolution. `1m` … `30m`: the per-expiration grid. `1s`, `5s`, `15s`, `30s`: the core names' default-view strike profile (the 4 nearest expirations summed) at that step, from 2026-10-19, inside a `start`/`end` window of at most 15 minutes; rows carry `delta`, `vanna` and `charm` exposure beside the GEX, snapshots carry `data_as_of` and `carried`. Not with `expiration` or `mode=difference` (`422`). A session without a one-second grid answers the per-minute grid with `meta.resolution_requested` and a note; a symbol outside the core list is `404` with the reason. `interval`, when given, wins. |
| `start` | string | | Sub-minute resolutions: the window's start, RFC 3339 (`2026-10-19T13:30:00Z`) or the session's ET clock (`09:30:00`). Required. |
| `end` | string | `start` + 15 min | Sub-minute resolutions: the window's end, at most 15 minutes after `start`. |
| `expiration` | date | nearest few | One expiration; omitted, the nearest expirations recorded that day are summed. |
| `mode` | string | `raw` | `raw` (levels) or `difference` (change since the previous snapshot). |
| `strikes` | integer | 45 | Strikes nearest spot to include, 5–100. |

| Field | Type | Unit | Meaning |
|---|---|---|---|
| `date`, `interval_minutes`, `mode`, `count` | | | The session, the step, the mode, the number of snapshots. `meta.resolution` names the step served (1.0.17). A sub-minute answer has `resolution`, `expirations_summed` (4), `start` and `end` instead of `interval_minutes` and `expirations`. |
| `expiration` | string \| null | | The expiration selected, or `null` when the nearest few are summed. |
| `expirations` | string[] | | Expirations recorded for the symbol that day (not yet expired on that date). |
| `snapshots[].as_of` | string | UTC | Start of the interval. |
| `snapshots[].spot` | number | USD | Spot at that minute. |
| `snapshots[].rows[].strike` | number | USD | |
| `snapshots[].rows[].call_gex`, `put_gex`, `net_gex` | number | USD per 1% move | Gamma exposure by leg and net, whole dollars. |
| `snapshots[].rows[].delta`, `vanna`, `charm` | number | shares; shares per vol point; shares per day | (1.0.18, sub-minute answers) Net dealer exposures at the strike, the units of `/v1/symbols/{symbol}/strikes` `exposures`. |
| `snapshots[].data_as_of`, `snapshots[].carried` | timestamp, boolean | | (1.0.18, sub-minute answers) The market-data time of the answer the snapshot repeats, and whether the step had no new answer of its own. |

```json
{"data": {"date": "2026-10-09", "interval_minutes": 5, "mode": "raw", "expiration": "2026-10-09", "expirations": ["2026-10-09", "2026-10-10", "2026-10-13"],
          "count": 78,
          "snapshots": [{"as_of": "2026-10-09T13:30:00Z", "spot": 776.3,
                         "rows": [{"strike": 775.0, "call_gex": 1250000000, "put_gex": -500000000, "net_gex": 750000000}, "…"]}, "…"]},
 "meta": {"symbol": "SPY", "as_of": "2026-10-11T15:40:02Z"}}
```

The one-second grid (1.0.18), four seconds of SPY's default view inside a window:

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/history/heatmap?date=2026-10-16&resolution=1s&start=2026-10-16T13:31:00Z&end=2026-10-16T13:31:03Z"
```

```json
{"data": {"date": "2026-10-16", "resolution": "1s", "expirations_summed": 4, "mode": "raw",
          "start": "2026-10-16T13:31:00.000Z", "end": "2026-10-16T13:31:03.000Z", "count": 4,
          "snapshots": [{"as_of": "2026-10-16T13:31:00.000Z", "data_as_of": "2026-10-16T13:31:00.995Z", "spot": 772.89, "carried": false,
                         "rows": [{"strike": 660.0, "call_gex": 0, "put_gex": -28837, "net_gex": -28837, "delta": 41343, "vanna": -12, "charm": 71},
                                  {"strike": 665.0, "call_gex": 0, "put_gex": -284059, "net_gex": -284059, "delta": 56006, "vanna": -124, "charm": 544}, "…"]}, "…"]},
 "meta": {"symbol": "SPY", "as_of": "2026-10-16T13:32:44.138Z", "resolution": "1s",
          "note": "the one-second grid is the default view's strike profile (4 nearest expirations summed) of the core names; the per-expiration grid is per minute"}}
```

### `GET /v1/symbols/{symbol}/vol`

The volatility summary of the symbol's chain (1.0.19): what the options market says about the symbol's volatility and its
expected moves, from the same chain the levels describe. Volatilities and moves are fractions, as everywhere in this API (a move is a fraction of spot), with price bands in USD. The default
expiration set is the term-structure set (`expirations=term`); a count selects the nearest N.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `expirations` | query | string | `term` | `term` (the nearest to 5, 7, 14, 30, 60, 90, 120, 180, 270 and 365 days) or the nearest N, 1 to 50. |

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `spot` | number | USD | The spot the chain was priced from. |
| `tenors.{1d,9d,30d,91d,182d}.iv` | number | fraction | Constant-maturity volatility at the tenor (1, 9, 30, 91 and 182 days). |
| `tenors.*.expected_move` | number | fraction of spot | The expected move over the tenor. |
| `term.slope_30d_91d` | number | fraction | (91-day vol − 30-day vol) ÷ 30-day vol. |
| `term.state` | string | | `contango` when the 91-day vol is above the 30-day, else `backwardation`. |
| `term.ratio_1d_30d` | number | ratio | 1-day vol ÷ 30-day vol. |
| `expected_moves.{close,1d,week,opex,30d}` | object \| null | | The expected move to each horizon (`close`: the rest of today's session; `opex`: the monthly expiration on the third Friday, the next month's once passed): `years`, `iv`, `sigma` (fraction of spot), `low`, `high` (USD); `opex` adds `expiration`; `close` is null after the close. |
| `skew_30d` | object | fraction | The first expiration at or past 25 days: `expiration`, `atm_iv`, `put_25d_iv`, `call_25d_iv`, `risk_reversal_25d`, `butterfly_25d`. |
| `expirations[]` | object | | Per expiration: `expiration`, `dte`, `years`, `forward` (USD), `atm_iv` (at the money), `strip_iv` (the expiration's variance-strip volatility), `call_25d_iv`, `put_25d_iv`, `risk_reversal_25d` (call 25-delta vol minus put 25-delta vol: positive when calls are dearer), `butterfly_25d` (the 25-delta average minus the at-the-money vol) (fractions, null when not available), `contracts_used` (contracts the expiration's numbers come from). |
| `smiles[]` | object | | Per expiration the out-of-the-money points used: `strike` (USD), `iv` (fraction), `delta` (absolute, fraction), `right`. |
| `meta` | | | As on `/levels`: `as_of`, `spot`, the expiration set. |

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/vol"
```

```json
{
 "data": {
  "spot": 762.7901,
  "tenors": {
   "1d": {
    "iv": 0.2138,
    "expected_move": 0.011191
   },
   "9d": {
    "iv": 0.182348,
    "expected_move": 0.028634
   },
   "30d": {
    "iv": 0.177582,
    "expected_move": 0.050911
   },
   "91d": {
    "iv": 0.164832,
    "expected_move": 0.082303
   },
   "182d": {
    "iv": 0.163535,
    "expected_move": 0.115478
   }
  },
  "term": {
   "slope_30d_91d": -0.071798,
   "state": "backwardation",
   "ratio_1d_30d": 1.204
  },
  "expected_moves": {
   "close": null,
   "1d": {
    "years": 0.00274,
    "iv": 0.2138,
    "sigma": 0.011191,
    "low": 754.2539,
    "high": 771.3263
   },
   "week": {
    "years": 0.019178,
    "iv": 0.190922,
    "sigma": 0.02644,
    "low": 742.6221,
    "high": 782.9581
   },
   "opex": {
    "years": 0.095838,
    "iv": 0.17663,
    "sigma": 0.054681,
    "low": 721.0804,
    "high": 804.4998,
    "expiration": "2026-11-20"
   },
   "30d": {
    "years": 0.082192,
    "iv": 0.177582,
    "sigma": 0.050911,
    "low": 723.9556,
    "high": 801.6246
   }
  },
  "skew_30d": {
   "expiration": "2026-11-20",
   "risk_reversal_25d": -0.059546,
   "butterfly_25d": 0.004945,
   "atm_iv": 0.152399,
   "put_25d_iv": 0.187117,
   "call_25d_iv": 0.127572
  },
  "expirations": [
   {
    "expiration": "2026-10-23",
    "dte": 5,
    "years": 0.013699,
    "forward": 763.0796,
    "atm_iv": 0.164823,
    "strip_iv": 0.2138,
    "call_25d_iv": 0.145414,
    "put_25d_iv": 0.201388,
    "risk_reversal_25d": -0.055974,
    "butterfly_25d": 0.008578,
    "contracts_used": 83
   },
   {
    "expiration": "2026-10-26",
    "dte": 8,
    "years": 0.021918,
    "forward": 763.2533,
    "atm_iv": 0.142961,
    "strip_iv": 0.183186,
    "call_25d_iv": 0.123301,
    "put_25d_iv": 0.175563,
    "risk_reversal_25d": -0.052262,
    "butterfly_25d": 0.006471,
    "contracts_used": 77
   },
   "\u2026"
  ],
  "smiles": [
   {
    "expiration": "2026-10-23",
    "dte": 5,
    "points": [
     {
      "strike": 650.0,
      "iv": 0.647635,
      "delta": 0.0156,
      "right": "P"
     },
     {
      "strike": 655.0,
      "iv": 0.624855,
      "delta": 0.0168,
      "right": "P"
     },
     "\u2026"
    ]
   },
   "\u2026"
  ]
 },
 "meta": {
  "symbol": "SPY",
  "as_of": "2026-10-16T20:27:27.978Z",
  "spot": {
   "price": 762.7901
  },
  "expirations": {
   "mode": "term",
   "count": 9,
   "dates": [
    "2026-10-23",
    "2026-10-26",
    "2026-11-20",
    "\u2026"
   ]
  }
 }
}
```

### `GET /v1/symbols/{symbol}/intraday`

One-minute bars of the symbol's spot, the spot the levels use (`spot.price` on the levels): open, high, low and close, no
volume. For a past session of a stock or ETF the bars are its real one-minute bars, every session from 2023-10-30 (1.0.51);
an index, or a name without them, gets bars of its recorded spot (one price per minute before 2026-10-19).

A past `date` at `1m` is a finished-session read (1.0.57): a key in attestation grace may make it, as it may the `history/*` endpoints (Conventions, "OPRA attestation and grace"); today's bars (no `date`, or a sub-minute `resolution`) need live access.

Live: today's whole window of one-minute bars (1.0.57), from 04:00 New York for a stock or ETF (its pre-market) and from 09:30 for an index, VIX or a name without a stock quote (SPX, NDX, XSP, VIX; 1.0.56), through the current minute: one bar for each minute with a price, in order, and no bar for a minute without one (never made up). A symbol's live bars start with its first read of the day (1.0.38); a stock first read during the session also has the minutes before that, from its own trades, so its chart never starts empty; an index or VIX has none before its first read.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `resolution` | query | string | `1m` | (1.0.17) `1m` returns the live minute bars below. A sub-minute step (`1s`, `5s`, `15s`, `30s`) returns bars of that step for the session; a session recorded per minute answers the minute bars with `meta.resolution_requested` and a note (Conventions, "Resolution"). |
| `date` | query | date | today | A past session at `1m`: that session's bars, with `meta.session` (1.0.51); a session with no bars is `404 no_data`, never today's bars (1.0.57). With a sub-minute `resolution` (1.0.17): the session to answer at that step. Omitted: today's live bars. |
| `bars` | query | integer | all | (1.0.61) Today's live minute bars: only the newest N, `1` to `1440`. The stream's `bars` family reads `bars=2` (the current minute and the one before it); the day is this GET once. |

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `bars[].ts`, `ts_ms` | timestamp | ISO-8601 UTC; epoch milliseconds | Start of the bar (the minute, or the step). |
| `bars[].open`, `high`, `low`, `close` | number | USD (index points for an index and VIX) | |

**Window** (1.0.57). Without a date at `1m`, on a session day: every bar from the window's start to the current minute, the window starting at 04:00 New York for a stock or ETF and at 09:30 for a name priced from its own options, bars of today's date only; up to 960 bars by 20:00. A minute without a price is absent, not filled. With a past `date`, the session's bars (above).

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/intraday"
```

```json
{
 "data": {
  "bars": [
   {
    "ts": "2026-10-02T13:30:00.000Z",
    "ts_ms": 1790947800000,
    "open": 770.32,
    "high": 770.32,
    "low": 770.32,
    "close": 770.32
   },
   {
    "ts": "2026-10-02T13:31:00.000Z",
    "ts_ms": 1790947860000,
    "open": 769.57,
    "high": 769.57,
    "low": 769.47,
    "close": 769.47
   }
  ]
 },
 "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:31:59.250Z"}
}
```

### `GET /v1/tape`

**Coverage** (all trade endpoints: `/v1/tape`, `/v1/sweeps`, `/v1/trades`): the regular and late sessions. Cboe's overnight session for SPX and VIX options (global trading hours, 20:15 to 09:15 ET) is not served.

The latest prints (the window `since` sets), oldest first, up to `limit`; or, with `date`, any kept session's prints (1.0.10), the same rows.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `symbol` | query | string | all | Restrict to one symbol. |
| `since` | query | duration | `5m` | `30s` to `60m` (live only). |
| `date` | query | date | | A kept session within the plan's history reach (Tiers); `since` is ignored and `from`/`to` bound the window. |
| `from`, `to` | query | integer | the session | Epoch ms; `to` exclusive. |
| `limit` | query | integer | 500 | At most 2000; with `date`, the newest `limit` prints of the window, returned oldest first. |

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `prints[].ts`, `ts_ms` | timestamp | | Exchange timestamp of the print. |
| `prints[].symbol`, `expiration`, `strike`, `right` | | USD for strike | The contract. |
| `prints[].size` | number | contracts | |
| `prints[].price` | number | USD per share | |
| `prints[].premium` | number | USD | size × price × 100. |
| `prints[].exchange` | string | ISO 10383 MIC | `XCBO`, `XISX`, `MPRL`, ...; `null` if unknown. |
| `prints[].bid`, `ask` | number | USD per share | NBBO at the print; 0 when no quote. |
| `count`, `since` | | | Rows returned and the window start. |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/tape?symbol=SPY&since=1m&limit=3"
```

```json
{
 "data": {
  "prints": [
   {
    "ts": "2026-10-02T13:31:59.244Z",
    "ts_ms": 1790947919244,
    "symbol": "SPY",
    "expiration": "2026-10-02",
    "strike": 765.0,
    "right": "C",
    "size": 2,
    "price": 4.86,
    "premium": 972.0,
    "exchange": "XNDQ",
    "bid": 4.86,
    "ask": 4.88
   },
   {
    "ts": "2026-10-02T13:31:59.244Z",
    "ts_ms": 1790947919244,
    "symbol": "SPY",
    "expiration": "2026-10-05",
    "strike": 775.0,
    "right": "C",
    "size": 1,
    "price": 0.61,
    "premium": 61.0,
    "exchange": "EMLD",
    "bid": 0.61,
    "ask": 0.62
   },
   ...
  ],
  "count": 3,
  "since": "2026-10-02T13:30:59.252Z"
 },
 "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:31:59.252Z"}
}
```

### `GET /v1/sweeps`

Prints in one contract that hit `min_exchanges` or more distinct exchanges within `window_ms` of the first print, reported as one sweep, with its side: `buy` when every print classified as a buy, `sell` when every print classified as a sell, `mixed` otherwise.

With the default `window_ms` and `min_exchanges`, every sweep since `since` is returned, up to 240 minutes back. Other values are judged over the most recent prints only, so they cover only the last seconds to minutes.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `since` | query | duration | `15m` | `30s` to `240m` (live only). |
| `date`, `from`, `to`, `symbol` | query | | | A kept session's sweeps (1.0.10): the day's sweeps (one contract, 3 or more exchanges within 250 ms); `window_ms` and `min_exchanges` do not apply to a past day. |
| `window_ms` | query | integer | 250 | Window from the first print, 50 to 1,000,000. |
| `min_exchanges` | query | integer | 3 | 2 to 99. |
| `side` | query | string | all | `buy`, `sell` or `mixed`: only sweeps of that side. |

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `sweeps[].ts`, `ts_ms` | timestamp | | First print of the sweep. |
| `sweeps[].symbol`, `expiration`, `strike`, `right` | | | The contract. |
| `sweeps[].size` | number | contracts | Total. |
| `sweeps[].avg_price` | number | USD per share | Size-weighted. |
| `sweeps[].premium` | number | USD | Total. |
| `sweeps[].exchanges`, `trades` | integer | | Distinct exchanges and prints in the sweep. |
| `sweeps[].side` | string | | `buy`, `sell` or `mixed`. |
| `window_ms`, `min_exchanges`, `side`, `since`, `count` | | | Parameters applied and rows returned (`side` null when not filtered). |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/sweeps?since=5m"
```

```json
{
 "data": {
  "sweeps": [
   {
    "ts": "2026-10-02T13:31:59.242Z",
    "ts_ms": 1790947919243,
    "symbol": "IWM",
    "expiration": "2026-10-05",
    "strike": 281.0,
    "right": "P",
    "size": 29,
    "avg_price": 1.07,
    "premium": 3103.0,
    "exchanges": 7,
    "trades": 8,
    "side": "sell"
   },
   ...
  ],
  "count": 257,
  "since": "2026-10-02T13:26:59.253Z",
  "window_ms": 250,
  "min_exchanges": 3,
  "side": null
 },
 "meta": {"as_of": "2026-10-02T13:31:59.253Z"}
}
```

### `GET /v1/sessions`

The finished sessions this API can answer history and playback for: a session is listed once all of it is ready (every listed name's minutes, grids, flow, trades, playback and closing entry), normally the next morning, so a client never has to probe for a date. Today is never listed; today's data is on the live endpoints.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `from`, `to` | query | date | all | Bounds on the sessions listed, inclusive. |

The plan's history reach applies (Tiers): a key sees the sessions it may read (Basic the last 5, Trader 30 days, Developer one year, Quant everything).

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `sessions[].date` | date | | A finished session, oldest first. |
| `sessions[].names` | integer | | Names with levels recorded that day. |
| `sessions[].resolution` | string | | (1.0.17, coming) The session's native resolution: `1s` for sessions recorded every second, `1m` for sessions recorded per minute. A request finer than this is answered at this step (Conventions, "Resolution"). |
| `sessions[].holds` | object | | What the day holds: `levels`, `grids`, `flow`, `trades`, `playback`, `answers`, `closing`, each `true` (held), `false` (short) or `null` (not checked that day). |
| `count` | integer | | |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/sessions"
```

```json
{
 "data": {"sessions": [{"date": "2026-10-05"}, {"date": "2026-10-07"}, {"date": "2026-10-08"}, {"date": "2026-10-09"}], "count": 4},
 "meta": {"as_of": "2026-10-15T03:44:18.672Z"}
}
```

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/sessions?from=2026-10-07&to=2026-10-09"
```

```json
{
 "data": {
  "sessions": [
   {
    "date": "2026-10-07",
    "names": 3634,
    "holds": {
     "levels": true,
     "grids": true,
     "flow": true,
     "trades": null,
     "playback": true,
     "answers": true,
     "closing": true
    }
   },
   {
    "date": "2026-10-08",
    "names": 3818,
    "holds": {
     "levels": true,
     "grids": true,
     "flow": true,
     "trades": null,
     "playback": true,
     "answers": true,
     "closing": true
    }
   },
   {
    "date": "2026-10-09",
    "names": 3795,
    "holds": {
     "levels": true,
     "grids": true,
     "flow": true,
     "trades": null,
     "playback": true,
     "answers": true,
     "closing": true
    }
   }
  ],
  "count": 3
 },
 "meta": {
  "as_of": "2026-10-15T15:13:41.357Z"
 }
}
```

### `GET /v1/sessions/{date}`

(1.0.29) The names one finished session holds, by product, so a client learns whether a day holds a name without asking `history/levels` and reading 200 or 404. `levels` is every name with recorded levels that day (the whole listed market); `playback` the names with a Replay (`history/replay`); `closing` the names with a closing entry. Trades are kept per session, not per name: the row's `holds.trades` says whether the session's prints are kept, and then every root traded that day is there. The catalog row's `holds` is repeated so one call answers "is it listed, and what for".

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `date` | path | date | | A listed finished session (`GET /v1/sessions`); an unlisted day is `404`, a malformed one `422`. |

The tier's reach applies as for the other history endpoints: a session before the key's `history_from` is `403 history_out_of_reach`.

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `date` | date | | The session. |
| `holds` | object | | The catalog row's `holds` (levels, grids, flow, trades, playback, answers, closing). |
| `names.levels` | string[] | | Names with recorded levels, sorted. |
| `names.playback` | string[] | | Names with a Replay. |
| `names.closing` | string[] | | Names with a closing entry. |
| `counts` | object | | The length of each list. |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/sessions/2026-10-16"
```

```json
{
 "data": {
  "date": "2026-10-16",
  "holds": {"levels": true, "grids": true, "flow": true, "trades": true, "playback": true, "answers": true, "closing": true},
  "names": {
   "levels": ["A", "AA", "AAAU", "AADX", "AAL", "AAMI", "AAOG", "AAOI", "…", "ZVIA", "ZVRA", "ZWS", "ZYME"],
   "playback": ["AAPL", "AMD", "AMZN", "AVGO", "COIN", "DIA", "GDX", "GLD", "GOOGL", "HOOD", "IBIT", "IWM", "META", "MSFT", "MU", "NDX", "NVDA", "PLTR", "QQQ", "SLV", "SPX", "SPY", "TSLA", "VIX"],
   "closing": ["AAPL", "AMD", "AMZN", "AVGO", "COIN", "DIA", "GDX", "GLD", "GOOGL", "HOOD", "IBIT", "IWM", "META", "MSFT", "MU", "NDX", "NVDA", "PLTR", "QQQ", "SLV", "SPX", "SPY", "TSLA", "VIX"]
  },
  "counts": {"levels": 4830, "playback": 24, "closing": 24}
 },
 "meta": {"as_of": "2026-10-20T06:58:41Z"}
}
```

(The `levels` list is elided here; the served answer carries every name.)

### `GET /v1/symbols/{symbol}/history/replay`

Playback of a finished session: with `part=levels`, one record per session minute with spot, the key levels, the two largest walls on each side and the whole net-GEX strike profile; with `part=surfaces`, the same per-minute rows plus the closing state of the session, the per-strike aggregate and the totals at 16:00 ET, which is what a replay scrubber needs to draw the last frame.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `date` | query | date | required | A finished session (before today). Today is `422`: it is not finished. |
| `from`, `to` | query | date | | A range of finished sessions instead of `date` (Conventions, "Ranges"), `part=levels` only: the per-minute records of every finished session from `from` to `to`, at most 31, in one answer (about 1.5 MB per session before compression). A session not ready yet is listed in `queued` and asked for; when none is ready the answer is `404 not_built` with `ready_by` for the last of them (Sessions not ready yet). |
| `part` | query | string | `levels` | `levels` or `surfaces` (`surfaces` is one session per call). |
| `expirations` | query | integer | 1 | Nearest N expirations the answer was computed over, 1 to 50. Depth 1 is always ready; another depth is prepared on its first request and answers `404 not_built` until it is ready, with `ready_by` saying when (Sessions not ready yet). |
| `resolution` | query | string | `1m` | (1.0.17) `part=levels`: the step between rows as a named resolution (`1s` … `30m`). A sub-minute step answers rows that carry `as_of`, `spot`, `key_levels`, `totals` and `carried` (the strike profile and walls are per minute); a second in which a name did not change repeats the last real row with its `data_as_of`. A session recorded per minute, or a depth not recorded every second, answers the minute rows with `meta.resolution_requested` and a `note`. See Conventions, "Resolution". |

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `date` | date | | |
| `rows[].as_of` | timestamp | | The moment of the state the row shows, UTC: the `as_of` of the same row on `history/levels` (1.0.63; before, the minute at :00). A chart shows a row from the first candle that opens at or after it. |
| `rows[].spot` | number | USD | Spot at that minute. |
| `rows[].key_levels` | object | USD | `gamma_flip`, `call_wall`, `put_wall`, `max_pain`, `vol_trigger`, `momentum_wall`, as on `/levels`. |
| `rows[].major_wall` | number | USD | The strike with the largest absolute net GEX at that minute. |
| `rows[].walls.calls[]`, `rows[].walls.puts[]` | array | | `{"strike": USD, "gex": USD per 1% move}`: the largest positive (calls) and negative (puts) net GEX strikes, up to three each. |
| `rows[].profile[]` | array | | `{"strike": USD, "net_gex": USD per 1% move}`: net dealer GEX at every strike with exposure, ascending by strike. |
| `count` | integer | | Rows, normally 390 or 391 per session. |
| `from`, `to`, `sessions[]`, `queued[]` | date | | Range answer: the bounds asked for, the sessions the rows come from (oldest first) and the sessions in the range not ready yet. |
| `closing.totals` | object | | `part=surfaces` only: the session's closing totals, the same fields and units as `totals` on `/levels` (`net_gex`, `call_gex`, `put_gex`, `net_volume_gex`, `gex_ratio`, `delta_exposure`, `vanna_exposure`, `charm_exposure`, `atm_iv`). |
| `closing.strikes[]` | array | | `part=surfaces` only: the per-strike aggregate at the close, the same row shape and units as `/strikes`. |

`meta.expirations` repeats the depth answered.

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/history/replay?date=2026-10-27"
```

```json
{
 "data": {
  "date": "2026-10-27",
  "rows": [
   {
    "as_of": "2026-10-27T13:30:59.260Z",
    "spot": 767.375,
    "key_levels": {"gamma_flip": 767.6275, "call_wall": 770.0, "put_wall": 765.0, "max_pain": 765.0, "vol_trigger": 767.0, "momentum_wall": 770.0},
    "major_wall": 767.0,
    "walls": {
     "calls": [{"strike": 768.0, "gex": 413068000}, {"strike": 770.0, "gex": 386006000}],
     "puts": [{"strike": 767.0, "gex": -832521000}, {"strike": 762.0, "gex": -397453000}]
    },
    "profile": [{"strike": 744.0, "net_gex": -2000}, {"strike": 745.0, "net_gex": -1764000}, ...]
   },
   {"as_of": "2026-10-27T13:31:59.694Z", ...},
   ...
  ],
  "count": 390
 },
 "meta": {"symbol": "SPY", "date": "2026-10-27", "expirations": 1, "as_of": "2026-10-28T17:03:37.150Z"}
}
```

With `part=surfaces`, the last row is the 16:00 minute and `closing` follows:

```json
{
 "data": {
  "date": "2026-10-09",
  "rows": [..., {"as_of": "2026-10-09T20:00:00.000Z", "spot": 778.51, "key_levels": {"gamma_flip": 775.7953, "call_wall": 779.0, "put_wall": 774.0, "max_pain": 773.0, "vol_trigger": 778.0, "momentum_wall": 779.0}, "major_wall": 778.0, "walls": {...}, "profile": [...]}],
  "count": 391,
  "closing": {
   "totals": {"net_gex": 9316778304, "call_gex": 13754544821, "put_gex": -4437766517, "net_volume_gex": 191062755676, "gex_ratio": 0.7561, "delta_exposure": 13763299, "vanna_exposure": -570, "charm_exposure": 780485675, "atm_iv": 0.9497},
   "strikes": [{"strike": 745.0, "gex": {"net": 0, "call": 0, "put": 0, "abs": 0, "net_volume": 0, "call_volume": 0, "put_volume": 0}, "exposures": {"delta": 170000, "vanna": 0, "charm": -6}, "open_interest": {"call": 1700, "put": 51076, "total": 52776}, "volume": {"call": 26, "put": 369}, "iv": 0.6119, "moneyness": -0.043}, ...]
  }
 },
 "meta": {"symbol": "SPY", "date": "2026-10-09", "expirations": 1, "as_of": "2026-10-15T03:49:05.542Z"}
}
```

`atm_iv` in `closing.totals` is the at-the-money volatility of the expirations answered at 16:00 ET; with `expirations=1` on a day with a same-day expiration that is the 0DTE's closing volatility, which is why it is high.

**Errors**: `422` for a date that is not a finished session or an unknown `part`; `404 not_built` when that session or depth is not ready yet (the request asks for it; `ready_by` says when it is expected, `queue_position` how many requests are ahead counting this one, and `Retry-After` how long to wait: Sessions not ready yet); `403` with `X-History-Earliest` past the tier's depth.

Over a range of sessions (`part=levels`), the records of every session in order; this answer is 4.8 MB uncompressed for three sessions:

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/history/replay?from=2026-10-07&to=2026-10-09"
```

```json
{
 "data": {
  "from": "2026-10-07",
  "to": "2026-10-09",
  "sessions": [
   "2026-10-07",
   "2026-10-08",
   "2026-10-09"
   ... 1172 more rows
  ],
  "queued": [],
  "rows": [
   {
    "as_of": "2026-10-07T13:30:00Z",
    "spot": 775.895,
    "key_levels": {
     "gamma_flip": 779.2572,
     "call_wall": 785.0,
     "put_wall": 767.0,
     "max_pain": 775.0,
     "vol_trigger": 775.0,
     "momentum_wall": 775.0
    },
    "major_wall": 775.0,
    "walls": {
     "calls": [
      {
       "strike": 781.0,
       "gex": 180904000
      },
      {
       "strike": 780.0,
       "gex": 133753000
      },
      {
       "strike": 782.0,
       "gex": 89832000
      }
     ],
     "puts": [
      {
       "strike": 775.0,
       "gex": -377638000
      },
      {
       "strike": 773.0,
       "gex": -342847000
      },
      {
       "strike": 774.0,
       "gex": -321346000
      }
     ]
    },
    "profile": [
     {
      "strike": 705.0,
      "net_gex": 0
     },
     {
      "strike": 710.0,
      "net_gex": 0
     },
     "... 98 more strikes"
    ]
   }
  ],
  "count": 1173
 },
 "meta": {
  "symbol": "SPY",
  "from": "2026-10-07",
  "to": "2026-10-09",
  "expirations": 1,
  "as_of": "2026-10-15T14:16:51.314Z"
 }
}
```

### `GET /v1/symbols/{symbol}/history/chain`

The chain as it stood every interval of a finished session: every listed contract of the symbol with its quote, implied volatility and Greeks at that moment, as newline-delimited JSON. This is the file a backtest replays. Gzip when accepted; about 13 MB compressed for SPY at 5 minutes.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `date` | query | date | required | A finished session (before today). |
| `interval` | query | string | `5m` | `1m`, `5m`, `10m`, `15m`, `30m`. `5m` is ready for every listed session; another interval answers `404 not_built` on its first request, with `ready_by` saying when it is expected (Sessions not ready yet). |

**Returns** `application/x-ndjson`: a manifest line, then one snapshot line per interval, 09:30 to 16:00 ET (79 at 5 minutes).

| Line / field | Type | Unit | Description |
|---|---|---|---|
| manifest: `type` | string | | `manifest`. |
| manifest: `dataset`, `ticker`, `session_date`, `interval` | string | | Dataset label, symbol, session as `YYYYMMDD`, interval. |
| snapshot: `type` | string | | `snapshot`. |
| snapshot: `timestamp` | timestamp | | The interval's moment, UTC; `time_et` the same as `HH:MM` New York. |
| snapshot: `contracts[].expiration` | date | | |
| snapshot: `contracts[].strike` | number | USD | |
| snapshot: `contracts[].right` | string | | `call` or `put`. |
| snapshot: `contracts[].bid`, `ask` | number | USD per share | NBBO at the moment; `0.0` when one side is empty. |
| snapshot: `contracts[].implied_vol` | number | fraction | |
| snapshot: `contracts[].delta`, `gamma`, `theta`, `vega`, `rho`, `vanna`, `charm`, `vomma`, `speed`, `zomma`, `color` | number | raw Greeks (Units table) | Per contract, as in the Units table. |
| snapshot: `contracts[].underlying_price` | number | USD | Spot at the moment. |
| snapshot: `contracts[].open_interest` | number | contracts | The day's open interest. |
| snapshot: `contracts[].interval_volume` | number | contracts | Volume traded in this interval, not cumulative. |

**Example**

```
curl -H "Authorization: Bearer $KEY" -H "Accept-Encoding: gzip" "https://api.datafirsttrading.com/v1/symbols/SPY/history/chain?date=2026-10-09" | gunzip | head -2
```

```json
{"type": "manifest", "dataset": "…", "ticker": "SPY", "session_date": "20261009", "interval": "5m"}
{"type": "snapshot", "timestamp": "2026-10-09T13:30:00Z", "time_et": "09:30", "contracts": [{"expiration": "2026-10-09", "strike": 763.0, "right": "call", "bid": 0.0, "ask": 0.0, "implied_vol": 0.2032, "delta": 1.0, "gamma": 0.0, "theta": -22.5652, "vega": 0.0, "rho": 0.5661, "vanna": -0.0, "charm": -0.0129, "vomma": 0.0001, "speed": -0.0, "zomma": 0.0, "color": 0.0001, "underlying_price": 787.915, "open_interest": 1779.0, "interval_volume": 0.0}, ...1,147 more contracts]}
```

Headers: `ETag` and `Cache-Control: private, max-age=300, must-revalidate` (Conventions, "Caching and revisions", 1.0.52), `X-Session: 2026-10-09`, `Content-Encoding: gzip` when accepted.

**Errors**: `422` for a date that is not a finished session or an unknown interval; `404 not_built` when that session or interval is not ready yet (the request asks for it: `ready_by`, `queue_position` and `Retry-After`, Sessions not ready yet); `404 not_available` for a session before the earliest day a chain file can be built (nothing is asked for); `403` with `X-History-Earliest` past the tier's depth.

### `GET /v1/symbols/{symbol}/history/iv`

Daily 30-day at-the-money implied volatility and 25-delta skew, one row per session, at the close.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `days` | query | integer | 252 | Most recent sessions to return, 1 to 365. |
| `from`, `to` | query | date | | Bounds on the session date, inclusive; `days` then applies within them. |

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `rows[].date` | date | | Session. |
| `rows[].atm_iv` | number | fraction | At-the-money implied volatility of the expiration nearest 30 calendar days, at the close. |
| `rows[].skew_25d` | number | fraction | 25-delta put IV minus 25-delta call IV on that expiration; positive when puts are richer. |
| `rows[].spot` | number | USD | Close. |
| `rows[].dte` | integer | days | Calendar days to the expiration used. |
| `count` | integer | | |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/history/iv?days=3"
```

```json
{
 "data": {
  "rows": [
   {"date": "2026-10-12", "atm_iv": 0.1266, "skew_25d": 0.0239, "spot": 778.045, "dte": 32},
   {"date": "2026-10-13", "atm_iv": 0.1262, "skew_25d": 0.0252, "spot": 777.95, "dte": 31},
   {"date": "2026-10-14", "atm_iv": 0.1373, "skew_25d": 0.0306, "spot": 768.98, "dte": 30}
  ],
  "count": 3
 },
 "meta": {"symbol": "SPY", "as_of": "2026-10-15T03:44:18.641Z"}
}
```

### `GET /v1/symbols/{symbol}/history/oi`

Open interest per contract for one session: the open interest published for that session's morning (positions at the previous close), one row per listed contract of the symbol, with the contracts traded during the session beside it. Available for every kept session and for today.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `date` | query | date | today | A kept session. A session not kept is `404` with the kept sessions in `detail`. |
| `expiration` | query | date | all | One expiration. |

**Returns**

| Field | Type | Unit | Description |
|---|---|---|---|
| `symbol`, `date` | | | |
| `rows[].osi` | string | | The contract's OSI symbol. |
| `rows[].expiration` | date | | |
| `rows[].strike` | number | USD | |
| `rows[].right` | string | | `C` or `P`. |
| `rows[].open_interest` | integer | contracts | As published for the session's morning. |
| `rows[].volume` | integer | contracts | Traded during the session (the whole session for a finished one, so far for today). |
| `count` | integer | | Rows, ordered by expiration, strike, calls before puts. Contracts without an open-interest record are left out. |
| `total_open_interest` | integer | contracts | Sum over the rows. |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPY/history/oi?date=2026-10-09&expiration=2026-10-09"
```

```json
{
 "data": {
  "symbol": "SPY",
  "date": "2026-10-09",
  "rows": [
   {
    "osi": "SPY261009C00500000",
    "expiration": "2026-10-09",
    "strike": 500.0,
    "right": "C",
    "open_interest": 62,
    "volume": 567
   },
   {
    "osi": "SPY261009P00500000",
    "expiration": "2026-10-09",
    "strike": 500.0,
    "right": "P",
    "open_interest": 633,
    "volume": 0
   },
   {
    "osi": "SPY261009C00505000",
    "expiration": "2026-10-09",
    "strike": 505.0,
    "right": "C",
    "open_interest": 15,
    "volume": 35
   }
   ... 363 more
  ],
  "count": 366,
  "total_open_interest": 1511387
 },
 "meta": {
  "date": "2026-10-09",
  "prints": 11445269,
  "orders": 1736200,
  "as_of": "2026-10-09T20:59:58.800Z",
  "as_of_ms": 1791579598800
 }
}
```

### `GET /v1/trades` and `GET /v1/trades/stats`

Every option print of the session (and of every kept session): each print with the NBBO at the print, the side it hit and its sign, or the orders built over the prints (sweeps, splits, blocks). Market-wide unless filtered.

**Parameters**

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `date` | query | date | today | Session to read; a session not kept is `404`. |
| `from`, `to` | query | integer | | Window, epoch ms; `to` exclusive. |
| `tickers`, `osi` | query | list | | Comma-separated. |
| `types` | query | list | | `C`, `P`. |
| `money` | query | list | | `ITM`, `ATM`, `OTM` (within 0.5 % of spot is ATM). |
| `strikes`, `strike_min`, `strike_max` | query | | | USD. |
| `expirations`, `exp_from`, `exp_to` | query | date | | `YYYY-MM-DD`. |
| `sides` | query | list | | `ABOVE_ASK`, `ASK`, `MID_MARKET`, `BID`, `BELOW_BID`. `MID_MARKET` is strictly inside the spread; a print exactly at the mid is `NEUTRAL`. |
| `sentiments` | query | list | | `BULLISH`, `BEARISH`, `NEUTRAL`. |
| `exchanges` | query | list | | MIC codes (`XCBO`, `XISX`, `MPRL`, …). |
| `order_types` | query | list | | `SWEEP`, `SPLIT`, `BLOCK` (the legacy name `consolidation` is accepted). |
| `premium_min`, `premium_max`, `size_min`, `size_max`, `volume_min`, `volume_max`, `oi_min`, `oi_max`, `dte_min`, `dte_max`, `spot_min`, `spot_max` | query | number | | Ranges, inclusive. |
| `sectors`, `industries` | query | list | | Comma-separated sector or industry names as `/v1/symbols/{symbol}/reference` reports them. |
| `golden`, `unusual`, `vol_gt_oi`, `index` | query | 0/1 | | Flags. |
| `view` | query | string | `prints` | `prints` (one row per print) or `orders` (sweeps, splits, blocks). |
| `sort`, `dir` | query | string | `trade_time`, `desc` | `trade_time`, `premium`, `size`, `volume`, `open_interest`, `price`, `strike`, `expiration`, `dte`, `spot`, `bid`, `ask`, `spread`. |
| `limit`, `after` | query | | 50 | `/v1/trades`: 1 to 1000; `after` is the previous page's `next`, joined by a comma. (`/v1/trades/stats`: see `sort`, `dir`, `limit` below.) |
| `stats` | query | 0/1 | | Totals by right and side alongside the rows. |
| `comprising` | query | 0/1 | | Orders: include their prints. |
| `iv` | query | 0/1 | 0 | `/v1/trades/stats` only: `1` adds `iv` per group, the mean implied volatility of the group's prints (fraction). |
| `by`, `period` | query | | | `/v1/trades/stats` only: group by any of `type`, `side`, `ticker`, `exchange`, `class` (`EQUITY_CALL`, `EQUITY_PUT`, `INDEX`), `expiration`, `strike`, `contract`, `sentiment`, `bucket` (`period` ms, default 60000). |
| `sort`, `dir`, `limit`, `offset` | query | | by the group columns, `desc`, 20000, 0 | `/v1/trades/stats` only: `sort` orders the groups by `premium`, `volume`, `trades`, `net_premium` or `iv` (`iv` needs `iv=1`); `limit` (1 to 20,000) keeps that many groups of that order, starting `offset` groups in (an integer, 0 or more). `groups_total` is how many groups matched and `truncated` whether more follow this page. To read every group, ask `offset=0` and then each answer's `next_offset` until it is `null` (1.0.34). |

**Returns** (`/v1/trades`)

| Field | Type | Unit | Description |
|---|---|---|---|
| `trades[].id` | string | | Stable within the session. |
| `trades[].ticker`, `osi`, `sector`, `industry` | string | | |
| `trades[].right` | string | | `C` or `P`. |
| `trades[].expiration` | date | | |
| `trades[].dte` | number | calendar days | At the print. |
| `trades[].strike` | number | USD | |
| `trades[].exchange` | string | | ISO 10383 MIC of the exchange; `null` when unknown. |
| `trades[].premium` | number | USD | size × price × 100 (an order: its prints summed). |
| `trades[].size`, `volume`, `open_interest` | integer | contracts | `volume` is the contract's volume so far that day, this print included. |
| `trades[].price`, `bid`, `ask`, `spread` | number | USD per share | The price (an order's size-weighted average) and the NBBO at the print. |
| `trades[].spot` | number | USD | Spot at that moment. |
| `trades[].iv` | number | fraction | Implied volatility of the print's price; `null` when there is none. |
| `trades[].side` | string | | Where the print hit the quote: `ABOVE_ASK`, `ASK`, `MID_MARKET`, `BID`, `BELOW_BID`; `null` without a quote. |
| `trades[].sentiment` | string | | `BULLISH` (a call bought or a put sold), `BEARISH` (the reverse), `NEUTRAL` (unclassified). |
| `trades[].order_type` | string | | Orders: `SWEEP`, `SPLIT`, `BLOCK`; prints: `null`. |
| `trades[].golden_sweep`, `unusual`, `index`, `volume_over_oi` | boolean | | Golden: a sweep of at least $1M, every print at or above the ask. Unusual: premium of at least $25,000 and the contract's volume so far above its open interest. |
| `trades[].greeks` | object | raw Greeks (Units table) | At the print's IV; `sigma` is that IV. |
| `trades[].moneyness` | object | | `distance` (spot − strike, USD), `ratio` (spot / strike − 1, fraction), `type` (`ITM`, `ATM`, `OTM`). |
| `trades[].trade_time`, `trade_time_ms` | timestamp, integer | | The print's moment, UTC; epoch ms twin. |
| `trades[].prints[]` | array | | With `comprising=1` on orders: `id`, `exchange`, `size`, `price`, `side`, `trade_time`, `trade_time_ms`. |
| `count` | integer | | Rows matching before paging. |
| `next` | array | | `[sort value, id]` for the next page, or `null`. |
| `statistics` | object | | With `stats=1`: by right (`C`, `P`) and side: `premium`, `volume`, `count`. |

**Returns** (`/v1/trades/stats`): `groups[]`, one per combination of `by`, each carrying its dimension values (`right` for `type`, `bucket` as RFC 3339 with `bucket_ms`) and `premium` (USD), `trades`, `volume` (contracts), `bullish_premium`, `bearish_premium`, `net_premium` (bought minus sold, USD), `net_volume` (contracts); with `bucket` and one contract (`contract` in `by`, or `osi`) also `open`, `high`, `low`, `close` (USD per share); `count` is the prints grouped; `groups_total` the groups that matched and `truncated` whether more groups follow this page; with `offset`, `next_offset` is the offset of the next page, `null` on the last.

`meta` carries `date`, `prints`, `orders`, `as_of` and `as_of_ms` (the newest print's moment).

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/trades?date=2026-10-14&tickers=SPY&view=prints&limit=2"
```

```json
{
 "data": {
  "trades": [
   {"id": "1099514871224", "ticker": "SPY", "osi": "SPY261014P00767000", "sector": "NOT_APPLICABLE", "industry": "NOT_APPLICABLE",
    "right": "P", "expiration": "2026-10-14", "dte": -0.0104, "strike": 767.0, "exchange": "EMLD", "premium": 10.0, "size": 2, "volume": 658572,
    "open_interest": 3837, "price": 0.05, "ask": 0.05, "bid": 0.04, "spread": 0.01, "spot": 769.54, "iv": null,
    "side": "ASK", "sentiment": "BEARISH", "order_type": null, "golden_sweep": false, "unusual": false, "index": false, "volume_over_oi": true,
    "greeks": {"delta": null, "gamma": null, "theta": null, "vega": null, "rho": null, "charm": null, "vanna": null, "vomma": null, "zomma": null, "speed": null, "color": null, "omega": null, "veta": null, "ultima": null, "sigma": null},
    "moneyness": {"distance": 2.54, "ratio": 0.0033, "type": "OTM"},
    "trade_time": "2026-10-14T19:59:59.969Z", "trade_time_ms": 1792008899969},
   ...
  ],
  "count": 658572,
  "next": ["1792008899929.0", "1099514871223"]
 },
 "meta": {"date": "2026-10-14", "prints": 9966337, "orders": 1476963, "as_of": "2026-10-14T20:14:59.912Z", "as_of_ms": 1792009799912}
}
```

**Example** (`/v1/trades/stats`, the second page of SPY's expirations; `offset=4` asks for the third)

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/trades/stats?date=2026-10-20&tickers=SPY&by=expiration&limit=2&offset=2"
```

```json
{
 "data": {
  "groups": [
   {"expiration": "2026-10-22", "premium": 53340177.0, "trades": 46820, "volume": 216795, "bullish_premium": 29655294.0, "bearish_premium": 20494289.0,
    "net_premium": 9519331.0, "net_volume": 2419, "spot": 765.825},
   {"expiration": "2026-10-23", "premium": 118380193.0, "trades": 76608, "volume": 488937, "bullish_premium": 61154219.0, "bearish_premium": 50323802.0,
    "net_premium": 10184719.0, "net_volume": 21184, "spot": 765.825}
  ],
  "count": 1632326, "groups_total": 33, "truncated": true, "next_offset": 4
 },
 "meta": {"date": "2026-10-20", "prints": 10307389, "orders": 1577095, "as_of": "2026-10-20T20:59:59.968Z", "as_of_ms": 1792529999968}
}
```

**Orders** (rules subject to change): `SWEEP` = one contract, at least 3 exchanges within 250 ms (the `/v1/sweeps` rule); `SPLIT` = of the rest, at least 2 prints within 250 ms; `BLOCK` = a single remaining print of at least 100 contracts.

**Depth and tier**: the plan's history reach (Free the current session, Basic 5 sessions, Trader 30 days, Developer 365 days, Quant everything kept). **Errors**: `404` for a session not kept, `400` problem for an unknown filter value or sort, or a `limit` or `offset` out of range.

### `GET /v1/export`, `GET /v1/export/{date}` and `GET /v1/export/{date}/{dataset}` (Quant)

Every finished output of a session, as columnar files you can take away whole, available after the close (from about 18:00 ET) and kept. One request per file; files are served with `Content-Disposition`, `X-Checksum-SHA256` and `X-Session`.

| Dataset | One row per | Columns | Typical size |
|---|---|---|---|
| `levels` | ticker-minute | `ts_ms`, `data_as_of_ms`, `ticker`, `chain_root`, `spot`, `max_exp`, `call_wall`, `put_wall`, `gamma_flip`, `max_pain`, `vol_trigger`, `mom_wall`, `expiries` (JSON), `per_expiry` (JSON: net GEX, GEX at the walls, contracts, per expiration) | 10–30 MB |
| `interval_map` | ticker-minute-expiration-strike | `ticker`, `t_ms`, `spot`, `expiration`, `strike`, `call_gex`, `put_gex`, `call_dex`, `put_dex`, `call_vex`, `put_vex`, `call_cex`, `put_cex`, in the file's own units, which differ by column: `call_gex`/`put_gex` USD billions per 1% move of spot; `call_dex`/`put_dex` shares; `call_vex`/`put_vex` millions of shares per 1.00 of volatility; `call_cex`/`put_cex` millions of shares per year (float32; cells whose every value is below 1e-6 are omitted) | 1–3 GB |
| `netflow` | ticker-sample (the points of `history/flow`) | `tk`, `t` (epoch ms), `callPrem`, `putPrem`, `spot`, the signed premium and volume series by leg and moneyness | 5–20 MB |
| `trades` | print | `ts_ns`, `id`, `osi`, `ticker`, `root`, `contract_type`, `expiration`, `strike`, `open_interest`, `price`, `size`, `bid`, `ask`, `exchange`, `side`, `sign`, `volume_so_far`, `stock_price`, `order_id` | 50–300 MB |
| `orders` | sweep, split or block | `order_id`, `type`, `ts_ns`, the contract columns, `size`, `premium`, `exchanges`, `golden`, `sign`, `first_print`, `last_print` | 1–10 MB |

All five datasets are part of the export. `trades` and `orders` carry OPRA market data and, like `/v1/trades`, are provided under DataFirst's OPRA vendor agreement to accounts with a current subscriber attestation; `levels`, `interval_map` and `netflow` are DataFirst's own computed series. A manifest answer:

```json
{"data": {"session": "2026-10-09", "built_at": "2026-10-09T22:04:11Z",
          "units": {"exposure": "USD billions per 1% move of spot", "premium": "USD", "price": "USD", "size": "contracts",
                    "ts_ms": "epoch milliseconds UTC", "ts_ns": "epoch nanoseconds UTC"},
          "datasets": {"levels": {"file": "levels.parquet", "rows": 962459, "bytes": 6860635, "sha256": "…", "opra": false,
                                  "description": "Key levels per ticker per minute…", "columns": [{"name": "ts_ms", "type": "int64"}, "…"],
                                  "url": "/v1/export/2026-10-09/levels"}, "…": {}}},
 "meta": {"as_of": "2026-10-11T14:06:00Z"}}
```

**Quality.** Every session is checked for completeness and consistency before it is listed; a session that fails the checks is not listed. The manifest carries `reconstructed` (`true` for a session rebuilt from one-minute quotes rather than recorded live; its levels match a live-recorded session's and its exposure totals are within about 20%), and the session list shows it beside each date.

**Listing sessions.** `GET /v1/export`:

```json
{"data": {"sessions": [{"date": "2026-10-08", "datasets": ["interval_map", "levels", "netflow"], "bytes": 1498231776, "built_at": "2026-10-11T14:21:40Z", "reconstructed": false},
                       {"date": "2026-10-09", "datasets": ["interval_map", "levels", "netflow", "orders", "trades"], "bytes": 1711004313, "built_at": "2026-10-11T14:33:02Z", "reconstructed": false}],
          "count": 2},
 "meta": {"as_of": "2026-10-11T15:02:11Z"}}
```

**Downloading a file.** Downloads are paced (the rate is in `X-Bulk-Rate-MBps`), with one file download in flight per key (the `export` budget of "Heavy reads in flight"; `/v1/me` reports it as `limits.in_flight.export`) and a limit across keys at busy times; over either you get `429` with `Retry-After`. `GET /v1/export/2026-10-09/levels` returns the Parquet bytes with:

```
HTTP/1.1 200 OK
Content-Type: application/vnd.apache.parquet
Content-Disposition: attachment; filename="2026-10-09-levels.parquet"
Content-Length: 6860635
X-Checksum-SHA256: 4f1c…e9a2
X-Session: 2026-10-09
X-Bulk-Rate-MBps: 32
```

```bash
curl -H "Authorization: Bearer $KEY" -o 2026-10-09-levels.parquet "$BASE/v1/export/2026-10-09/levels"
python -c "import pyarrow.parquet as pq; t = pq.read_table('2026-10-09-levels.parquet'); print(t.num_rows, t.schema.names)"
```

A key whose plan has no export (every plan but Quant) is answered `403 export not in tier`; a session with no files, `404 no export for that session`; a dataset name the manifest lacks, `404 no such dataset`. `trades` or `orders` for an account without a current OPRA attestation: `403 attestation_required` (see Errors).

### `GET /v1/me`

Your key as the API sees it. No parameters. Useful at start-up to learn the rate you may send at and the oldest session you may ask for. Since 1.0.43 it also carries `plan`, `mode` and `usage`, the same as `GET /v1/status`. Answered for any valid key that is not revoked, whether or not its account is attested (1.0.39): it carries no market data, and it says why live data is refused (`status`, `grace_until`). A revoked key is `403 key_revoked` here too; an unknown key, `401`.

| Field | Type | Meaning |
|---|---|---|
| `name` | string | The key's label. |
| `tier` | string | `free`, `basic`, `trader`, `developer` or `quant` (the Tiers section); DataFirst's own keys carry `admin`. |
| `access` | string[] | Where the key may be used: `["site", "api"]` for every key since 1.0.56 (every plan reaches the API at its own limits). |
| `rate`, `burst` | number | Sustained requests per second and the burst allowance (the same numbers as the `RateLimit-*` headers). |
| `status` | string \| null | The account's OPRA attestation status (`non_professional`, `professional`); `null` when not attested. |
| `grace_until` | string \| null | (1.0.39) RFC 3339 UTC. While `status` is `null`: support's grace, finished sessions only until this time (Conventions, "OPRA attestation and grace"); `null` when none was given. |
| `plan_until` | string \| null | (1.0.44) RFC 3339 UTC. When the plan ends, as `GET /v1/status` carries it; `null` = open-ended (Conventions, "Plan end"). |
| `history_from` | string \| null | The first session date this key may request (`YYYY-MM-DD`); `null` when the key reaches everything kept. |
| `export` | boolean | Whether `/v1/export` is open to this key (Quant). |
| `symbols` | string | (1.0.27; 1.0.61 shape) `"all"`: every symbol; `"limited"`: the plan reaches some symbols, and a symbol outside them is `403 symbol_not_in_tier` ("your plan doesn't include this symbol"). |
| `addons` | string[] | (1.0.58) The platform add-ons the account holds now, in catalog order, as codes: `ninjatrader` (NinjaTrader 8), `motivewave` (MotiveWave), `atas` (ATAS), `sierrachart` (Sierra Chart), `quantower` (Quantower), the desktop platforms; `tradesea` (TradeSea), `alphatrader` (AlphaTrader), `topstepx` (TopstepX), the extensions on a prop platform. Each unlocks that platform's DataFirst indicator; TradingView comes with every plan and has no code. A Lifetime, Academy or permanent-comp account holds all eight. An add-on whose term ended or that was revoked is not listed; `[]` when none. The add-on belongs to the account, so every key of the account answers the same. |
| `multi_indicator` | boolean | (1.0.58) Whether the account holds the more-than-one-chart-or-device licence for its indicators. A licence, not a platform: never in `addons`. |
| `notices` | string[] | Announcements for API users (deprecations, `/v2` availability); empty today. |
| `limits` | object | (1.0.23) Your numbers as data: `rate`, `burst`, `history_from`, (1.0.27) `per_day`, `used_today` and `quota_resets_at` (`null` on a tier without a quota), `stream_symbols_per_connection`, `stream_connections` and `chat_per_day` (1.0.57: the plan's stream, symbols per connection and connections open at once, `0` = no stream on this plan, `null` = no limit; and Ask Marlon's questions a day, `null` = unlimited), `in_flight` (`chain`, `range`, `export` reads at once), `address_rate` and `address_window_s` (the per-address ceiling your key gets: the larger of a shared floor and its plan rate, 1.0.52), `time_limit_s` (`live`, `history`, `streaming` null = by progress), `retry` (`attempts`: how many tries a retrying client makes). |

```json
{"data": {"name": "…", "tier": "trader", "access": ["site", "api"], "rate": 2.0, "burst": 10.0, "status": null,
          "grace_until": "2026-10-24T08:19:48Z", "history_from": "2026-09-24", "export": false, "symbols": "all",
          "addons": [], "multi_indicator": false,
          "limits": {"rate": 2.0, "burst": 10.0, "history_from": "2026-09-24", "per_day": 1000, "used_today": 0, "quota_resets_at": "2026-10-25T03:59:59Z",
                     "stream_symbols_per_connection": 25, "stream_connections": 2, "chat_per_day": 30, "in_flight": {…}, "address_rate": …, "address_window_s": …,
                     "time_limit_s": {…}, "retry": {…}},
          "notices": []},
 "meta": {"as_of": "2026-10-24T07:20:23Z"}}
```

For a Developer key on 2026-10-11, `history_from` is `2025-10-11` and `export` is `false`; for Trader, `2026-09-11` (30 calendar days); for Basic, `2026-10-07` (today and the four sessions before it); for Free, `2026-10-11`. A Trader key that has made 250 calls at 14:00 New York reports `"per_day": 1000, "used_today": 250, "quota_resets_at": "2026-10-12T04:00:00Z"`. An account that bought the NinjaTrader and TradeSea add-ons answers `"addons": ["ninjatrader", "tradesea"]`; a Lifetime account, `"addons": ["ninjatrader", "motivewave", "atas", "sierrachart", "quantower", "tradesea", "alphatrader", "topstepx"]`; an add-on bought on the site shows here within 30 seconds.

### `GET /v1/status`

Your plan, mode and usage in one read (1.0.43). The same object the hosted MCP's account line, the site's header badge and the chat's panel show, so every surface says the same thing. **Free:** never counted against the day quota, never rate-limited by the key (the address limit still applies), no `RateLimit-*` headers on it, `Cache-Control: no-store`. **Polling:** the answer carries an `ETag` of its content; send it back as `If-None-Match` and an unchanged status answers `304` with no body, so a 30-second poll costs nothing while nothing moves. `meta.as_of` is when the count was read. Answered for any valid key that is not revoked, attested or not, like `/v1/me`.

| Field | Type | Meaning |
|---|---|---|
| `plan` | string | The key's plan (its tier): `free`, `basic`, `trader`, `developer`, `quant`; DataFirst's own keys carry `admin`. |
| `plan_until` | string \| null | (1.0.44) RFC 3339 UTC. When the plan ends: the end of the paid period, as the DataFirst site last reported it (every payment, renewal, cancellation and comp moves it). `null` = open-ended (a lifetime, a comp without an end, DataFirst's own keys). Conventions, "Plan end". |
| `mode` | string | What the key may read now. `live`: attested on the current release of the subscriber agreement. `finished`: support's grace, finished sessions only. `attest`: no attestation, or one on an older release; sign the agreement first. `ended` (1.0.44): a professional subscriber's plan ran out; nothing until it is renewed. The hosted MCP adds `demo` for a connection without any key. |
| `mode_label` | string | The mode in words, the same on every surface: `Live data`, `Finished sessions only`, `Sign the data agreement to unlock live`, `Plan ended, renew to continue` (and, from the MCP, `Demo: finished sessions only, sign in for live`). |
| `account.name`, `attested`, `status`, `internal` | string, boolean, string \| null, boolean | The key's label; whether the attestation stands; `non_professional` \| `professional` \| `null`; whether this is one of DataFirst's own keys. |
| `usage.api` | object | `per_day` (`null` = unlimited), `used_today`, `left_today`, `resets_at` (midnight New York, RFC 3339). The count is kept on every plan, quota or not. |
| `usage.rate` | object | `per_second`, `burst`, `remaining_now` (requests the burst allowance holds this moment), `refills_in_s`. |
| `usage.chat` | object \| null | The MCP chat's questions a day for this key: `per_day`, `used_today`, `left_today`, `resets_at`; `null` when the chat could not be asked. |
| `usage.stream` | object | (1.0.57) The plan's WebSocket stream: `symbols_per_connection` (`0` = no stream on this plan), `connections` open at once per key (`null` = no limit), `open_now` (this key's connections open this moment). |
| `reach` | object | `history_from` (the first session the plan may ask for; `null` = everything kept), `symbols` (`"all"` or `"limited"`), `export`. |
| `products` | object | What the plan opens: `dashboard`, `terminal`, `api`, `mcp`, `chat`, each a boolean. |

**Example**

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/status"
```

```json
{"data": {"plan": "quant", "plan_until": null, "mode": "live", "mode_label": "Live data",
          "account": {"name": "…", "attested": true, "status": "non_professional", "internal": false},
          "usage": {"api": {"per_day": null, "used_today": 1, "left_today": null, "resets_at": null},
                    "rate": {"per_second": 50.0, "burst": 150, "remaining_now": 150, "refills_in_s": 0},
                    "chat": {"per_day": null, "used_today": 1, "left_today": null, "resets_at": "2026-10-25T04:00:00Z"},
                    "stream": {"symbols_per_connection": 50}},
          "reach": {"history_from": null, "symbols": "all", "export": true},
          "products": {"dashboard": true, "terminal": true, "api": true, "mcp": true, "chat": true}},
 "meta": {"as_of": "2026-10-25T02:12:10Z"}}
```

`GET /v1/me` carries the same `plan`, `mode` and `usage` beside its older fields.

### `GET /v1/plans`

(1.0.56) The plans and their prices: every plan on sale, in order, with its price, whether you can sign up now or join the waitlist, what it includes and opens, Ask Marlon's questions a day, and the limits enforced for its keys. No parameters, **no key needed**, never counted against a key and not rate-limited by one; `Cache-Control: max-age=60`. Everything comes from the same table that enforces each plan, so the website, the MCP's `plans` tool and Ask Marlon all say the same, and a change shows here within a minute. DataFirst's own staff plans are not listed.

| Field | Type | Unit | Meaning |
|---|---|---|---|
| `plans[].tier` | string | | The plan's id, as `tier` on `/v1/me`: `free`, `basic`, `trader`, `developer`, `quant`. |
| `plans[].name` | string | | The plan's name. |
| `plans[].availability` | string | | `open` (sign up now) or `waitlist` (keys open to the waitlist first). |
| `plans[].price.month` | number | USD | A month's price; `0` for Free. |
| `plans[].price.year` | number \| null | USD | A year's price, where the plan is sold yearly; else `null`. |
| `plans[].price.lifetime` | number \| null | USD | One payment for life, where sold that way; else `null`. |
| `plans[].limits.rate`, `.burst` | number | requests per second; requests | What is enforced per key: sustained rate and the burst allowance (the `RateLimit-*` headers). |
| `plans[].limits.calls_per_day` | integer \| null | calls | API calls a New York day; `null` = unlimited. The website's own requests never count. |
| `plans[].limits.history`, `.history_reach` | string \| null; string | | How far back history reaches: `Ns` sessions or `Nd` calendar days, `null` everything recorded; and the same in words ("the last 5 sessions"). |
| `plans[].limits.symbols` | string | | `"all"`: every symbol; `"limited"`: some symbols (Free; a symbol outside them is `403 symbol_not_in_tier`). |
| `plans[].limits.export` | boolean | | Bulk Parquet export (`/v1/export`). |
| `plans[].products.dashboard` | string \| null | | The website dashboard: `today` (today's session only) or `full`. |
| `plans[].products.terminal` | boolean \| null | | The trading terminal. |
| `plans[].products.api_mcp` | string \| null | | The API and the MCP: `trial` (a small daily amount to try: `limits.calls_per_day`), `full`, or `extended` (Quant: unlimited calls, several connections, redistribution by agreement). |
| `plans[].products.stream.symbols`, `.connections` | integer \| null | symbols; connections | The WebSocket stream: symbols per connection and connections at once; `0` = no stream (quotes only: up to 50 quotes symbols on one connection). |
| `plans[].products.compatible_apis` | boolean \| null | | The compatible APIs. |
| `plans[].products.bulk_export` | boolean | | The same as `limits.export`. |
| `plans[].products.support` | string \| null | | `standard`, `priority` or `sla`; `null` = community only. |
| `plans[].chat_per_day` | integer \| null | questions | Ask Marlon's questions a day; `null` = unlimited. |
| `plans[].includes` | string[] | | What the plan opens, in the short lines the website's plan cards use. |
| `currency` | string | | `USD`. |
| `notes` | string | | These definitions in one sentence each, for a reader that has only the answer. |
| `meta.as_of` | string | RFC 3339 UTC | When the plan table was last read. |

`products` describes each plan and is what is enforced too: `limits` (rate, burst, calls a day, history, symbols, export) since 1.0.56, the stream (`stream.symbols` per connection, `stream.connections` at once) and `compatible_apis` since 1.0.57, and `chat_per_day` by Ask Marlon; `dashboard`, `terminal` and `api_mcp` are the website's to open. While the plan table cannot be read, the answer is `503 plans_unavailable` with `Retry-After: 60`.

```json
{"data": {"plans": [
   {"tier": "free", "name": "Free", "availability": "open", "price": {"month": 0.0, "year": null, "lifetime": null},
    "limits": {"rate": 1.0, "burst": 5.0, "calls_per_day": 100, "history": "1s", "history_reach": "the current session",
               "symbols": "limited", "export": false},
    "products": {"dashboard": "today", "terminal": false, "api_mcp": "trial", "stream": {"symbols": 0, "connections": 0},
                 "compatible_apis": false, "bulk_export": false, "support": null},
    "chat_per_day": 5, "includes": ["The dashboard, today's session", "API and MCP: 100 calls a day", …]},
   {"tier": "basic", "name": "Basic", "availability": "open", "price": {"month": 49.99, "year": 449.99, "lifetime": 1499.0},
    "limits": {"rate": 1.0, "burst": 5.0, "calls_per_day": 250, "history": "5s", "history_reach": "the last 5 sessions", "symbols": "all", "export": false},
    "products": {"dashboard": "full", "terminal": false, "api_mcp": "trial", "stream": {"symbols": 5, "connections": 1},
                 "compatible_apis": false, "bulk_export": false, "support": "standard"},
    "chat_per_day": 15, "includes": ["The full dashboard", "The last 5 sessions", "API and MCP: 250 calls a day"]},
   {"tier": "developer", "name": "Developer", "availability": "waitlist", "price": {"month": 249.0, "year": null, "lifetime": null},
    "limits": {"rate": 10.0, "burst": 60.0, "calls_per_day": 100000, "history": "365d", "history_reach": "365 days", "symbols": "all", "export": false},
    "products": {"dashboard": "full", "terminal": true, "api_mcp": "full", "stream": {"symbols": 50, "connections": 2},
                 "compatible_apis": true, "bulk_export": false, "support": "priority"},
    "chat_per_day": 100, "includes": ["Dashboard, terminal, API, MCP", "10 requests/s, 100,000 calls a day", "Streaming (50 symbols), compatible APIs",
                                      "One year of history", "Priority support"]}],
  "currency": "USD", "notes": "price.month is a month's price; ..."},
 "meta": {"as_of": "2026-10-26T16:21:30Z"}}
```

The example shows three of the five plans (Trader and Quant follow the same shape: Trader $99 a month on the waitlist with the terminal and a stream of 25 symbols on 2 connections; Quant $999 with unlimited calls, everything recorded, bulk export, a stream of 50 symbols on 5 connections and SLA support).

### `GET /v1/reference` and `GET /v1/symbols/{symbol}/reference`

The reference inputs in use, as data: the interest-rate curve, dividend yields and contract conventions.

**`/v1/reference`** (no parameters)

| Field | Type | Unit | Description |
|---|---|---|---|
| `rates.points[]` | [number, number][] | years, fraction | The curve in use: 13-week, 5-, 10- and 30-year Treasury yields. |
| `rates.rate` | number | fraction | A flat rate, given instead of `points` when no curve is in use. |
| `rates.loadedAt` | integer | epoch s | When the curve was loaded. |
| `dividend_yields` | object | fraction | Continuous dividend yield per symbol. |
| `conventions.multiplier` | object | shares | 100 shares per contract; adjusted series are flagged (`adjusted` on the symbol's reference). |
| `conventions.expiry_time` | object | | `pm`: 16:00 New York; `am`: the opening print at 09:30 New York, every expiration of the roots SPX, NDX, RUT, DJX, OEX, XEO. SPX and NDX answers price the weekly roots (SPXW, NDXP), which are `pm`. |
| `conventions.time_to_expiry`, `atm_band`, `spot`, `sectors` | | | The conventions in use for time to expiry, the at-the-money band, spot and sectors. |

**`/v1/symbols/{symbol}/reference`**

| Field | Type | Unit | Description |
|---|---|---|---|
| `roots` | string[] | | Option roots behind the symbol (`SPX` → `SPX`, `SPXW`). |
| `underlying` | string | | The root with any adjustment digit removed. |
| `adjusted` | boolean | | True for an adjusted series (root ending in a digit): the contract does not deliver the standard 100 shares. |
| `is_index` | boolean | | Cash-settled index option. |
| `multiplier`, `multiplier_known` | integer, boolean | shares | 100; `multiplier_known` is false for adjusted series. |
| `expiry_time` | string | | `am` or `pm` for the root this symbol is priced by (see `/v1/reference`): `pm` for SPX and NDX (SPXW, NDXP), `am` for RUT, DJX, OEX, XEO. |
| `dividend_yield` | number | fraction | The yield used for this symbol. |
| `sessions` | object | | (1.0.29) The symbol's recorded sessions among the listed finished ones: `first` and `last` (dates) and `count`. `count` 0 with null dates for a symbol never recorded. A newly listed session appears here within the hour. |
| `sector`, `industry`, `sic` | string | | From the SEC's SIC code of the underlying's registrant; `NOT_APPLICABLE` for ETFs, indexes and unregistered names. |

```
curl -H "Authorization: Bearer $KEY" "https://api.datafirsttrading.com/v1/symbols/SPX/reference"
```

```json
{"data": {"symbol": "SPX", "roots": ["SPX", "SPXW"], "underlying": "SPX", "adjusted": false, "is_index": true, "sessions": {"first": "2025-06-30", "last": "2026-10-16", "count": 285},
          "multiplier": 100, "multiplier_known": true, "expiry_time": "pm", "dividend_yield": 0.0,
          "sector": "NOT_APPLICABLE", "industry": "NOT_APPLICABLE", "sic": null},
 "meta": {"symbol": "SPX", "as_of": "2026-10-10T19:20:11Z"}}
```

### `GET /v1/symbols`, `/expirations`, `/spot`

`symbols`: `symbols[]` and `count`. `expirations`: `expirations[]` (dates still live today) and `term_selection[]` (the `term` set). `spot`: `price` (USD).

**Examples**

`GET /v1/symbols`

```json
{"data": {"symbols": ["A", "AA", ...], "count": 6317}, "meta": {"as_of": "2026-10-02T13:31:59.194Z"}}
```

`GET /v1/symbols/SPY/expirations`

```json
{
 "data": {"expirations": ["2026-10-02", "2026-10-05", ...], "term_selection": ["2026-10-07", "2026-10-09", ...]},
 "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:31:59.203Z"}
}
```

`GET /v1/symbols/SPY/spot`

```json
{
 "data": {"price": 769.47},
 "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:31:59.206Z"}
}
```

### Errors

Every error is an RFC 9457 problem document with `Content-Type: application/problem+json` and a stable `code` (the Errors section lists them). The four you will meet:

`GET /v1/symbols/SPY/levels (no key)` -> `401`

```json
{"type": "about:blank", "title": "unauthorized", "status": 401, "code": "unauthorized", "detail": "missing or unknown API key"}
```

`GET /v1/symbols/ZZZZ/levels` -> `404`

```json
{
 "type": "about:blank",
 "title": "unknown symbol",
 "status": 404,
 "code": "unknown_symbol",
 "detail": "ZZZZ has no listed options in this book"
}
```

`GET /v1/symbols/SPY/levels?expirations=99` -> `422`

```json
{
 "type": "about:blank",
 "title": "invalid parameter",
 "status": 422,
 "code": "invalid_parameter",
 "detail": "expirations must be 1..50 or 'term'"
}
```

## WebSocket `/v1/stream`

One socket carries every topic you subscribe to, and every update carries the same bytes as the topic's REST twin (1.0.61). Two steps: **negotiate** (`POST /v1/stream/negotiate` with your key: the topics on offer, your limits and a `neg` token, next section), then **open the socket** at the path the answer gives, with the token:

```
wss://api.datafirsttrading.com/v1/stream?api=1&encoding=zstd&token=<your key or a ticket>&neg=<neg_token.neg>
```

Negotiate before every socket. A token is good for 300 s and for any number of sockets of the same key until then; negotiate again about 90 s before it runs out. Take the path from `transports.ws.url` each time (`/v1/stream` today) rather than typing it in. From a web page, your server negotiates and asks `POST /v1/stream/ticket` with the key, and hands the page the ticket and the `neg` only: the ticket goes in `token` and the key never reaches the browser.

**The model.** Push changes, pull the day. A **topic** names one REST answer, and its messages carry that answer's exact bytes: a *snapshot* family sends the whole body when it changed (`snap`); an *append* family sends a `snap`, then only what is new (`app`, carrying `prev`, the `asof` it continues). A message is built and compressed once and the same bytes go to every subscriber; nothing is sent for a topic that did not change. History, the day's bars, a wide chain and replays stay `GET`: a panel fetches its starting state once, then applies messages.

| Query parameter | Values | Notes |
|---|---|---|
| `api` | `1` | Required, exactly `1` (the `/v1` API). Anything else closes the socket `4400 invalid_api`. |
| `token` | your key, or a ticket (`dft_tk_...`) | Checked as for live REST data: the key, its plan and its OPRA attestation. |
| `neg` | `neg_token.neg` from the negotiation | Required. Missing, unknown or expired: `4003 negotiation_required`; another key's: `4003 negotiation_principal`. |
| `encoding` | `zstd` (default), `deflate`, `json` | How messages are framed (below). Anything else: `4400 invalid_encoding`. |
| `compact` | (not served) | Compact protobuf bodies are not offered yet; the parameter, with any value, closes `4400 invalid_compact`. |

**Encodings.** With `zstd` every message is a binary WebSocket frame holding one MessagePack map compressed whole with zstd; `deflate` is the same with raw deflate (RFC 1951, no zlib header) for clients without zstd; `json` sends text frames of JSON, for debugging. In MessagePack a topic's `body` is a `bin` holding the exact bytes the REST twin answers (UTF-8 JSON, `{"data": ..., "meta": ...}`); in JSON it is inlined as a JSON value. Every other member is the same in all three.

**Topics.** A topic is `{family}:{TICKER}:{scope}:{view}`: the family, the symbol in capitals (`core` takes `-`), a scope from the family's set, and the view, always `-` (no futures views are served). The REST twin's other parameters are fixed per family, so no family takes parameters after the view: the scopes are the shapes. One spelling is accepted; anything else is refused for that topic with the canonical form in the detail (`err 4400 invalid_param`, `non-canonical topic "levels:spy:exp4:-", use "levels:SPY:exp4:-"`). A topic is at most 256 bytes.

| Family | Topic | Kind | Scopes | Body: the REST twin |
|---|---|---|---|---|
| `levels` | `levels:{S}:{scope}:-` | snapshot | `latest`, `exp4`, `exp5`, `exp10`, `exp20`, `exp50` | `GET /v1/symbols/{S}/levels` at that depth (`exp4`: no `expirations` parameter; `latest`: `expirations=1`): key levels, walls, totals, flow, classification; the meta carries `spot` and, for SPY, SPX, QQQ, NDX, IWM, RUT, DIA and DJX, `futures` |
| `profile` | `profile:{S}:{scope}:-` | snapshot | as `levels` | `GET /v1/symbols/{S}/strikes` at that depth, every strike |
| `contracts` | `contracts:{S}:{scope}:-` | snapshot | `latest`, `exp4` | `GET /v1/symbols/{S}/contracts` (`latest`: `expirations=1`); a wider chain is a `GET` |
| `surface` | `surface:{S}:term:-` | snapshot | `term` | `GET /v1/symbols/{S}/vol` (its default, the term structure) |
| `flow` | `flow:{S}:all:-` | snapshot | `all` | `GET /v1/symbols/{S}/flow?buckets=1`: totals, the current one-minute bucket, classification (the day's buckets: the `GET`) |
| `quotes` | `quotes:{S}:-:-` | snapshot | `-` | `GET /quotes?tickers={S}`: one row, `ticker`, `price` USD, `prev_close` USD, `change` USD, `change_pct` percent, `volume` shares, `session`, `market`, `price_age_s` seconds (section "Market data" of the site API); sent when the row changes, its `price_age_s` aside. Any symbol, stocks and ETFs included. |
| `drift` | `drift:{S}:all:-` | append | `all` | `snap`: `GET /v1/symbols/{S}/flow?buckets=0&series=1` (the net-drift path, `series.samples[]`: `ts_ms`, `spot` USD, `signed_call_premium_cum`, `signed_put_premium_cum` USD). `app`: the same body with `series.samples` holding only the points after the newest one you hold |
| `bars` | `bars:{S}:-:-` | append | `-` | `{"data": {"bars": [...]}, "meta": ...}`, rows as `GET /v1/symbols/{S}/intraday` (`ts`, `ts_ms`, `open`, `high`, `low`, `close`; USD, index points for an index). `snap`: the current minute's bar; `app`: the current bar when it changed, and when the minute turns the finished bar's final values then the new one. Upsert by `ts_ms`. |
| `imap` | `imap:{S}:exp7:-` | append | `exp7` | `GET /v1/symbols/{S}/history/heatmap?interval=1` (its default view: the nearest 7 expirations) with `snapshots` holding the newest minute only (`rows[]`: `strike`, `call_gex`, `put_gex`, `net_gex`, USD per 1% move), sent once when the minute is recorded |
| `tape` | `tape:{S}:all:-` | append | `all` | `GET /v1/tape?symbol={S}`'s rows: `snap` the latest 50 prints of the last 5 minutes, `app` the prints since, oldest first (`ts`, `ts_ms`, `expiration`, `strike`, `right`, `size` contracts, `price`, `premium` USD, `exchange` MIC, `bid`, `ask`); `since` is the newest print of the message before |
| `sweeps` | `sweeps:{S}:all:-` | append | `all` | `GET /v1/sweeps?symbol={S}`'s rows, newest first (`size` contracts, `avg_price`, `premium` USD, `exchanges`, `trades`, `side`), plus `unusual`: the new unusual prints (premium of $25,000 or more on a contract whose volume so far is above its open interest), rows as the tape's |
| `core` | `core:-:exp4:-` | append | `exp4` | `{"data": {"SPY": <GET /v1/symbols/SPY/levels body>, ...}}` for the 24 core names; `snap` all of them, each `app` only the names that changed. Counts as one plan symbol. |

`tape` and `sweeps` messages come about a second apart while prints arrive, each print exactly once.

**Scopes: the shapes a socket serves.** A socket serves a fixed set of shapes, the ones the DataFirst app asks for; every other shape is a `GET`, which takes any value. Each family's scopes, the expiration depths of `levels` and `profile` first (and `latest` and `exp4` on `contracts`):

| Scope | Expirations | Same REST read |
|---|---|---|
| `latest` | the nearest one still trading: today's until the 16:00 New York close, then the next listed (after a Friday's close, over a weekend or a holiday, the next listed expiration). On an early-close day today's stays until 16:00 for now, though its options stop at 13:00. | `?expirations=1` |
| `exp4` | the nearest 4 (the REST default) | no `expirations` parameter |
| `exp5` | the nearest 5 | `?expirations=5` |
| `exp10` | the nearest 10 | `?expirations=10` |
| `exp20` | the nearest 20 | `?expirations=20` |
| `exp50` | the nearest 50 (every listed expiration for nearly every name) | `?expirations=50` |
| `exp7` | the nearest 7 expirations the interval map records (`imap` only) | `history/heatmap`'s default view |
| `term` | the term structure's sampled expirations (`surface` only) | `/vol`'s default |
| `all` | every listed expiration, no day limit: every print of any expiration counts (`flow`, `drift`, `tape`, `sweeps`) | the route as written in the table |
| `-` | no expiration: a stock's or an index's quote, the spot bars (`quotes`, `bars`) | the route as written in the table |

The names are the usual ones where the meaning is exactly ours (`latest`, `all`) and our own otherwise, by count. `next` (the next expiration alone) and `full` (every expiration out to 90 days) are not served: `/v1` reads the nearest N expirations or the term set, and a scope is never an approximation. The full `hello` and the negotiation's `catalogue.inline` carry the table as `scope_defs` (`{"latest": {"expirations": 1, "doc": ...}, ...}`).

A scope outside a family's set is refused for that topic, `err 4400 scope_not_served`, the detail listing the family's scopes (`scope: levels is served on latest, exp4, exp5, exp10, exp20, exp50; every other shape is a REST read`); a parameter after the view is `err 4400 unknown_param`, one sent twice `duplicate_param`. The socket stays open.

**The core names are always live.** The 24 core names' default topics on `levels` (`exp4`), `bars`, `flow`, `tape`, `sweeps` and `quotes` are always live, whether anyone subscribes or not, so the first subscriber gets its `snap` at once, and they count against no one's plan (subscribing to one counts its symbol on your socket, as any topic does). The core names: SPX, QQQ, SPY, NDX, GLD, AAPL, TSLA, NVDA, AMD, META, MSFT, GOOGL, AMZN, AVGO, MU, PLTR, HOOD, COIN, IBIT, VIX, IWM, DIA, SLV, GDX.

**Messages you send.** JSON text frames, one message each, whatever the `encoding`; each has a `k` naming it.

| Message | Effect |
|---|---|
| `{"k": "sub", "topic": "levels:SPY:exp4:-", "every_ms": 5000}` | Subscribe. `every_ms` is optional and slows a topic down only: 1000, 2000, 5000, 10000, 15000, 30000 or 60000, a value in between rounded up, a value at or under the topic's own pace leaving it alone, outside 1000 to 60000 `err 4400 out_of_range`. Subscribing again to a topic you hold sends its newest body again as a `snap` (a token of the socket's bucket, none of the key's 1,200 a minute); a different `every_ms` subscribes it again at the new rate. |
| `{"k": "unsub", "topic": "..."}` | Unsubscribe. Free, no reply. |
| `{"k": "resync", "topic": "..."}` | A `snap` of the topic on your socket only: use it when an `app` does not continue what you hold. |
| `{"k": "pause"}` | No bodies until `resume`; pings continue. For a hidden window. |
| `{"k": "resume"}` | Bodies restart: a `gap` and a `snap` for every topic that changed while paused. |

A message the API does not understand, malformed JSON and any binary frame from you are ignored: no reply, the socket stays open. Nothing has to be sent on a schedule.

**Messages you receive.** Shown as JSON; in `zstd` and `deflate` the same maps in MessagePack.

| `k` | When | Members |
|---|---|---|
| `hello` | first, as soon as the key is accepted (a `sub` sent before it is handled) | With a current `neg`: `api`, `neg` (the negotiation's id), `catalogue` (the hash of the topic list; you hold the rest from the negotiation). Otherwise the full hello: `api` (1), `proto` (2, the message format; a breaking change is a new `proto`), `dialect` (your `encoding`), `catalogue`, `families` (each family's body schema version), `kinds` (`snap` or `append`), `params` (none), `scopes`, `routes` (each family's REST route), `list_sep` (`+`), `every_ms`, `gap_notice` (`true`), `beat_ms` (5000), `scope_defs` (each scope's expirations and meaning), `max_topics`, `max_symbols` (the plan's symbols per connection), `quotes_symbols` (50). |
| `snap` | when you subscribe (as soon as the topic has a state), after a `gap`, after a `resync`, and on every change of a snapshot topic | `topic`, `v` (`[asof, n]`: the version, `n` counting the topic's messages), `asof` (epoch milliseconds: the time of the value), `enc` (`json`), `body` (the REST twin's bytes). Replace what you hold. |
| `app` | every change of an append topic, after its `snap` | as `snap`, plus `prev`: the `asof` of the message it continues. Apply it only if `prev` is the `asof` you hold for the topic; otherwise discard it and send `resync`. |
| `gap` | before the body that follows messages your socket did not get (it could not keep up, or it was paused) | `topic`. The body after it is a `snap`, a whole replacement, never an `app`. |
| `beat` | every `beat_ms` | `topics`: your topics that sent nothing since the last beat (alive, unchanged). A topic silent for three beat periods is not live: read its REST twin. |
| `ping` | after 15 s with nothing else sent | `n`: messages sent to you before it. No reply is expected. |
| `err` | a topic refused, never the socket | `topic`, `code`, `reason`, `detail` (for people; its wording can change), `request_id` (quote it to support); `retry_ms` where retrying can help. |

**Append topics in detail.** An append topic sends an `app` only to a socket whose last body for it was the message the `app` continues; a socket that missed one gets a `gap` and a `snap` instead, so `prev` always matches unless your own code lost a message. `drift`: replace every member of the body but `series.samples`, and append the app's samples (drop any you hold at or after its first `ts_ms`); a new session's series, or one that went back, comes as a `snap`. `bars`: upsert each row by `ts_ms`. `tape`, `sweeps`: append the rows. `core`: replace the names it carries. `imap`: append the minute.

**Limits.**

- **Symbols.** The plan sets symbols per connection and connections at once (1.0.57; `GET /v1/plans` `products.stream`, your own numbers on `GET /v1/me` `limits` and in the negotiation): Basic 5 symbols and 1 connection, Trader 25 and 2, Developer 50 and 2, Quant 50 and 5. A symbol counts once however many families carry it, `core` counts as one, `quotes` do not count. A topic past the plan's symbols is refused alone, `err 4290 stream_symbols_over_plan`.
- **Quotes.** Up to 50 `quotes` symbols per connection on every plan, Free included, apart from the plan's symbols; the 51st is `err 4290 quotes_over_allowance`. A plan without a stream (Free) opens one socket at a time for quotes only; any other family on it is `err 4003 not_on_plan`.
- **Topics per socket** (`max_topics`, the negotiation's `topics_cap`): four per plan symbol, at least 20, plus the 50 quotes (Basic 70, Trader 150, Developer and Quant 250); past it `err 4290 too_many_topics`.
- **Subscribing.** Each `sub` that attaches a topic spends one of 1,200 a minute kept for your key across its sockets (`err 4290 rate_limited`, `retry_ms` 60000); a `sub` of a topic you hold spends none of them. On each socket `sub` and `resync` also come from a bucket of `max_topics` + 16, refilled at 4 a second (`err 4290 rate_limited`, `retry_ms` 1000); `unsub` is free.
- **Connecting.** 60 connection attempts a minute per address (a burst of 100; past it the handshake answers `429` with `Retry-After`) and 20 a minute per key (a burst of 60; close `4003 connect_rate`). Connections at once per key: the plan's (close `4003 connection_limit`; a socket that dropped frees its slot once it is seen closed, at once on a clean close, within 60 s for a peer gone silent).
- **Keeping up.** A socket that cannot keep up is never queued for: it holds at most one pending message per topic (the newest; a `gap` before it), and one that has not drained for 30 s is closed `1013 slow_consumer`.
- **The key is checked again** while the socket is open: a revoked key closes `4001 key_revoked`; a plan cut to fewer symbols keeps the ones subscribed first and drops the rest (`err 4290 stream_symbols_over_plan` each); a plan without a stream keeps the quotes and drops the rest (`err 4003 not_on_plan` each).

**Refusals of a topic (`err`).** The socket stays open.

| `code` | `reason` | When | Retry |
|---|---|---|---|
| `4000` | `unknown_name` | Not a family (`hello.families`). | No. |
| `4000` | `ticker_unsupported` | Not a listed symbol (`levels:_SPX:...`, or no listed options). | No. |
| `4003` | `not_on_plan` | A family other than `quotes` on a plan without a stream. | With a plan that streams. |
| `4290` | `rate_limited` | The socket's bucket (`retry_ms` 1000) or the key's 1,200 a minute (`retry_ms` 60000). | After `retry_ms`. |
| `4290` | `stream_symbols_over_plan`, `quotes_over_allowance`, `too_many_topics` | Past the plan's symbols, the 50 quotes, or the socket's topics. | After an `unsub`. |
| `4400` | `invalid_param` | A malformed or non-canonical topic (the detail names the canonical form), a view other than `-`. | Fixed. |
| `4400` | `scope_not_served` | A scope outside the family's set (the detail lists them). | With a listed scope, or a `GET`. |
| `4400` | `unknown_param`, `duplicate_param`, `out_of_range` | A parameter after the view, one sent twice, an `every_ms` outside 1000 to 60000. | Fixed. |

**Close codes.** At the handshake, an address past its connection rate gets `429` with `Retry-After` (no socket), and `404` while the stream is not available. On the socket:

| Close code | Reason | When | Reconnect |
|---|---|---|---|
| `4001` | `unauthorized` | No key, or an unknown key (also one deleted while the socket is open). | No. |
| `4001` | `invalid_ticket` | A ticket unknown, already used, or older than 60 s. | With a new ticket. |
| `4001` | `key_revoked`, `plan_ended` | The key was revoked, or a professional's plan ended (1.0.44), at connect or within 30 s on an open socket. | No; not until the key is restored or renewed. |
| `4003` | `attestation_required`, `attestation_pending`, `internal_only` | No current OPRA attestation, or support's grace (finished sessions only), at connect or later; `internal_only` is a trial-period refusal, not answered since 2026-10-24. | No; not until the cause is fixed. |
| `4003` | `negotiation_required`, `negotiation_principal` | No current `neg`, or one another key negotiated. | After negotiating. |
| `4003` | `connect_rate`, `connection_limit` | The key's connection attempts, or its connections at once. | Yes, later or after closing one. |
| `4400` | `invalid_api`, `invalid_encoding`, `invalid_compact` | A query parameter value that is not accepted. | Fixed. |
| `1009` | | A message of yours over 64 KiB. | Yes. |
| `1011` | `audit_unavailable` | Data access is temporarily unavailable; the stream closes. | Yes, retry later. |
| `1011` | `backend_unavailable` | The socket could not be registered. | Yes, backing off. |
| `1013` | `slow_consumer` | Messages to you undrained for 30 s. | Yes, backing off. |
| `4408` | `no_topics` | Nothing subscribed for 120 s (from the open, or your last `unsub`; a refused `sub` does not count). | When you have something to subscribe. |
| `4429` | `message_flood` | Your messages faster than the socket takes (a burst of 600, then 100 a second). | Yes, backing off. |

A socket that stops answering the WebSocket ping is closed. Reconnect, backing off exponentially; a new socket holds nothing: negotiate if the token is near its end, then `sub` each topic again and treat each first body as a replacement.

**Moving from the earlier stream (1.0.39 to 1.0.60).** What a client of the earlier stream changes:

1. Negotiate before every socket (`POST /v1/stream/negotiate`) and open `transports.ws.url` with `?api=1&encoding=...&token=...&neg=...`. `token` is the key or the ticket from `POST /v1/stream/ticket` (tickets now start `dft_tk_`); `?key=` and `?ticket=` are gone.
2. Every message names itself with `k` where `type` and `op` were. Send `{"k": "sub", "topic": "levels:SPY:exp4:-"}`, one topic a message (the `topics` list and `{"op": "subscribe", "channel": ..., "symbols": [...]}` are gone), and `unsub`, `resync`, `pause`, `resume`. No `ack` or `pong` comes back: the first `snap` is the acknowledgement, an `err` the refusal; a `ping` from you is ignored.
3. Topics: `/v1/symbols/SPY/levels` is `levels:SPY:exp4:-`; `?expirations=1` is `levels:SPY:latest:-`, 5, 10, 20 and 50 are `exp5` to `exp50`, other depths are REST only. `/strikes` is `profile:SPY:exp4:-` (every strike); `/contracts` is `contracts:SPY:exp4:-` or `latest`; `/vol` is `surface:SPY:term:-`; `/flow?buckets=1` is `flow:SPY:all:-`; the flow series is `drift:SPY:all:-`; `/intraday` is `bars:SPY:-:-`; `/history/heatmap?interval=1` is `imap:SPY:exp7:-`; `/v1/tape?symbol=SPY` is `tape:SPY:all:-`; `/v1/sweeps?symbol=SPY` is `sweeps:SPY:all:-`; `/v1/core/levels` is `core:-:exp4:-`.
4. A `snap` carries the REST answer whole in `body` (`body.data`, `body.meta`) where the frame carried `data` and `meta`; `asof` is epoch milliseconds (was `as_of`, RFC 3339); `v` (`[asof, n]`) replaces `seq`; `channel`, `symbol`, `age_s` and `every_ms` are gone (the topic names them).
5. An append family's increments are `app` (was `inc`), with `prev` in epoch milliseconds. `drift` sends `app` now: replace the body's other members and append its `series.samples`.
6. Binary frames are MessagePack maps compressed with zstd (they were zstd-compressed JSON): decompress, then unpack; `body` is JSON bytes.
7. Close codes: an unknown key `4001 unauthorized` (was `4401`), a used ticket `4001 invalid_ticket` (was `4401`), a refused key `4003 attestation_required` / `attestation_pending` (was `4403`), connections at once `4003 connection_limit` (was `4403 stream_connections_over_plan`); the reason is the code alone (was `"<code>: <detail>"`). A plan without a stream connects for quotes only instead of `4403 stream_not_in_plan`.
8. `err` carries `code`, `reason`, `detail`, `request_id` and, when retrying helps, `retry_ms`: `4000` for an unknown family or symbol (was `4404`), `4003 not_on_plan`, `4290` for rates and the plan's symbols (`stream_symbols_over_plan` was `4403`), `4400` for a malformed topic (`invalid_param` with the canonical form was `non_canonical_topic`).
9. `beat` is `{"k": "beat", "topics": [...]}` and names only quiet topics; a `ping` comes after 15 s of silence; a `gap` is `{"k": "gap", "topic": ...}` and is always followed by a `snap`.

Captured from a replay of the 2026-10-02 open (the session's quotes and prints at real pace, 1.0.61) with `encoding=json`, the socket opened with a current `neg` (so the short `hello`); lists trimmed to their first rows with `...`. Every `body` is its REST twin's answer:

```json
{"k": "sub", "topic": "levels:SPY:exp4:-"}
{"k": "sub", "topic": "drift:SPY:all:-"}
{"k": "sub", "topic": "bars:SPY:-:-"}
{"k": "sub", "topic": "tape:SPY:all:-"}
{"k": "sub", "topic": "sweeps:SPY:all:-"}
{"k": "sub", "topic": "core:-:exp4:-"}
{"k": "sub", "topic": "imap:SPY:exp7:-"}
{"k": "sub", "topic": "levels:SPY:exp8:-"}
{"k": "sub", "topic": "levels:spy:exp4:-"}
{"k": "hello", "api": 1, "neg": "a62f1bf3e7ccb539", "catalogue": "8d2697c932211421"}
{
 "k": "snap",
 "topic": "levels:SPY:exp4:-",
 "v": [
  1790947947467,
  77
 ],
 "asof": 1790947947467,
 "enc": "json",
 "body": {
  "data": {
   "key_levels": {
    "gamma_flip": 765.0922,
    "call_wall": 770.0,
    "put_wall": 760.0,
    "max_pain": 764.0,
    "vol_trigger": 770.0,
    "momentum_wall": 772.0,
    "momentum_value": 353429159
   },
   "walls": ...,
   "totals": {
    "net_gex": 4721431818,
    "call_gex": 9186978251,
    "put_gex": -4465546434,
    "net_volume_gex": -149714180,
    "gex_ratio": 0.6729,
    "delta_exposure": 12693978,
    "vanna_exposure": -96982,
    "charm_exposure": 3007866,
    "atm_iv": 0.1241
   },
   "flow": ...,
   "classification": ...
  },
  "meta": {
   "symbol": "SPY",
   "as_of": "2026-10-02T13:32:27.467Z",
   "as_of_ms": 1790947947467,
   "computed_at": "2026-10-02T13:32:27.746Z",
   "spot": {
    "price": 769.83
   },
   "expirations": ...,
   "stale": false
  }
 }
}
{"k": "app", "topic": "drift:SPY:all:-", "v": [1790947949893, ...], "asof": 1790947949893, "prev": 1790947948806, "enc": "json", "body": {"data": {"date": "2026-10-02", "totals": {"signed_call_premium": 1634174.0, "signed_put_premium": -461881.0, "bullish_premium": 22789336.0, "bearish_premium": 20693281.0, "net_premium": 2096055.0}, "buckets": [], "classification": {"classified_trades": 33667, "unclassified_trades": 1595, "coverage": 0.9548}, "series": {"session": "current", "samples": []}}, "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:32:29.893Z"}}}
{"k": "app", "topic": "bars:SPY:-:-", "v": [1790947949242, ...], "asof": 1790947949242, "prev": 1790947947806, "enc": "json", "body": {"data": {"bars": [{"ts": "2026-10-02T13:32:00.000Z", "ts_ms": 1790947920000, "open": 769.41, "high": 769.85, "low": 769.41, "close": 769.85}]}, "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:32:29.242Z", "resolution": "1m"}}}
{"k": "app", "topic": "tape:SPY:all:-", "v": [1790947949436, ...], "asof": 1790947949436, "prev": 1790947948360, "enc": "json", "body": {"data": {"prints": [{"ts": "2026-10-02T13:32:28.428Z", "ts_ms": 1790947948428, "symbol": "SPY", "expiration": "2026-10-06", "strike": 765.0, "right": "C", "size": 3, "price": 6.53, "premium": 1959.0, "exchange": "SPHR", "bid": 6.53, "ask": 6.63}, ...], "count": 122, "since": "2026-10-02T13:32:28.360Z"}, "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:32:29.436Z"}}}
{"k": "app", "topic": "sweeps:SPY:all:-", "v": [1790947949021, ...], "asof": 1790947949021, "prev": 1790947948139, "enc": "json", "body": {"data": {"sweeps": [{"ts": "2026-10-02T13:32:29.021Z", "ts_ms": 1790947949021, "symbol": "SPY", "expiration": "2026-10-02", "strike": 770.0, "right": "C", "size": 5, "avg_price": 1.468, "premium": 734.0, "exchanges": 3, "trades": 3, "side": "sell"}, ...], "count": 5, "since": "2026-10-02T13:32:28.139Z", "window_ms": 250, "min_exchanges": 3, "side": null, "unusual": []}, "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:32:29.021Z"}}}
{"k": "app", "topic": "core:-:exp4:-", "v": [1790947949441, 2], "asof": 1790947949441, "prev": 1790947948946, "enc": "json", "body": {"data": {"AMD": {"data": {"key_levels": {"gamma_flip": 612.5711, "call_wall": 637.5, "put_wall": 600.0, "max_pain": 602.5, "vol_trigger": 635.0, "momentum_wall": 640.0, "momentum_value": 12807884}, ...}, "meta": {"symbol": "AMD", "as_of": "2026-10-02T13:32:29.441Z", ...}}, ...}, "meta": {"as_of": "2026-10-02T13:32:29.441Z"}}}
{"k": "snap", "topic": "imap:SPY:exp7:-", "v": [1790947860000, ...], "asof": 1790947860000, "enc": "json", "body": {"data": {"date": "2026-10-02", "interval_minutes": 1, "mode": "raw", "expiration": null, "expirations": ["2026-10-02", ...], "snapshots": [{"as_of": "2026-10-02T13:31:00.000Z", "spot": 770.15, "rows": [{"strike": 748.0, "call_gex": 5950071, "put_gex": -32026458, "net_gex": -26076387}, ...]}], "count": 1}, "meta": {"symbol": "SPY", "as_of": "2026-10-02T13:32:29.052Z", "resolution": "1m"}}}
{"k": "err", "topic": "levels:SPY:exp8:-", "code": 4400, "reason": "scope_not_served", "detail": "scope: levels is served on latest, exp4, exp5, exp10, exp20, exp50; every other shape is a REST read", "request_id": "b7ecf3b800000007"}
{"k": "err", "topic": "levels:spy:exp4:-", "code": 4400, "reason": "invalid_param", "detail": "non-canonical topic \"levels:spy:exp4:-\", use \"levels:SPY:exp4:-\"", "request_id": "b7ecf3b800000008"}
{"k": "beat", "topics": ["contracts:SPY:latest:-", "quotes:SPX:-:-", "surface:SPY:term:-"]}
```

A `quotes` snap's `body` is the REST answer for its one symbol; `GET /quotes?tickers=AAPL` at 13:08 ET on 2026-10-27 answered:

```json
{"data": {"quotes": [{"ticker": "AAPL", "price": 345.47, "prev_close": 343.91, "change": 1.56, "change_pct": 0.4536, "volume": 650147, "session": "2026-10-27", "market": "open", "price_age_s": 0.6}]}, "meta": {"as_of": "2026-10-27T17:08:56Z"}}
```

### `POST /v1/stream/negotiate`

What the stream offers your key, and the token its socket requires (1.0.61). Call it before every socket, from your server or your client.

**Parameters.** No query parameters (one is `422 invalid_parameter`). The body is optional JSON of at most 16 KiB; an empty body negotiates the defaults. Every member is optional and descriptive only:

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `client` | body | string | — | A name for your client, at most 64 bytes. |
| `platform` | body | string | — | Where it runs, at most 16 bytes. |
| `version` | body | string | — | Your build, at most 64 bytes. |
| `features` | body | string[] | `[]` | What you would like, at most 32 words of at most 32 bytes: `ws`, `zstd`, `deflate`, `msgpack` and `catalogue.inline` are agreed; any other word (`protobuf`, `compact.plugin`, ...) comes back in `ignored`, never an error. |
| `have.catalogue` | body | string | — | The catalogue hash you already hold: `catalogue.inline` is filled only when it differs. |

Refused as live data is (`401`, `403 key_revoked`, `attestation_required`, `attestation_pending`, `internal_only`), and counted against the key's rate and day quota as any call. A plan without a stream negotiates a quotes-only socket.

**Returns.**

| Field | Type | Unit | Description |
|---|---|---|---|
| `api` | integer | — | The socket's `api` value: `1`. |
| `negotiation_id` | string | — | This negotiation's id: the short `hello`'s `neg`. |
| `features`, `ignored` | string[] | — | The words you sent, split into what is agreed and what is not offered. |
| `transports.rest` | object | — | `base` (`/v1`), `body_encodings` (`json`), `compression` (`gzip`). |
| `transports.ws.url` | string | — | The socket path: `/v1/stream`. |
| `transports.ws.enabled`, `.dialects`, `.body_encodings`, `.compact` | — | — | Whether the socket is served; `zstd`, `deflate`, `json`; `json`; `[]` (no compact bodies yet). |
| `transports.ws.beat_ms`, `.every_ms` | integer, integer[] | milliseconds | The beat period (5000); the `every_ms` rungs. |
| `transports.ws.list_sep`, `.gap_notice` | string, boolean | — | `+`; `true`: a `gap` always precedes a body after messages you did not get. |
| `topics[]` | object[] | — | One per family: `name`, `path` (the REST route whose body it carries), `params` (`[]`), `scopes`, `schema` (the body's version), `encoding` (`json`), `kind` (`snap` or `append`). Build topic names from it. |
| `catalogue.hash` | string | — | The hash of the topic list. When it changes, a socket opened with an older token gets the full `hello`. |
| `catalogue.url`, `catalogue.inline` | null, object or null | — | No separate catalogue address; `inline` is the full hello's topic description when you asked `catalogue.inline` and hold another hash. |
| `limits.rate_bucket`, `.rate_limit`, `.rate_window_s`, `.rate_left` | string, integer or null | requests; seconds | Your key's REST budget: `per_key`, the plan's sustained rate over a 60 s window, the tokens left in the key's bucket now (`null`: no rate limit). |
| `limits.sockets_cap`, `.sockets_open` | integer or null, integer | connections | The plan's connections at once (`null`: no limit; 1 for a quotes-only plan), and the key's open now. |
| `limits.topics_cap` | integer | topics | Topics one socket may hold. |
| `limits.symbols_per_connection`, `.quotes_symbols_per_connection` | integer | symbols | The plan's symbols per connection (`0`: quotes only); the quotes allowance (50). |
| `limits.subscribes_per_minute` | integer | topics | Topics attached a minute per key (1,200). |
| `neg_token.neg`, `.expires_in_s` | string, integer | seconds | The token for the socket's `neg`; good for 300 s, for any socket of this key. It is not a credential: the key still goes in `token`. |

```
curl -s -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"client": "capture", "features": ["ws", "zstd", "compact.plugin"]}' https://api.datafirsttrading.com/v1/stream/negotiate
```

Captured with a key that has no rate limit and no connection cap (`null`); a plan's key carries its numbers (a Trader key: `"sockets_cap": 2`, `"topics_cap": 150`, `"symbols_per_connection": 25`). The token's parts are named rather than shown:

```json
{"data": {
  "api": 1,
  "negotiation_id": "a62f1bf3e7ccb539",
  "features": ["ws", "zstd"],
  "ignored": ["compact.plugin"],
  "transports": {
   "rest": {"base": "/v1", "body_encodings": ["json"], "compression": ["gzip"]},
   "ws": {"enabled": true, "url": "/v1/stream", "dialects": ["zstd", "deflate", "json"], "body_encodings": ["json"], "compact": [], "beat_ms": 5000, "list_sep": "+", "every_ms": [1000, 2000, 5000, 10000, 15000, 30000, 60000], "gap_notice": true}
  },
  "topics": [
   {"name": "levels", "path": "/v1/symbols/{ticker}/levels", "params": [], "scopes": ["latest", "exp4", "exp5", "exp10", "exp20", "exp50"], "schema": 1, "encoding": "json", "kind": "snap"},
   {"name": "profile", "path": "/v1/symbols/{ticker}/strikes", "params": [], "scopes": ["latest", "exp4", "exp5", "exp10", "exp20", "exp50"], "schema": 1, "encoding": "json", "kind": "snap"},
   ...
  ],
  "catalogue": {"hash": "8d2697c932211421", "url": null, "inline": null},
  "limits": {"rate_bucket": "per_key", "rate_limit": null, "rate_window_s": 60, "rate_left": null, "sockets_cap": null, "sockets_open": 113, "topics_cap": 250, "symbols_per_connection": 50, "quotes_symbols_per_connection": 50, "subscribes_per_minute": 1200},
  "neg_token": {"neg": "a62f1bf3e7ccb539.<key id>.<expiry>.8d2697c932211421.<signature>", "expires_in_s": 300}
 },
 "meta": {"as_of": "2026-10-27T17:24:19Z"}
}
```

**Errors.** `422 invalid_parameter`: a query parameter, a body that is not a JSON object, a member too long or of the wrong type, more than 32 feature words, a body over 16 KiB.

### `POST /v1/stream/ticket`

A one-use ticket that opens `/v1/stream` as your key (1.0.39), for a web page that streams: the page never holds the long-lived key. Your server (or the DataFirst website's, for its members) calls this with the key and hands the page the ticket with the negotiation's `neg`; the page opens the socket with the ticket as `token` (1.0.61; `?ticket=` before). The ticket is good for one connection within 60 s, bound to the key and to the way in it was asked through (`site` or `api`), and the stream it opens is checked and closed exactly as a key-opened one.

**Parameters.** None; the key in the `Authorization` header, as every call. Refused as live data is (`401`, `403 key_revoked`, `attestation_required`, `attestation_pending`, `internal_only`), and counted against the key's rate and day quota as any API call.

**Returns.**

| Field | Type | Unit | Description |
|---|---|---|---|
| `ticket` | string | — | Opaque, starts `dft_tk_`; one use; send it as the socket's `token`. |
| `expires_in` | integer | seconds | The ticket's life from now: 60. |

The answer is `Cache-Control: no-store`.

```
curl -s -X POST -H "Authorization: Bearer $KEY" https://api.datafirsttrading.com/v1/stream/ticket
```

```json
{"data": {"ticket": "dft_tk_XNSMHomvajA46K36wOTAoYY6fAswhSDFwY_ZLU2Qcao", "expires_in": 60}, "meta": {"as_of": "2026-10-27T17:08:56Z"}}
```

**Errors.** A used, expired or unknown ticket opens the socket and closes it at once with `4001 invalid_ticket` (the close codes above).

## OAuth sign-in (for the hosted MCP)

(1.0.41) Connectors that cannot carry a pasted key, claude.ai, ChatGPT, the Claude and ChatGPT phone apps, connect to the hosted MCP (`https://mcp.datafirsttrading.com/mcp`) with a sign-in instead: the person signs in with their DataFirst account, confirms there that it was their own app that asked, and the app receives an **access token** that stands for them. **The key never reaches the app.** `https://api.datafirsttrading.com` is the OAuth 2.1 authorization server; the hosted MCP is the protected resource. Standards: RFC 6749 (authorization code), RFC 7636 (PKCE, `S256` required), RFC 7591 (dynamic client registration), OAuth Client ID Metadata Documents (an `https` URL as `client_id`, 1.0.60), RFC 8252 §7.3 (loopback redirects, any port, 1.0.60), RFC 8414 (server metadata), RFC 9728 (protected resource metadata), RFC 7009 (revocation). Public clients only: there is no client secret; PKCE binds the code to the app that started the sign-in.

**What a token is.** `Authorization: Bearer dft_at_<43 characters>` on any `/v1` request is the account's **newest live key**: the same tier, rate, day quota, history reach, OPRA attestation rules and sharing check, counted as API use. For the sharing check a connector counts as **one device** (`oauth:<client id>`) and **no address** (1.0.46): its calls come from its provider's addresses, not the person's, so a busy claude.ai session never looks like a shared key. An access token lives one hour; the refresh token (`dft_rt_...`) thirty days and is rotated at every refresh. A token that cannot be resolved (unknown, expired, revoked, the account has no live key, or that key is revoked) answers `401 unauthorized` with `detail` "sign in again" and a `WWW-Authenticate: Bearer error="invalid_token"` challenge, and the connector signs in again. A revoked token can answer for up to a minute more; a revoked **key** is refused at once.

**The flow** (what an MCP client does by itself; a person sees the DataFirst login and the consent page):

1. The client's first request to `https://mcp.datafirsttrading.com/mcp` without a credential is `401` with `WWW-Authenticate: Bearer ..., resource_metadata="https://mcp.datafirsttrading.com/.well-known/oauth-protected-resource"`; that document names the authorization server, `https://api.datafirsttrading.com`, whose `/.well-known/oauth-authorization-server` lists the endpoints below.
2. `POST /oauth/register` once per client installation, or the client names itself by the `https` URL of the metadata document it publishes (1.0.60; Claude's "Use Claude's published identity"), or it already has a `client_id`.
3. The person's browser opens `GET /oauth/authorize?...`: a valid request answers `302` to the DataFirst login, `https://app.datafirsttrading.com/login`, with `return_to` set to `https://api.datafirsttrading.com/oauth/continue?req=<request id>`. No page and no code is shown.
4. The person signs in there with their DataFirst account. The site then shows its **consent page**: it names the app and its address and says what is granted (your DataFirst market data access, at your plan's limits; revoke any time in Account); continue only if you started this from your own app. Login alone never approves anything: an attacker could start a sign-in in their own app and send a member the login link, and the consent page is where the member sees whose app asked. On Continue, the site's server approves the request (`POST /v1/oauth/approve`, below, with the request's user code, which it reads from `GET /v1/oauth/request/<id>`) and sends the browser to `return_to`.
5. `GET /oauth/continue?req=...` hands out the one-time authorization code and redirects to the client's `redirect_uri` with `code` and `state`.
6. The client exchanges the code at `POST /oauth/token` with its PKCE `code_verifier` and receives the pair. From then on every MCP request carries `Authorization: Bearer dft_at_...`, which stands for the member's key.

Everything under `/oauth/` and `/.well-known/oauth-*` (and the `404` at `/.well-known/openid-configuration`) is keyless and rate-limited per address as any request from outside; tokens travel in request bodies, never in paths. A sign-in request and its authorization code are short-lived, and the code works once; a code presented twice revokes every token it produced (RFC 6749 §4.1.2).

### `GET /.well-known/oauth-authorization-server`

RFC 8414 metadata of the authorization server, keyless, `Access-Control-Allow-Origin: *`, cacheable an hour. `issuer` is `https://api.datafirsttrading.com`. `offline_access` in `scopes_supported` (1.0.42) tells a client it may keep the connection across token expiry: every exchange answers an access token and a refresh token (refresh rotates the pair), as it always has; ChatGPT keeps a connector only when the scope is advertised, and Claude asks for it only when listed. `client_id_metadata_document_supported: true` (1.0.60) tells a client it may use the `https` URL of its own metadata document as its `client_id` instead of registering (below); dynamic registration stays.

```json
{"issuer": "https://api.datafirsttrading.com",
 "authorization_endpoint": "https://api.datafirsttrading.com/oauth/authorize",
 "token_endpoint": "https://api.datafirsttrading.com/oauth/token",
 "registration_endpoint": "https://api.datafirsttrading.com/oauth/register",
 "revocation_endpoint": "https://api.datafirsttrading.com/oauth/revoke",
 "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"],
 "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none"],
 "revocation_endpoint_auth_methods_supported": ["none"], "scopes_supported": ["datafirst", "offline_access"],
 "client_id_metadata_document_supported": true, "service_documentation": "https://api.datafirsttrading.com/docs/"}
```

### `GET /.well-known/openid-configuration`

(1.0.60) `404`: DataFirst is an OAuth 2.0 authorization server, not an OpenID Connect provider, and issues no ID tokens. Clients that probe OpenID discovery beside RFC 8414 see "absent" and use `/.well-known/oauth-authorization-server`. Keyless, CORS-open; the same for `/.well-known/openid-configuration/mcp` and `/mcp/.well-known/openid-configuration`. (The hosted MCP's host answers `404` on them too.)

```json
{"type": "about:blank", "title": "not found", "status": 404, "code": "not_found",
 "detail": "DataFirst is an OAuth 2.0 authorization server, not an OpenID Connect provider: its metadata is /.well-known/oauth-authorization-server"}
```

### `GET /.well-known/oauth-protected-resource`

RFC 9728 metadata of the hosted MCP, the same document on both hosts (`https://mcp.datafirsttrading.com/.well-known/oauth-protected-resource`, also with the `/mcp` suffix, and here), keyless, CORS-open.

```json
{"resource": "https://mcp.datafirsttrading.com/mcp", "authorization_servers": ["https://api.datafirsttrading.com"],
 "scopes_supported": ["datafirst", "offline_access"], "bearer_methods_supported": ["header"],
 "resource_documentation": "https://api.datafirsttrading.com/docs/"}
```

### `POST /oauth/register`

RFC 7591 dynamic registration, JSON body, public clients only.

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `redirect_uris` | body | string[] | required | 1 to 10 URLs the code may be sent to: `https://...`, or `http://localhost` / `http://127.0.0.1` (any path, with or without a port) for a desktop client. No fragment. A loopback URL matches at sign-in whatever port the client listens on then (1.0.60, `GET /oauth/authorize`). |
| `client_name` | body | string | none | Shown to the person on the sign-in page and the site's consent page (at most 200 characters). |
| `token_endpoint_auth_method` | body | string | `none` | Must be `none`: there are no client secrets. |

**Returns** `201` with `client_id` (`dft_c_...`), `client_name`, `redirect_uris`, `client_id_issued_at` (epoch seconds), `token_endpoint_auth_method: "none"`, `grant_types`, `response_types`, `scope`. **Errors** (RFC 7591 shape, `{"error", "error_description"}`): `400 invalid_redirect_uri`, `400 invalid_client_metadata`.

```
curl -s -X POST https://api.datafirsttrading.com/oauth/register -H 'Content-Type: application/json' \
  -d '{"client_name": "Claude", "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]}'
```

```json
{"client_id": "dft_c_...", "client_name": "Claude", "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"], "client_id_issued_at": 1792780800,
 "token_endpoint_auth_method": "none", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scope": "datafirst"}
```


### Client ID Metadata Documents

(1.0.60) Instead of registering, a client may use as its `client_id` the `https` URL of a JSON document it publishes about itself. At `GET /oauth/authorize` DataFirst fetches that document and uses it as the client's registration; at `POST /oauth/token` the URL is the `client_id` like any other.

```json
{"client_id": "https://app.example/oauth/client-metadata.json", "client_name": "Example App",
 "redirect_uris": ["https://app.example/oauth/callback", "http://localhost/callback"], "token_endpoint_auth_method": "none"}
```

| Field | Type | Description |
|---|---|---|
| `client_id` | string | Required; exactly the document's own URL. |
| `redirect_uris` | string[] | Required; 1 to 10, each `https://...` or `http://localhost`, `http://127.0.0.1` or `http://[::1]` (loopback, any port at sign-in), at most 2,000 characters, no fragment. The sign-in's `redirect_uri` must be one of them. |
| `client_name` | string | Optional. Shown with the URL's host, "Example App (app.example)", on the sign-in page, the site's consent page and Account's list of connectors, so a document cannot borrow another app's name unseen. |
| `token_endpoint_auth_method` | string | Absent or `none`: public clients only, PKCE `S256` as for every client. |

Other fields are ignored. **The URL**: `https`, the default port, a path (no `.` or `..` segments), no user name, password or fragment, at most 2,000 characters. **The fetch** is strict: public addresses only, checked when the connection is made (after every DNS lookup and on the address actually connected to), so a private, loopback, link-local (cloud metadata), carrier-grade NAT or unique-local address is never reached; redirects followed only to `https`, at most three; 5 seconds in all; at most 64 KiB; JSON. **Kept** per URL for its `Cache-Control` `max-age` (or `s-maxage`), at most 24 hours, five minutes when it sends none, not at all on `no-store` / `no-cache`; a refused document is remembered a minute (an unreachable host ten seconds) and not fetched again in that time. A document that will not do refuses the sign-in with the `400` page naming why ("the app's client metadata document is larger than 64 KiB", "... names another client_id than its own URL", "... is on <host>, which is not a public address", ...); nothing is stored. Each fresh copy is recorded as the client's registration under its URL, so revoking it in Account, the connectors list and every rule below apply as for a registered client.

### `GET /oauth/authorize`

Starts a sign-in and sends the browser to the DataFirst login (`302`).

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `response_type` | query | string | required | `code`. |
| `client_id` | query | string | required | From registration, or the `https` URL of the client's metadata document (1.0.60, above). |
| `redirect_uri` | query | string | required | One of the client's registered URLs, character for character, with one exception (1.0.60, RFC 8252 §7.3): an `http://localhost`, `http://127.0.0.1` or `http://[::1]` URL matches a registered one with the same host, path and query whatever the port on either side, since a desktop client (Claude Code) listens on a new port each session. `localhost` and `127.0.0.1` are not interchangeable; an `https` URL's port is never ignored. |
| `code_challenge` | query | string | required | base64url(SHA-256(`code_verifier`)), 43 characters. |
| `code_challenge_method` | query | string | required | `S256`. |
| `state` | query | string | none | Returned unchanged on the redirect (at most 1024 characters). |
| `scope` | query | string | none | `datafirst` (the only scope; it means the account's own access). |
| `resource` | query | string | none | `https://mcp.datafirsttrading.com/mcp` (RFC 8707); recorded with the request. |

**Returns** `302` to `https://app.datafirsttrading.com/login?return_to=https%3A%2F%2Fapi.datafirsttrading.com%2Foauth%2Fcontinue%3Freq%3D<request id>`, with `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The request carries a **user code** (`XXXX-XXXX`: 8 of `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`) that the site's consent page reads from `GET /v1/oauth/request/<id>` and sends back with the approval; it is not shown to the person. **Errors**: `400` (a page) for a `response_type` other than `code`, an unknown `client_id`, a `redirect_uri` the client did not register, a method other than `S256`, a malformed challenge; a refused request stores nothing and is never redirected.

### `GET /oauth/continue`

Where the site sends the browser after the consent page: the one-time code and the redirect to the client.

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `req` | query | string (UUID) | required | The request id from the `return_to` link. |

**Returns** `302` to `<redirect_uri>?code=dft_ac_...&state=...` (a loopback `redirect_uri` on the port the sign-in was begun from, 1.0.60) when the site approved the request (the code is handed out exactly once); `409` page "Finish signing in on the site, then return here" while it is not approved; `410` page when the code was handed out already, or the request expired; `404` for an unknown id. `Cache-Control: no-store`, `Referrer-Policy: no-referrer`.

### `POST /oauth/token`

RFC 6749 token endpoint, `application/x-www-form-urlencoded`, no client authentication (public clients), `Cache-Control: no-store`.

| Name | In | Type | Default | Description |
|---|---|---|---|---|
| `grant_type` | form | string | required | `authorization_code` or `refresh_token`. |
| `code` | form | string | with `authorization_code` | From the redirect. |
| `code_verifier` | form | string | with `authorization_code` | The PKCE verifier (43 to 128 characters of `A-Z a-z 0-9 - . _ ~`) whose SHA-256 was the `code_challenge`. |
| `client_id` | form | string | with `authorization_code` | The client that began the sign-in. |
| `redirect_uri` | form | string | with `authorization_code` | The one the sign-in began with (a loopback one: its port is not compared, 1.0.60). |
| `refresh_token` | form | string | with `refresh_token` | A live refresh token; it is revoked and a new one answered (rotation). |
| `client_id` | form | string | with `refresh_token` | The client the token was issued to. Another `client_id` is `invalid_grant` and the token is revoked. A refresh token presented again after it was rotated means two parties hold it: every token of that sign-in (the family) is revoked (RFC 6749 §10.4). |

**Returns** `200`:

| Field | Type | Unit | Description |
|---|---|---|---|
| `access_token` | string | — | `dft_at_` + 43 characters; `Authorization: Bearer` on every request to the MCP or `/v1`. |
| `token_type` | string | — | `Bearer`. |
| `expires_in` | integer | seconds | 3600. |
| `refresh_token` | string | — | `dft_rt_` + 43 characters; good for 30 days, one use (the refresh answers a new one). |
| `scope` | string | — | `datafirst`, when the sign-in asked for a scope. |

**Errors** (`400`, `{"error", "error_description"}`): `invalid_grant` (unknown, expired or used code; wrong `code_verifier`, `client_id` or `redirect_uri`; unknown, revoked or expired refresh token), `unsupported_grant_type`; `503 temporarily_unavailable` when the service is briefly unavailable.

```
curl -s -X POST https://api.datafirsttrading.com/oauth/token -d grant_type=authorization_code -d code=dft_ac_... \
  -d code_verifier=... -d client_id=dft_c_... -d redirect_uri=https://claude.ai/api/mcp/auth_callback
```

```json
{"access_token": "dft_at_...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "dft_rt_...", "scope": "datafirst"}
```

### `POST /oauth/revoke`

RFC 7009, form-encoded `token` (an access or a refresh token; revoking a refresh token revokes the access tokens issued with it). Always `200`, an unknown token included; `400 invalid_request` without a `token`. This is what a client calls when the connector is removed. A member revokes a connector, or all of them, in **Account** on the site, which holds no token: it uses the site calls below.

### The site's calls

Only the DataFirst sites call these, with `X-Site-Token`; they are listed here because they are `/v1` paths: `GET /v1/oauth/request/{request_id}` answers what the consent page shows (`client_name`, `client_id`, `user_code`, `created_at`, `expires_at`, `resource`, `approved`; `404` unknown, `410` expired or used); `POST /v1/oauth/approve` with `{request_id, user_id, user_code}` approves once (`401` without the site token, `404`, `409 code_mismatch` / `already_approved` / `no_live_key`, `410 gone`); `GET /v1/oauth/connectors/{user_id}` lists the member's live sign-ins, one per client (`client_id`, `client_name`, `signed_in_at`, `last_refreshed_at`, `expires_at`); `POST /v1/oauth/connectors/revoke` with `{user_id, client_id?}` revokes every token of the member, or of that one client (`{ok, revoked}`).

## OpenAPI

**Hosted reference** (1.0.24). The API serves its own documentation without a key: `/docs/` (an interactive reference over the OpenAPI document, with a try-it console that takes your key, generated cURL, Python and JavaScript samples and a response per status), `/docs/api.md` (this document as Markdown), `/docs/endpoints/<name>.md` (one endpoint's section, named as the MCP names its tool: `levels`, `history_flow`, `flow_leaders`), `/llms.txt` (the index agents read first), `/.well-known/openapi.json` and `/.well-known/skill.md` (an agent skill file: when to use the API, how to call it, what never to do).

`GET /v1/openapi.json` returns the OpenAPI 3.1.0 document for this API. Load it in Postman, Insomnia or any generator to get typed clients. Every numeric property carries `x-unit`.

## Compatibility: how the API grows without breaking you

The API will keep gaining fields, endpoints and datasets as tiers and history grow. Within `/v1` these rules hold, so a client written today keeps working:

- **Objects only grow.** Every response body is an object (`data`, `meta`), and every element of an array is an object; new fields are added to them; existing fields are not removed, renamed, retyped or re-scaled. The one exception was a one-time cleanup before public launch (1.0.61: a few fields removed and one error code renamed, listed in the Changelog); from here on, objects only grow. Read by name and ignore what you don't know.
- **Enumerations may gain values** (sides, consolidation types, regimes, dataset names); treat an unknown value as "other", never as an error.
- **New endpoints and new export datasets appear without a version change.** Discover them through `/v1/openapi.json` and each session's export manifest rather than a fixed list; manifests only add datasets and columns, and Parquet readers select columns by name.
- **Defaults, units and sign conventions do not change** within `/v1`; a different unit or convention is a new field.
- **Depth and limits only move in your favour**: a tier's reach, rate and burst may be raised, never lowered, without notice.
- **Headers and errors are stable**: the `RateLimit-*`, `Age`, `ETag`, `X-History-Earliest` headers and RFC 9457 problem bodies keep their names (1.0.61's one-time cleanup renamed one code); new headers may be added.
- **Anything else is `/v2`**, run beside `/v1` for at least twelve months, with the differences listed in this document's Changelog and announced on `/v1/me` (`notices`, a field reserved for that purpose).

## Errors

Every error is an RFC 9457 problem document (`application/problem+json`) with `type`, `title`, `status`, `detail` and, since 1.0.23, a stable `code`. Branch on `code`, show `detail` to a person. `title` and `detail` may be reworded; `code` and `status` will not change meaning.

| Status | `code` | When | Retry |
|---|---|---|---|
| 400 | `bad_request` | The request could not be read. | No. |
| 401 | `unauthorized` | No key, or an unknown key; or (1.0.41) an OAuth access token that is unknown, expired or revoked, or whose account has no live key (`detail`: "sign in again", with a `WWW-Authenticate: Bearer` challenge). | No; sign in again. |
| 403 | `attestation_required` | The account has no current OPRA subscriber attestation: none, or (1.0.40) one on an older release of the subscriber agreement; `detail` then names the current release. | No; attest first (sign the current release). |
| 403 | `attestation_pending` | (1.0.39) The account is not attested yet and support has given it grace: finished sessions only, and this request may carry today's data (a live endpoint, the stream, no date, or a range open at the top). `detail`: "Your access is limited to finished sessions until your OPRA attestation is complete." | No; ask for a finished session, or attest. |
| 403 | `plan_ended` | (1.0.44) The plan ended (`plan_until` on `/v1/status`, plus a day for a renewal to land) and the account is a professional subscriber: no data, on any endpoint or the stream, until the plan is renewed; `detail` names the date. A non-professional whose plan ended is not refused: the key reads as the Free plan (Conventions, "Plan end"). | No; renew on the site. |
| 403 | `access_denied` | Not answered since 1.0.56: every plan reaches the API at its own limits. Until 1.0.55, the key was not enabled for this way in (`access` on `/v1/me`). | No. |
| 403 | `internal_only` | A trial-period refusal, not answered since 2026-10-24: every attested account reaches live data. | No. |
| 403 | `key_revoked` | The key was used from too many addresses or devices. A connector signed in with OAuth counts as one device (`oauth:<client id>`) and no address (1.0.46). | No; contact support. |
| 403 | `history_out_of_reach` | The session asked for is before the tier's reach; `X-History-Earliest` says the first allowed. Every date the request names counts and the oldest decides, in either date form (`2026-10-22` or `20261022`) (1.0.39). | No; ask for a later session. |
| 404 | `no_such_session` | (1.0.29) `/v1/sessions/{date}`: the day is not a listed finished session. | No; pick a date from `GET /v1/sessions`. |
| 422 | `invalid_date` | (1.0.29) `/v1/sessions/{date}`: the path date is not `YYYY-MM-DD`. | No; fix the date. |
| 403 | `export_not_in_tier` | `/v1/export` is a Quant right. | No. |
| 409 | `code_mismatch`, `already_approved`, `no_live_key` | (1.0.41) `POST /v1/oauth/approve` (the site's server): the user code is not the sign-in request's; the request was approved already; the account has no live key at all (a sign-in never completes into `401`s). | No. |
| 410 | `gone` | (1.0.41) `POST /v1/oauth/approve`, `GET /v1/oauth/request/{id}`: the sign-in request expired or was used. | No; start again from the app. |
| 403 | `symbol_not_in_tier` | (1.0.27) `detail`: "your plan doesn't include this symbol" (Free reaches a limited set). | No; a plan with every symbol. |
| 403 | `compatible_api_not_in_plan` | (1.0.57) The request came through one of the compatible APIs and the key's plan has none (Free, Basic, Trader); that API answers in its own error shape with this `detail`, which names the plan and the plans that include them. The key's own `/v1` rights are unchanged. | No; Developer and Quant include them. |
| WebSocket close `4003`; in-band `err` | `connection_limit`, `connect_rate`; `not_on_plan`, `stream_symbols_over_plan`, `quotes_over_allowance` | (1.0.57; codes and reasons since 1.0.61) The plan's stream gates: the key's connections already open, or its connection attempts a minute; a family other than quotes on a plan without a stream; a subscribe past the plan's symbols per connection or the 50 quotes (an `err` message, the subscribe not made, the socket open). The WebSocket section lists every close code and refusal. | Connections: after closing one. Symbols: with fewer. |
| 404 | `unknown_symbol` | No listed options for that root. | No. |
| 404 | `no_data`, `no_history`, `no_aggregates`, `no_export`, `no_such_dataset`, `file_missing`, `not_found` | Nothing recorded for what was asked (a session, a symbol-day, an export dataset). | No, unless the session is still being prepared. |
| 404 | `not_built` | A finished session's playback answer or chain file is not ready yet; asking queues it. Since 1.0.62 the body carries `ready_by` (RFC 3339 UTC, when it is expected) and `queue_position` (requests ahead, counting this one), and `Retry-After` is sent (Sessions not ready yet). | Yes, after `Retry-After`. |
| 404 | `not_available` | (1.0.62) `history/chain`: the session is before the earliest day a chain file can be built; nothing is queued. | No. |
| 404 | `no_session` | The `date` asked for fell on a weekend or a market holiday: there was no session, so nothing is owed (1.0.57). | No: pick a session day. |
| 422 | `invalid_parameter`, `invalid_range`, `range_too_long`, `unknown_part`, `unknown_interval`, `one_session_per_call`, `finished_session_required` | A parameter is missing, out of range or not served in this combination; `detail` names it. | No; change the request. |
| 429 | `rate_limited` | Over the key's `rate` and `burst`; `RateLimit-*` and `Retry-After` are sent. | Yes, after `Retry-After`. |
| 429 | `address_rate_limited` | Over the address's ceiling across keys (Conventions, "Per-address ceiling"). | Yes, after `Retry-After`. |
| 429 | `quota_reached_day` | (1.0.27) The tier's API calls for the New York day are spent; `Quota-*` and `Retry-After` (seconds to midnight New York) are sent. The website's requests for you never count. | Yes, after `Retry-After`. |
| 429 | `too_many_in_flight` | Too many heavy reads (chain files, session ranges, exports) at once for this key. | Yes, after `Retry-After`, when one finishes. |
| 503 | `busy_loading_sessions` | (1.0.29) Too many past sessions are being prepared at once. | Yes, after `Retry-After`. |
| 503 | `temporarily_unavailable` | The service is briefly unavailable; `Retry-After` is sent. | Yes, after `Retry-After`. |
| 503 | `plans_unavailable` | (1.0.56) `GET /v1/plans` while the plan table is briefly unavailable. | Yes, after `Retry-After`. |
| 503 | `audit_unavailable` | Data access is temporarily unavailable. | Yes, retry later. |
| 504 | `timeout` | The answer took longer than its time limit (Conventions, "Time limits"). | Narrow the request (fewer sessions, strikes or expirations), then yes. |

```json
{"type": "about:blank", "title": "beyond this tier's history", "status": 403, "code": "history_out_of_reach",
 "detail": "this key's tier (standard) reaches back to 2025-10-18; 2025-06-02 was asked for"}
```

A key in support's grace asking for live data (1.0.39):

```json
{"type": "about:blank", "title": "attestation_pending", "status": 403, "code": "attestation_pending",
 "detail": "Your access is limited to finished sessions until your OPRA attestation is complete."}
```

## Sessions not ready yet

Every session is kept. A request that needs a past session that is not ready at that moment does not wait for it: it is
answered at once with

```
GET /v1/symbols/IWM/history/heatmap?date=2026-09-04&interval=5

HTTP/1.1 202 Accepted
Retry-After: 3
Content-Type: application/json; charset=utf-8

{"status": "warming", "retry_after_s": 3, "symbol": "IWM", "date": "2026-09-04"}
```

Ask again after `Retry-After` seconds: the same request then answers `200` with the usual body. The body repeats the request's `symbol` (or `ticker`, `tickers`), `date`, `from` and `to`. It
applies to `history/levels`, `history/flow`, `history/heatmap`, `history/oi`, `/v1/trades`, `/v1/trades/stats`, and
`/v1/tape` and `/v1/sweeps` with `date`; a range (`from`/`to`) prepares every session it needs at once. Today's and live
answers never answer `202`. The `datafirst` client, the MCP and the compatible APIs wait and ask again by themselves. (1.0.33.)

**An answer that is built on request** (1.0.62): a chain file (`history/chain`) or a playback answer (`history/replay`) that
does not exist yet is asked for by the request itself and answered at once `404 not_built`, saying when it will be ready:

| Field or header | Type | Unit | Description |
|---|---|---|---|
| `ready_by` | timestamp | RFC 3339 UTC | When the answer is expected, to the minute: an estimate, which can be minutes away. Ask again after it. |
| `queue_position` | integer | requests | Where the request stands among those waiting to be built, counting it: `1` is next. A range counts to the last session it asked for. |
| `Retry-After` | header | seconds | Seconds until `ready_by`, at least 60. |

```
GET /v1/symbols/NVDA/history/chain?date=2026-10-27&interval=1m

HTTP/1.1 404 Not Found
Retry-After: 16368
Content-Type: application/problem+json; charset=utf-8

{"type": "about:blank", "title": "not built", "status": 404, "code": "not_built",
 "detail": "not built yet; queued, expected by 2026-10-28T21:38:00Z", "ready_by": "2026-10-28T21:38:00Z", "queue_position": 4}
```

Asking again before then does not move the request back in line. A chain file for a session before the
earliest day one can be built is never asked for: `404 not_available` ("before the earliest day we can build"); a chain file
already kept for such a day is still served.

## Limits, versioning and support

Rate limits are per key and shown on every response; for higher limits contact support. Breaking changes will arrive under `/v2` with at least 90 days' notice; additions to `/v1` are non-breaking: new fields may appear, existing ones will not change meaning or unit.

## Coming

What is planned next for this API, in the order it is coming. An item leaves this list the day it ships and appears in the Changelog below with its version.

- **Per-minute volatility history.** The implied-volatility surface through a session per minute (every 5 minutes it is already in the chain file, `history/chain`).
- **Raw quotes at a past minute** (Quant, sessions a day old or older, a per-key budget): every change of the best bid and offer in a minute for one symbol's chain, and the chain as it stood at that minute.
- **Futures views.** Prices on a futures contract's axis (SPX and SPY on ES, NDX and QQQ on NQ, and the others), on REST and in the stream's view segment.

## Changelog

What changed for clients, newest first.

| Version | Date | Change |
|---|---|---|
| 1.0.65 | 2026-10-29 | Crossed quotes (`bid > ask`) cannot set parity spot, implied forwards or solved IV; existing IV fallback retains eligible contracts and OI. Closing-minute recording precedes Replay answer builds. Trade-query polls capture all shard endpoints before processing. |
| 1.0.64 | 2026-10-29 | Flow leaders, sectors, breadth, unusual activity, baselines and history honor an explicit `date` exactly, returning `404` when unavailable. Omit `date` to select the newest available completed session. |
| 1.0.63 | 2026-10-28 | `history/replay` rows (both parts) are stamped with the moment of the state they show, the `as_of` of the same row on `history/levels`, and carry that moment's walls and strike profile; they were stamped with the minute at :00. Sessions already built are rebuilt in the new form. |
| 1.0.62 | 2026-10-28 | An answer built on request that is not ready yet (`history/chain`, `history/replay`) says when it will be: `404 not_built` carries `ready_by` and `queue_position`, with `Retry-After`. A chain file for a session before the earliest buildable day answers `404 not_available` and is not queued. |
| 1.0.61 | 2026-10-27 | The stream, rebuilt: `POST /v1/stream/negotiate` before every socket, one socket for every topic (`{family}:{TICKER}:{scope}:-`), each message carrying its REST twin's body, MessagePack or JSON frames, a fixed set of streamable shapes (the scopes), the `quotes` family (up to 50 a connection on every plan), `drift` sending only its new points, the core names always live; a client of the earlier stream: "Moving from the earlier stream" in the WebSocket section. `GET /v1/symbols/{symbol}/intraday?bars=N` and `GET /v1/symbols/{symbol}/flow?buckets=N`: the newest N bars or buckets. `GET /oauth/authorize` sends a valid sign-in straight to the DataFirst login (`302`); the code is no longer shown, and the consent page names the app. **A one-time cleanup before public launch** (Compatibility): the levels answer, `meta` and `GET /v1/symbols/{symbol}/spot` dropped internal fields; one `503` code was renamed, to `temporarily_unavailable` (with `Retry-After`, as before); `symbols` on `/v1/plans` (`limits`), `/v1/status` (`reach`) and `/v1/me` is `"all"` or `"limited"` (it listed the symbols; on `/v1/me` it was a list or `null`), and `symbol_not_in_tier` reads "your plan doesn't include this symbol". |
| 1.0.60 | 2026-10-27 | OAuth sign-in: an `https` URL of a client's metadata document may be its `client_id`; a loopback `redirect_uri` matches whatever its port; `/.well-known/openid-configuration` answers `404`. |
| 1.0.59 | 2026-10-27 | A same-day contract's time to expiry runs from its quotes' moment (`meta.as_of`); today's expiration leaves `/expirations` and the `expirations=N` windows at the 16:00 New York close, finished sessions keep it. |
| 1.0.58 | 2026-10-26 | `GET /v1/me` gains `addons` and `multi_indicator`. |
| 1.0.57 | 2026-10-26 | A past day's bars (`/intraday?date=`) are finished-session data; today's bars cover the whole day; a weekend or holiday `date` answers `404 no_session`; the plan's stream limits and compatible-API access are enforced. |
| 1.0.56 | 2026-10-26 | Real open, high, low and close for indexes' past-session minutes; an index's bars start at 09:30 New York; every plan reaches the API; `meta.settlement` declared; the export manifest gives each column its unit. |
| 1.0.55 | 2026-10-26 | `/intraday` without a date answers today's bars only, from 04:00 New York. |
| 1.0.54 | 2026-10-25 | Every contract with open interest counts (`meta.oi_floor` 1). |
| 1.0.53 | 2026-10-25 | `If-None-Match` is compared the weak way, so revalidating through a proxy answers `304`. |
| 1.0.52 | 2026-10-25 | Finished-session answers carry an `ETag` and take `rev=` for permanent caching; an undetermined flip or wall is `null`; the per-address ceiling is the larger of 20 requests/s and the key's plan rate. |
| 1.0.51 | 2026-10-25 | Past sessions' bars carry real open, high, low and close. |
| 1.0.50 | 2026-10-25 | On a weekend or holiday `/expirations` lists every expiration of the last session. |
| 1.0.49 | 2026-10-25 | On a weekend or holiday, `/expirations` and `/intraday` answer from the last session. |
| 1.0.48 | 2026-10-25 | `GET /candles` and `GET /quotes`: daily and weekly candles and batch quotes with the day's change. |
| 1.0.47 | 2026-10-25 | A key's rate and burst are enforced exactly, and `RateLimit-Remaining` is the whole key's. |
| 1.0.46 | 2026-10-25 | A connector signed in with OAuth counts as one device and no address for the sharing rule. |
| 1.0.45 | 2026-10-25 | The plan ladder: Free, Basic, Trader, Developer and Quant, with their rates, quotas and history reach (Tiers). |
| 1.0.44 | 2026-10-25 | `plan_until` on every key: a professional's ended plan is `403 plan_ended`, a non-professional's key reads as Free. |
| 1.0.43 | 2026-10-24 | `GET /v1/status`: plan, mode, usage and reach in one free read. |
| 1.0.42 | 2026-10-24 | `offline_access` advertised in the OAuth metadata. |
| 1.0.41 | 2026-10-24 | OAuth sign-in for the hosted MCP. |
| 1.0.40 | 2026-10-24 | An account on an older release of the OPRA subscriber agreement signs the current one before live data (`403 attestation_required`). |
| 1.0.39 | 2026-10-24 | Grace (`403 attestation_pending`, finished sessions only), `GET /v1/me` for every valid key, stream tickets, streams closed when their key is refused. |
| 1.0.38 | 2026-10-23 | `/intraday` starts a symbol's live bars at its first read, with the day's earlier minutes for a stock. |
| 1.0.37 | 2026-10-23 | `/intraday` keeps the day's earlier minutes at every hour. |
| 1.0.36 | 2026-10-22 | The tier `pro` is named `developer`, with the same limits. |
| 1.0.35 | 2026-10-22 | VIX's price is the VIX index. |
| 1.0.34 | 2026-10-22 | `/v1/trades/stats` gains `offset`. |
| 1.0.33 | 2026-10-22 | A past session not ready yet answers `202` with `Retry-After` (Sessions not ready yet). |
| 1.0.32 | 2026-10-22 | `403 internal_only` for the trial period (not answered since 2026-10-24). |
| 1.0.31 | 2026-10-21 | `meta.futures` also on IWM, RUT, DIA and DJX; a running session's sub-minute history stops at the current second; one row per minute in today's bars. |
| 1.0.30 | 2026-10-21 | `/v1/trades/stats` honours `limit` and gains `sort`. |
| 1.0.29 | 2026-10-20 | `GET /v1/sessions/{date}`; `/v1/symbols/{symbol}/reference` gains `sessions`. |
| 1.0.28 | 2026-10-19 | `GET /v1/me` in the OpenAPI document. |
| 1.0.27 | 2026-10-19 | Tiers with rate, burst, a daily API quota, history reach and the Free symbol list; `Quota-*` headers. |
| 1.0.26 | 2026-10-18 | Charm's sign corrected; `meta.time_to_expiry_years` declared. |
| 1.0.24 | 2026-10-18 | `strikes=N` or `all` on `strikes` and `heatmap`; the hosted reference (`/docs/`, `/llms.txt`, `/.well-known/skill.md`). |
| 1.0.23 | 2026-10-18 | A stable `code` on every error; time limits, heavy reads in flight and the per-address ceiling; `limits` on `/v1/me`; the retry rule. |
