- Changed**One answer for a wrong column name, everywhere.** Every place a column name enters the API — `/v2/scan` `q`, `fields` and `order` (live and `asof`), `/v2/series` `columns`, `/v2/signals/{signal}`, custom signal expressions, and the `q` / `columns` of a webhook or subscription — now checks the name against the published catalog the same way and says the same thing when it is wrong: `400 invalid_query` (`404 unknown_signal` on the signals path) with `Unknown column <name>. It is not a published signal. Did you mean <closest published names>? Catalog: /v2/signals`. Before, the wording depended on which check caught it first, and the as-of read said `not available at interval=1d` about names that exist at no interval.
- Changed**Renamed-only columns from the 2026-09-07 percent cutover are served under their old names.** `atr_percent`, `free_float_percent`, `percent_insiders`, `percent_institutions`, `short_percent_of_float` and `bollinger_pct_b` — the six whose values did not change — resolve to `atr_pct`, `free_float_pct`, `insiders_pct`, `institutions_pct`, `short_float_pct` and `bollinger_b` anywhere a column is named, and the response says so in `_meta.deprecated_columns` (`requested`, `use`, `note`). A rename that did not change a value should never have broken a job. The 22 rescaled columns (fraction → percent) stay a 400 with the rename sentence: serving those under the old name would hand back the wrong unit. A custom signal saved with an old name is stored with the new one.
- ChangedA column the catalog does not publish is refused in a `/v2/scan` predicate as it already was in `fields` — the same rule on both, not only on what comes back.
Reference
Changelog
Notable changes to the API. Newest first. Breaking changes are announced here.
- Changed**Context refs in `q` accept any catalog symbol.** `<ticker>.<column>` on `/v2/scan` and webhook `q` was limited to 15 ETFs (SPY, QQQ, IWM, DIA and the SPDR sectors); it now takes any symbol `/v2/tickers` returns — an equity (`aapl.day_change_pct > 0`), a rate (`r:sofr.close > 4`), a crypto or FX pair (`x:btcusd.day_change_pct`). The cap of five refs per query is gone; the 4,000-character limit on `q` is the only bound. Dotted symbols (`BRK.B`) cannot be qualifiers.
- Added`400 unknown_context_ticker` names a qualifier that is not in the catalog, checked before the query runs. A typo used to come back as a raw SQL error. `400 context_ticker_ambiguous` covers a qualifier that case-folds to two symbols.
- ChangedTicker ids may be up to 64 characters and contain underscores, ahead of the universe rows (`U:big_tech`) landing in the catalog.
- Fixed**`GET /v2/signals/{signal}` and the `signal` trigger accept timestamp, date and string signals.** The `condition` grammar was numeric-only and every non-boolean signal was parsed as a number, so the 52 timestamp, date and string signals the catalog lists (`price_asof`, `earnings_date`, `asset_class`, …) returned 400 whatever you sent. `condition` is now typed like the signal: `<2026-09-22T19:00:00Z` for a timestamp, `>=2026-10-01` for a date, `=ETF` or `!=ETF` for a string; numerics are unchanged. The 400 for a missing or mis-shaped condition names the signal's type and the shape it takes. Same grammar on the live read, the as-of read, `POST /v2/signals/{signal}/subscribe` and `POST /v2/webhooks` with a `signal` trigger. Relative windows ("older than 15 minutes") stay a `/v2/scan` query.
- Added**`price_asof` on the live ticker row** (`GET /v2/tickers/{ticker}`, scan columns, the stream): when the market data behind the row's price was produced, as an ISO timestamp. `as_of` is when we answered; `as_of` minus `price_asof` is the age of the row's live signals. A stale `price_asof` during the session means a halted or illiquid name, and `q="price_asof < now() - interval '15 minutes'"` on `/v2/scan` finds them, which nothing could do before. Live-only: the as-of reads already disclose their row time in `_meta.sources`. Note the name: this account's trades and quotes arrive 15 minutes delayed while the aggregates behind `price` are current, so a last-trade time would have mislabelled a fresh row as stale; `price_asof` stamps whichever source produced the price.
- Removed**The ticker row returns only the columns the catalog lists.** `GET /v2/tickers/{ticker}` was `SELECT *` and returned 46 columns `/v2/signals` never advertised. Gone from the live row: 22 internal fields (every `_`-prefixed name: `_last_trade_timestamp`, `_repaint_state`, `_data_quality`, `_branding_icon_src`, `_min_timestamp`, …) and 23 dead ones (`bid`, `ask`, `bid_size`, `ask_size`, `market`, `ex_dividend_date`, `gap_pct_v2`, `breaking_above_20d_high`, `breaking_below_20d_low`, the seven `guidance_*` / `last_guidance_*` fields, and six unpublished financial-statement lines). The as-of read (`?asof=`) and `full=true` on `/v2/scan` hide the same two statuses on the history tables (for example `average_volume`, `consolidating`, `macd_above_zero`, `volume_spike`, `definition_version`). `fields` on `/v2/scan` now rejects a name the catalog does not list with `400 invalid_query` instead of returning it. If you read `_last_trade_timestamp` for the market-data time, `price_asof` is the published replacement. Everything `/v2/signals` lists is unchanged, on every plan.
- ChangedStream `update` frames: `as_of` is now the time the frame was pushed, the same meaning as on every REST response. It was the minute-bar time, which read as two minutes staler than the `price_asof` in the same frame.
- Added`GET /v2/health` also carries a `log` block: console errors and warnings in the last five minutes and the last error line, so a fault in the API process is visible from a plain HTTP probe.
- Fixed`_meta.null_coverage` on a live `/v2/scan` counted a live-only column such as `price_asof` as NULL on every row while the rows carried values (it measured the history copy of the table). It now measures the table the scan read.
- Fixed**`/v2/stream` delivers again.** From 2026-09-07 the stream sent no `update` frames to subscriptions with `fields:"all"`, and from 2026-09-18 to none at all: a column named in the stream's field list no longer existed on the table (first `atr_percent`, renamed to `atr_pct` by the percent cutover; then `52_week_high` and `52_week_low`, which the stream's own identifier check rejected), and because the stream reads one query for every subscriber, one bad name failed every push for everyone. Auth, `subscribed` acks and `pong` kept working, so a client saw a healthy connection that never spoke. Fixed: the field list is checked against the live table before each push and a stale name is dropped for the subscribers who asked for it instead of failing the tick; the identifier check is the shared one every other endpoint uses; and the stream now reads the live database, so `price_asof` streams with a value. If you subscribed in that window and saw nothing, nothing was wrong with your client.
- Added`GET /v2/health` carries a `stream` block: connections, subscribed tickers, last delivery time and `fanout_failures_10m`, so a stalled stream is visible from a plain HTTP probe. The three fields the route already returned (`rss_mb`, `event_loop_lag_ms`, `uptime_s`) are now documented.
- Removed**Deprecated routes are no longer listed in `/openapi.yaml`, the endpoint index (`/docs/endpoints`, `/docs/endpoints.md`) or `/llms-full.txt`.** The five reads sunsetting on 2026-10-31 (`GET /v2/tickers/{ticker}/history/{interval}`, `GET /v2/tickers/{ticker}/events`, `GET /v2/signals/{signal}/{ticker}/history/{interval}`, `GET /v2/signals/{signal}/{ticker}/events`, `GET /v2/analyst/events`) were still in the spec with `deprecated: true`, and new integrations kept being generated against them. They still serve, still carry `Deprecation`/`Sunset`/`Link` headers, and their docs pages stay up until the sunset; they are just not offered as current to a code generator, an agent or a reader of the index. An alias (an old spelling that serves forever, such as `/v2/tickers/{ticker}/bars/{interval}`) is documented only on the page of the route it aliases. Successors: [`/v2/series`](/docs/endpoints/series/state) and [`/v2/events`](/docs/endpoints/events/query).
- Changed**As-of reads name the company that held the symbol that day.** `GET /v2/tickers/{ticker}?asof=` used to serve a reused symbol's old prices under today's holder (`SHLD?asof=2010-06-30` returned Sears' 2010 tape labelled as the Global X Defense Tech ETF). Now, when a different company held the symbol at the requested date, `name`, `cik` and `composite_figi` are that company's, `held_at_asof` is true with its tenure window, `_meta.identity` names it, and the current holder's classification fields (`sector`, `industry`, `asset_type`, `exchange`, `asset_class`, `ticker_category`) are null rather than wrong. Symbols held by one company throughout are unchanged.
- Added**`issuer_country` and `incorporation_country`** on the ticker row (`GET /v2/tickers/{ticker}`, scan columns): where the company is run from and where it is legally incorporated, as ISO 3166-1 alpha-2 codes, from the issuer's SEC EDGAR filings. `HUIZ` reads `issuer_country: cn`, `incorporation_country: ky`; `AAPL` reads `us` for both. `country` is unchanged: it is the listing locale and reads `us` for every US-listed symbol, ADRs included. Both new fields are null for symbols with no SEC filer identifier (most ETFs, non-SEC filers). Refreshed nightly for new and changed filers, in full monthly.
- Fixed**Company icons and logos now load.** `branding_icon_url` and `branding_logo_url` on the ticker row used to be the image host's own URLs, which answer `401` to anyone without that host's API key — so to every customer the two fields were dead links. They now point at `GET /v2/tickers/{ticker}/icon` and `GET /v2/tickers/{ticker}/logo`, which serve the image bytes (PNG, JPEG, SVG or WebP, with the right `Content-Type`) under your own Tickerbot key — the same `Authorization: Bearer` header as every other call. Both fields stay `null` when the issuer has no image on file. Responses carry a year-long `Cache-Control` and an `ETag`; the image behind a URL never changes (a new image is a new nightly row value).
- Changed**Parameter values are checked at the door.** Every documented `enum`, `integer` and `boolean` on a public endpoint is now enforced before the handler runs: an out-of-vocabulary value (`dir=up`, `interval=5m`, `kind=earnings,foo`, `asset_type=crypto`) or a mis-shaped one (`limit=abc`, `full=maybe`) is a `400 bad_request` naming the parameter and the accepted values. Previously each handler decided for itself and a few fell through silently (a junk boolean read as `false`; an unknown `asset_type` matched nothing). Enum matching is case-insensitive; booleans accept `true/false/1/0/yes/no`. The docs enums, the MCP tool enums and the server now read one constant, so a value the docs list is a value the API takes.
- Fixed`GET /v2/tickers` documented `exchange` as a closed list that included `NYSE Arca`, `Cboe BZX`, `NYSE American` and `OTC Link` — values the endpoint has always rejected (letters only). It is an open filter: any exchange name or MIC code, as the description said. The list is gone.
- FixedComma-list parameters are typed consistently as `string[]` across every page (scan `columns`/`group_by`/`select`/`asset_class`, analyst `tickers`, webhook `columns`); `action` and `transition` on `/v2/events` now show their closed lists.
- Added**MCP tools now declare what they return.** Every tool on `api.tickerbot.io/mcp` (and the npm server) carries an `outputSchema` — the same key set and field descriptions as the endpoint's Returns table, derived from one declaration — and `tools/call` returns `structuredContent` beside the text block. The server negotiates the protocol version (2025-06-18, 2025-03-26 or 2024-11-05, whichever your client asks for) instead of pinning 2024-11-05; both additions are additive, so existing clients keep working unchanged.
- Fixed**Every endpoint documents exactly what it returns.** The eleven write endpoints (create/update webhook, enable, test, the four subscribe doors, create/update universe, create/update custom signal) had no Returns section; they do now, from the live wire. Bars gained `adjusted` and `session` on 2026-09-14 and the page never said so; scan, events and news aggregate mode return `truncated` (and no `next_cursor`); the as-of reads of a ticker or a signal carry `_meta`. Each Returns table is now held equal to the server's own declaration in CI, and the sandbox's live responses are checked against the same declaration, so a field cannot appear or disappear undocumented again.
- ChangedMCP: tool names now follow the docs rail — one name per endpoint, derived from the same source as the docs. Renamed: `tickerbot_list_signals_catalog` → `tickerbot_list_signals`, `tickerbot_get_signals_match` → `tickerbot_get_signal`, `tickerbot_get_ticker_bars` → `tickerbot_get_bars`, `tickerbot_get_ticker_holdings` → `tickerbot_get_etf_holdings`, `tickerbot_get_ticker_sectors` → `tickerbot_get_etf_sectors`, `tickerbot_patch_webhook` → `tickerbot_update_webhook`. Folded: `tickerbot_get_ticker_history` into `tickerbot_get_ticker` (pass `asof`), `tickerbot_list_system_universes` into `tickerbot_list_universes` (pass `owner=system`). `tickerbot_scan` now posts to `POST /v2/scan`; `tickerbot_search_news` reads the canonical `GET /v2/news`. Every retired name answers with a tombstone naming its successor, as the 2026-08-07 retirements do. MCP clients refetch the catalog at session start, so no client config changes.
- ChangedMCP: every tool description and parameter list is now the same text the endpoint docs show, plus a short agent-only note where a model needs one. Previously a second hand-written copy — it had drifted (the events tool omitted earnings; three tools kept names from before the bars and ETF path families).
- Changed**`invalid_query` messages are written for the reader.** Three mistakes in a `q` used to come back as raw database text. An unknown column (`rsi_99 < 30`) now reads `Unknown column rsi_99. Did you mean rsi_14 or rsi_7? Catalog: /v2/signals`. A numeric or text column standing alone where a condition is needed (`rsi_14`) reads `rsi_14 is a number, not a condition. Compare it to a value, for example rsi_14 < 30.` A true/false column compared to a number (`high_volume_alert > 30`) reads `high_volume_alert is true/false. Use it bare, or high_volume_alert = true, not compared to a number.` Same on `/v2/scan`, `/v2/events`, `/v2/series`, and the dry-run that validates a webhook rule at creation. The envelope is unchanged: same 400, same `error: invalid_query`, same `request_id`; only the text moved. Every other message is exactly what it was.
- Added**Two new path families, old paths kept forever.** `GET /v2/bars/{tickers}/{interval}` is the canonical spelling of the OHLCV feed (it was also the most-guessed wrong path on the API), and `GET /v2/etf/{ticker}/holdings` / `/sectors` the canonical spelling of ETF composition. `/v2/tickers/{ticker}/bars/{interval}`, `/holdings` and `/sectors` keep serving as permanent aliases — same handler, same response, no deprecation headers, no sunset. The docs, OpenAPI and the MCP tools name the new paths.
- Added**Bars: `adjusted=false` and `session=regular`.** `adjusted=false` returns prices as they printed on the tape that day instead of split-adjusted (a broker fill from before a reverse split now lands on the chart); volume scales the other way. `session=regular` keeps sub-hour bars in 09:30–16:00 ET only, which drops late-reported off-exchange prints that land in pre-market minutes; `limit` counts after the filter. Both are read-time options, echoed in the response. From a customer report; the store is unchanged.
- Added**`merge_gap_seconds` on `GET /v2/events?kind=signal`.** Runs of a flag separated by at most N seconds collapse into one before the unpivot, so a flag with thousands of per-tick fragments yields one `enter` and one `exit` per real run (`3600` for hourly flags, `86400` for daily). Requires `signal` and an explicit `tickers` list, filter mode only; a cursor pins it.
- Deprecated**`GET /v2/signals/{signal}/{ticker}/events` (signal spans)** — sunset **2026-10-31**, with the rest of the TB-82 slate. It reads the same rows `GET /v2/events?kind=signal&signal=X&tickers=Y` unpivots into enter/exit point-events, and gap merging moved there. Responses are unchanged until the sunset and carry `Deprecation` / `Sunset` / `Link` headers. The MCP tool `tickerbot_list_signal_events` is retired; a stale client calling it is told to use `tickerbot_list_events`.
- Changed**Docs reorganised around one vocabulary per family: overview · catalog · state · subscribe.** A *state* page carries both tenses — the live read, then the same call with `asof` as its `#as-of` section. Old page URLs redirect: `tickers/symbols` → `tickers/catalog`; `tickers|signals|scan/live` and `/asof` → `…/state`; `series/get` → `series/state`; `events/all` → `events/query`; `tickers/bars` → `bars/get`; `tickers/holdings|sectors` → `etf/…`. The sidebar folds each family into a disclosure row.
- Added**`GET /v2/tickers/{ticker}` takes a list.** Put up to 50 comma-separated symbols in the path — `GET /v2/tickers/AAPL,MSFT,NVDA` — and the response carries `data` keyed by symbol, `requested`, `count`, and `not_found` (symbols we do not track, which never fail the request). `?asof=` works the same way on the list form and means what it means on the single form: the row, and the company that held the symbol, at that instant, with `_meta` keyed by symbol. One call for a holdings list. The single-symbol response is unchanged.
- RemovedBREAKING — **`GET /v2/tickers` is now the symbol catalog and nothing else.** It walks every tracked symbol (active and delisted) and returns identity rows only: `ticker`, `name`, `asset_class`, `asset_type`, `exchange`, `exchange_mic`, `currency_name`, `country`, `cik`, `composite_figi`, `list_date`, `delisted_utc`, `active`. Price, market cap and the day change are gone from the walk. `search` now ranks an exact ticker first, then ticker prefixes, then name matches, alphabetical within each rank (it used to sort by market cap). Five parameters are retired and return a `400` naming the replacement:
Retired Use instead `?tickers=A,B,C` (full rows)`GET /v2/tickers/A,B,C``?universe=<id>` (full rows)`GET /v2/scan?universe=<id>` with no `q``?asof=` (who held the symbol)`GET /v2/tickers/{ticker}?asof=` — resolves the holder and returns its state`?min_market_cap=``POST /v2/scan` with `q=market_cap > 1e10``?sector=``POST /v2/scan` with `q=sector = 'Technology'` - ChangedThe spec's `profile` group is re-cut to match: `identity` is exactly the catalog row above, `classification` keeps `sector`, `industry`, `sic_code`, `sic_description`, `ticker_category`, and a new `company` category holds the issuer profile (`description`, `homepage_url`, address, phone, branding, `total_employees`, `round_lot`, `share_class_figi`, `ticker_root`, `inactive_at`, `inactive_reason`). Every column is still on the state row; only its catalog tag moved. Why: the same word `asof` meant symbol tenure on `/v2/tickers?tickers=` and a state snapshot on `/v2/tickers/{ticker}`, and the same path returned an 8-column row or a 475-column row depending on a query flag. The catalog now answers only "what is this symbol"; the state route answers only "what is it doing".
- ChangedEvery percent-typed field returns percent numbers, not fractions: 0.043 → 4.3. One instant — Monday, September 7 at 9:00 AM ET — across REST, scan, webhook payloads, MCP, and the WebSocket stream; no mixed state, no grandfathering. Previously the same field could return a fraction on a live read and a percent number on a historical one, a silent 100x ambiguity. There is now one unit, percent, everywhere. Nearly every affected field is also renamed, so old code fails loudly instead of silently reading wrong numbers — and a request using an old name returns an error naming its replacement. The full mapping:
Old name New name Values day_change_pctchange_1d_pctfraction → percent (0.043 → 4.3) change_1wchange_1w_pctfraction → percent change_1mchange_1m_pctfraction → percent change_3mchange_3m_pctfraction → percent change_6mchange_6m_pctfraction → percent change_1ychange_1y_pctfraction → percent change_ytdchange_ytd_pctfraction → percent pct_from_sma_10from_sma_10_pctfraction → percent pct_from_sma_20from_sma_20_pctfraction → percent pct_from_sma_50from_sma_50_pctfraction → percent pct_from_sma_100from_sma_100_pctfraction → percent pct_from_sma_200from_sma_200_pctfraction → percent pct_from_52w_highfrom_52w_high_pctfraction → percent pct_from_52w_lowfrom_52w_low_pctfraction → percent position_in_52w_rangeposition_in_52w_range_pctfraction → percent pct_from_vwapfrom_vwap_pctfraction → percent from_open_pcfrom_open_pctfraction → percent premarket_change_pcpremarket_change_pctfraction → percent afterhours_change_pcafterhours_change_pctfraction → percent revenue_growth_yoyrevenue_growth_yoy_pctfraction → percent revenue_growth_yoy_quarterlyrevenue_growth_yoy_quarterly_pctfraction → percent earnings_growth_yoyearnings_growth_yoy_pctfraction → percent atr_percentatr_pctunchanged (already percent) free_float_percentfree_float_pctunchanged percent_insidersinsiders_pctunchanged percent_institutionsinstitutions_pctunchanged short_percent_of_floatshort_float_pctunchanged bollinger_pct_bbollinger_bunchanged (stays 0–1) - Changedgap_pct keeps its name and its value multiplies by 100 (0.03 → 3.0) — the one field that changes scale without changing name. If you filter or alert on it, update your thresholds at the cutover or your rules will effectively never (or always) fire.
- Removedgap — redundant beside gap_pct, which replaces it.
- Changed**The sandbox is the API without a key.** `/v2/sandbox/*` now runs every endpoint exactly as `/v2/*` does: the 365-day as-of floor, the 50-row page clamp, the shorter `q` limit, the 5-ticker cap on series/events, the latest-day-only bars scope, and the refused-parameter 400s (projections, aggregates, universes, `sort_by`, events `q`/`join`) are all gone. The only thing that differs without a key is the per-IP budget — 30 requests/hour and 60/day — which returns `429 sandbox_limit`. `400 history_window_exceeded` and `403 free_scope` are no longer emitted. The Run buttons in these docs return the same response a keyed call would.
- Added**Bars at `2h`, `4h`, `1w`, and `1mo`.** [`/v2/tickers/{ticker}/bars/{interval}`](/docs/endpoints/tickers/bars) now serves eleven intervals from 1-second to monthly: `2h`/`4h` roll up the hourly store, `1w`/`1mo` roll up daily bars into calendar weeks (Monday start) and months, with `t` = bucket start. Same `limit`/`before`/`from`/`to`/`asof` controls as every other interval. (These four were previously drawn only by the dashboard chart through a private route, which is gone — the dashboard reads the public endpoint.)
- Changed**The signal taxonomy has been rebuilt — `category` values on `/v2/signals` rows have changed.** Every signal was re-filed bottom-up into 33 categories under 9 groups: grab-bag categories (`indicators`, `edge_alerts`, `bands`) were dissolved into real subjects; the indistinguishable `highs_lows`/`price_extremes` pair merged into one `highs_lows`; the 96-field `fundamentals` monolith split by statement (`income_statement`, `balance_sheet`, `cash_flow`, `margins_ratios`, `valuation`, `share_structure`, plus `short_interest`); `analyst_ratings` split into `consensus_ratings`, `analyst_actions`, `price_targets`, `estimates`; events split into `earnings`, `dividends`, `ipos`, `capital_changes`, `insider_activity`. `category` has always been output-only metadata — no query parameter accepts it — so no request breaks, but anything displaying or switching on the old strings should re-sync from the catalog.
- Added**Signal rows carry their full taxonomy membership, and the response carries the tree.** Each built-in row on `/v2/signals` now includes `category_label`, `group`, and `group_label` beside `category` (slugs are stable — switch on those; labels are display strings), and flag rows carry `definition` — the precise firing rule, previously available only in the static [`schema.json`](/docs/schema.json). The response gains a `taxonomy` block: the 9 groups in derivation order — record (Price, Volume), behavior (Price action), indicator (Technicals), then reported / calendar / reference / opinion / text — each with its definition and its categories' definitions, once per response rather than repeated per row. The same tree ships in [`/docs/schema.json`](/docs/schema.json) for offline/agent use.
- Changed**Your custom signals are `kind: "custom"`.** Catalog rows, create/update responses, and the per-signal history route tagged user-authored signals `kind: "expression"` while everything else about them said custom — the `count_custom` counter beside those very rows, `_meta.sources` on series, error codes, and all prose. One word now means one thing: `custom` is what a user-authored signal *is* (the `kind` value, counterpart of `builtin`); `expression` refers only to the SQL text in `expr`. If your client matches on `kind === "expression"`, switch to `"custom"`; the `?kind=expression` filter value keeps working as a legacy alias, as `?kind=custom` always had in the other direction.
- Fixed**A ticker returns one company's history, not several.** Prices were stored under whatever symbol they traded as, so a symbol that changed hands carried every occupant's history glued into one series: `Q` returned Qwest, Quintiles and Qnity as a continuous line, and `SHLD` returned Sears attached to the Global X defence ETF that holds the symbol today. 1,074 symbols carried a prior company's prices; they no longer do, and every indicator derived from them has been recomputed.
- Fixed**A company that changed its ticker keeps its history.** The other face of the same bug: `META` began in 2021 and showed a collapse in mid-2022 that never happened, because Facebook's 2012–2022 history was still filed under `FB`. Renamed companies have been reunited with their own past — `META` now runs continuously from its 2012 IPO — across 136 symbol changes.
- Fixed**Delisted companies are in the universe.** The ticker universe was built from the currently-active list, so any company that died before we started tracking was absent entirely: `LEH`, `SIVB` and `SBNY` returned nothing. 6,147 delisted companies have been restored with their real delisting dates and full price history, which makes a backtest over a past universe survivorship-bias-free rather than silently limited to the survivors.
- Added**Ticker records carry their symbol's ownership history.** `tenure_start` and `tenure_end` give the dates the company on the record has held the symbol; `symbol_reused` is `true` when an earlier, unrelated company held it; `predecessors[]` lists those earlier holders with `name`, `cik` and `composite_figi`; and `prior_symbols[]` lists the same company's own earlier tickers. So `META` reports `prior_symbols: [{ ticker: "FB", … }]` and `SHLD` reports Sears Holdings as a predecessor.
- Added**`asof` resolves which company a symbol meant.** `GET /v2/tickers?tickers=SHLD&asof=2010-06-30` and `GET /v2/tickers/SHLD?asof=2010-06-30` both return Sears Holdings; without the parameter you get the company holding the symbol today. `GET /v2/tickers/{ticker}/bars/{interval}?asof=` returns that company's prices, not just its name. On [`/v2/series`](/docs/endpoints/series) there is no `asof` — it is the range endpoint, and the `from`/`to` window you request selects the era, so `from=2010-06-01&to=2010-06-30` on `SHLD` returns Sears' prices. `asof` keeps its one documented meaning everywhere it exists: the state at that instant.
- Changed**Bars are no longer clamped at `list_date`.** History used to be cut at the symbol's listing date to avoid serving a previous occupant's prices. That cut too much: a share-class conversion reset Criteo's listing date to 2026 and hid thirteen years of `CRTO`. The boundary is now the company's actual tenancy, so legitimate history that was hidden is served again.
- Fixed`before` on `GET /v2/tickers/{ticker}/events` is exclusive again, as its docs have always said. The bound had been overshooting: `before=2026-06-16` returned the 16th, and a time-carrying bound returned up to two extra days (`before=2026-08-09T12:00Z` returned events dated the 10th). The route converted your bound to a bare date before handing it to the events primitive, and when a date-only window bound came to mean "through the end of that day" (v2.23.0), that conversion silently gained a day. Your bound is now passed through as the exact instant you named — a bare date as its own midnight. **If you were compensating** by subtracting a day, remove that adjustment. Back-paging with `next_cursor` is unaffected, and so is `to` on the successor route [`/v2/events`](/docs/endpoints/events/query), which was always correct.
- Fixed**Aggregating over a payload field works.** Naming a rollup key whose alias matched a payload field — `group_by=payload->>'firm' AS firm` on `/v2/events?kind=analyst`, the flagship example on [All events](/docs/endpoints/events/query) — returned `400 invalid_query "syntax error at or near ("`. Single-kind payload fields are rewritten to their `payload->>` expression before the query is compiled, and that rewrite was also hitting the name after `AS`, producing `… AS ((payload->>'firm'))`. Aliases (and cast targets) are now left alone wherever the rewrite runs: `/v2/events` payload fields, and custom signals on `/v2/scan`, `/v2/news` and `join=state`. The same fix lands an aliased key on `/v2/scan`, where `group_by=sector AS s` was `syntax error at or near "AS"` — the alias reached `GROUP BY`, where SQL does not allow one.
- Changed**No rollup column comes back as `?column?`.** An un-named group key or `select` item that Postgres declines to name — `payload->>'firm'`, `a || b` — was keyed by the literal placeholder `?column?` in the JSON body. Such columns are now named: a payload read takes its key (`payload->>'firm'` → `firm`), a field you spelled bare keeps your word (`group_by=firm` → `firm`), and anything else falls back to `group_1`, `group_2`. Columns Postgres already names — bare columns, casts of them, function calls like `lower(source)`, `COUNT(*) FILTER (…)`, and `CASE` — are untouched, so no existing key changes. Applies to `/v2/events`, `/v2/scan` and `/v2/news` aggregate mode. `order=` can now name a derived key.
- ChangedOn `/v2/events`, the echoed `query` object returns `q`, `select`, `group_by` and `having` exactly as you sent them. It previously echoed the compiled SQL — `group_by=firm` came back as `["((payload->>'firm'))"]` — which made a diff of sent-vs-returned look like the request had been rewritten. `/v2/scan` has echoed your spelling since v2.20.0; the two now match.
- Deprecated`GET /v2/tickers/{ticker}/history?asof=` (the no-interval second spelling of the as-of read) now carries `Deprecation` + `Sunset: 2026-10-31` + successor `Link` headers, joining the four reads deprecated in v2.17.1 on the same sunset date. The canonical spelling, [`GET /v2/tickers/{ticker}?asof=`](/docs/endpoints/tickers/state#as-of), returns the byte-identical response — migration is a one-line URL swap, nothing else changes. On or after the sunset date the alias may return `410 Gone`. (v2.17.1 called this spelling "blessed" and kept it; that call was reversed — one capability, one URL.)
- Changed**Numeric fields are now JSON numbers everywhere.** A handful of endpoints — `/v2/scan` (live and as-of), `/v2/tickers/{t}`, `/v2/tickers/{t}/holdings`, `/v2/tickers/{t}/sectors`, and webhook payload rows — returned numeric quantities as strings (`"price": "307.1440"`, `"weight": "0.07760000"`) while the docs showed bare numbers (`"price": 121.09`) and other endpoints (series, bars, events) already emitted numbers. The whole surface now follows one convention: every numeric quantity is a JSON number; strings are reserved for text, identifiers (CIK, FIGI, cursors), and dates/timestamps. Values are numerically identical — only the type and insignificant trailing zeros change (`"0.07760000"` → `0.0776`). The sole exception: integers too large for a JSON double to hold exactly (beyond 2^53) keep their string form — today that is only the nanosecond-epoch columns `_last_trade_timestamp`, `_last_quote_timestamp`, and `_snapshot_updated_ns` on the full ticker row, which were strings before and are unchanged. If your client declared the flipped fields as strings, loosen it to a number type; parsers that already called `parseFloat`/`float()` are unaffected. This moves the API to its long-documented contract.
- Fixed`transitions_only` on `/v2/series` dated the same flip differently depending on which other columns rode along: with a bar column (say `close`) in `columns`, a flip recorded on a weekend/holiday carry row was re-dated to the next trading day by the non-trading-day drop; without one it kept its original date. Flips now always keep the date the state table recorded them — the drop no longer applies under `transitions_only` (its output is already sparse, so there are no gap-looking rows to protect). Bar columns on such a row are `null` and count in `_meta.coverage.*.missing_bar_cells`.
- FixedOn `GET /v2/events` with `kind=signal`, request validation now runs before the signal-log readiness check. A malformed query (`group_by` over `kind=signal`, or `q`/`join=state` without `signal=`) sent while the log was unavailable answered `503 kind_unavailable` — documented as retryable — instead of its `400`, so a well-behaved client would retry a request that could never succeed. A `503` now guarantees the query itself is well-formed.
- ChangedUnknown-path `404` bodies now point somewhere. Every "no such endpoint" response carries `docs_url` (the API reference), and recognized near-misses of real routes additionally carry `suggestion` — the route you probably meant — echoed in the message as "Did you mean …?". `GET /v2/bars` suggests `/v2/tickers/{ticker}/bars/{interval}`; `/v2/news/query` suggests `/v2/news/scan`; `/v2/events/all` suggests `/v2/events`. Resource 404s (unknown ticker, webhook, delivery) are unchanged. If your client sees `suggestion`, switch routes — retrying the same path will 404 forever.
- Removed`/openapi-gpt.yaml` (the ChatGPT custom-GPT Actions spec) retired: the GPT Store path was dropped 2026-06-12 (Actions support only shared-key auth), ChatGPT connects via the MCP server with per-user OAuth instead, and the spec had no remaining consumers. The full spec remains at `/openapi.yaml`; GPTs built from the old URL keep working (Actions snapshot schemas at import).
- Added`to` on `GET /v2/webhooks/{id}/deliveries` — the upper window bound, completing `from`. Same value grammar as `from` (epoch seconds, epoch milliseconds, or ISO datetime); a date-only `to` means through the end of that UTC day, matching bars, series, spans, and events. `from` after `to` is a `400`.
- Changed`sort_by` on `GET /v2/signals/{signal}` now rejects unrecognized values with a `400` naming the two valid ones (`default`, `market_cap`). A typo like `sort_by=marketcap` previously fell through silently to the default order, so results looked plausible while ignoring what you asked for. Note `sort_by` is deliberately a two-position switch — for ordering by arbitrary columns, use `/v2/scan` with `order`/`dir`.
- ChangedPATCH `/v2/signals/{name}` and PATCH `/v2/universes/{id}` now reject unknown body fields with a `400` listing what each accepts, matching webhook PATCH. Unknown keys were previously ignored, so a PATCH of only unknown fields returned `200` with an unchanged record — reading as success.
- Fixed`fields` (the documented alias of `columns`) now works on `/v2/series?interval=1q`. The quarterly arm only read `columns`, so `?interval=1q&fields=eps` was a `400` while the same request with `columns=eps` worked.
- FixedA malformed `from`/`to` on `GET /v2/signals/{name}/{ticker}/history/{interval}` for a custom signal now returns a clean `400` naming the expected format, instead of a database cast error surfacing as a `500`.
- Added`from`/`to` time windows on `GET /v2/tickers/{t}/bars/{interval}` and `GET /v2/signals/{s}/{t}/events` (signal spans). Same conventions as `/v2/series`: `from` inclusive, a bare `YYYY-MM-DD` `to` means through the end of that day, and `cursor` pages backward *inside* the window. On bars these params used to be silently ignored; on spans they were advertised and ignored — "golden crosses in June" returned all-time data. Contradictory combinations (`from`/`to` with `asof` or `before`) are a `400`, and a spans cursor now carries its window, so replaying it without resending `from`/`to` stays in-window (resending conflicting values is a `400`).
- ChangedA date-only `to`/`until` on `GET /v2/events` (and its per-kind views and `/v2/analyst/events`) now means **through the end of that day**, matching bars, series, and spans. `to=2026-06-30` previously bound at that day's midnight and silently excluded the whole named day on timestamp-grained kinds. Timestamp-bearing values keep their exact exclusive semantics.
- Added`from` (alias `since`) on `GET /v2/webhooks/{id}/deliveries` accepts epoch milliseconds (13+ digits) alongside epoch seconds and ISO datetimes — bare digit values on bars/series/spans are milliseconds, so a value reused across surfaces no longer lands ~50,000 years out.
- AddedNew error code `unknown_parameter` (400): a query/body parameter name the endpoint does not accept is rejected, and the message lists every accepted name — ghost parameters (`tickr=`, `condtion=`) no longer succeed while meaning something else. Rolling out in warn mode first (unknown names are logged, requests pass through); enforcement follows.
- ChangedMCP: four tools retired with graceful tombstones — `get_ticker_history_series` and `get_signal_history` → `tickerbot_get_series`, `get_ticker_events` → `tickerbot_list_events` with `ticker=`, `list_analyst_events` → `tickerbot_list_events` with `kind=analyst`. Calling a retired name returns an error naming the successor and the argument mapping instead of a bare "Unknown tool". Four tools added in the same pass: `get_series`, `get_ticker_coverage`, `create_webhook`, `patch_webhook`. `list_events` also gains the structured `firm`/`action` analyst filters (case-insensitive firm match).
- ChangedThe GPT Action spec (`/openapi-gpt.yaml`) restores `list_signal_events` — the only span-shaped read; `/v2/events?kind=signal` returns point edges, not spans — and drops `get_stream_usage` (a dashboard concern), staying at 29 operations under ChatGPT's 30-op cap.
- Removed`POST /v2/scan/explain` (and its GET form). The endpoint now returns `410 Gone`. Its job — telling you whether a query would run — is already done by the scan itself: an invalid `q` returns the same `400` with the same message, and every scan response echoes the query it executed (`query`, plus `_meta` on as-of reads for the resolved interval). If you used explain as a pre-flight in CI or before subscribing, run the scan with `limit=1` instead; subscribing also validates the query and creates nothing on failure.
- Added`interval` on as-of reads (`/v2/scan`, `/v2/signals/{signal}`): `1m`, `1h`, `1d`, or `auto` (the default, which is the previous behavior). State is stored at three grains and they do not carry the same columns, so a cross-section — which must resolve to ONE grain — had its grain decided implicitly by the projection list, the universe scope, and how far back the instant was. Asking for one `1d`-only column such as `rsi_14` re-dated every other column in the request. With `interval` set that is a `400 interval_unavailable` naming the column and the grains that carry it. Same parameter name and values as `/v2/series` and `/v2/tickers/{t}/bars`.
- Fixed**`?asof=` on `/v2/tickers/{t}/bars/{interval}` returned bars from the FUTURE relative to the requested moment.** The read was bounded 24 hours past the instant, and because daily bars are stamped at the session date's midnight ET, a mid-session timestamp pulled in the NEXT session's close: `?asof=2026-07-29T14:00:00Z` (10:00 ET) returned close 333.43, which is the 07-30 close — the 07-29 close was 338.19. asof now returns the most recent bar whose period had CLOSED at or before the moment. A bare `YYYY-MM-DD` still means that day's close and is unchanged. If you built a backtest on intraday `asof` bars, re-run it.
- Fixed`GET /v2/tickers/{ticker}?asof=` returned none of the columns its own `_meta.frozen_fields` advertised — `name`, `sector`, `industry`, `asset_type`, `exchange`, `asset_class`, `ticker_category` were absent from `data` entirely, while the live read of the same endpoint returned them. They are now joined from the current ticker row, as `/v2/scan?asof=` already did.
- Changed`_meta.interval` (`1m`/`1h`/`1d`) replaces `_meta.resolution` (`minute`/`hourly`/`daily`) on every as-of response, and `_meta.state_interval` replaces `_meta.state_resolution` on `/v2/events?join=state`. The old keys remain as deprecated aliases with their old values. This makes `interval` the single vocabulary for "which grain" across series, bars, as-of reads and the state join — it was previously spelled three different ways. Also new: `_meta.interval_reason` (always present, says which grain ran and why) and `_meta.coverage_gap` now reports on the fallback path too, not only when the chosen grain was already fully covered.
- FixedWhole-market intraday as-of reads were pinned to `1d` by a coverage check with an absolute 25-ticker tolerance. Measured on 2026-07-31, hourly missed 30 of 13,732 tickers (0.22%) and failed by five — while the minute tier's 92% shortfall was judged by the same constant. The tickers involved are active SPAC warrants, units and rights that simply never print intraday, so no fixed count could be correct. The threshold is now a fraction of the universe, and the gap is disclosed either way.
- FixedThe as-of grain for a given instant no longer depends on which date another caller asked about first. Coverage verdicts for every instant inside the last 7 days shared one cache entry, so the same request could return `1h` or `1d` depending on cache state. Verdicts are now per instant.
- FixedDefault columns a grain does not store (for example `market_cap` at `1m`) are returned as `null` and named in `_meta.columns_not_stored_at_interval`, instead of being dropped from the row. The response shape no longer changes with the grain.
- FixedThe three `/v2/sandbox/*` as-of routes (`/tickers/{t}`, `/tickers/{t}/history`, `/signals/{s}`) now run the same handlers as the keyed endpoints. Their own implementations had no staleness floor, answered `as_of` with the CURRENT time on a historical row, silently truncated an intraday `asof` to a date, and emitted no `_meta`. This also fixes the 500 on `/v2/sandbox/tickers/{t}/history?asof=`, which was an unbounded scan.
- FixedA ticker whose most recent state sits exactly on the staleness boundary is no longer served by `/v2/scan` and `/v2/signals` while 404ing on `/v2/tickers`. The coverage probes used an exclusive bound and the serving queries an inclusive one; all of them now share one definition.
- Changed`?asof=` on bars honors an explicit `limit` (the last N closed bars) instead of silently overriding it, and `asof` combined with `before`/`cursor` is now a `400` instead of `asof` quietly winning. A malformed `asof` is a `400` rather than falling through to the live tail.
- Added`GET /v2/events?kind=signal` now takes the `q` grammar and `join=state` — scoped with two new params, `signal=<flag>` and `transition=enter|exit`. `signal=` is required for `q`/`join=state` (filter mode needs neither) — naming the signal binds the query to an index over the ~175M-row firing log, so "every `golden_cross` enter across the market this week" returns in milliseconds instead of timing out. `transition=` is an ordinary optional filter. `group_by` over `kind=signal` remains unsupported — an aggregate has no `limit` to stop at, so its cost tracks how often the signal fires. See /docs/endpoints/events/signal-firings.
- Changed**Breaking, `kind=signal` only:** the payload field `direction` is renamed `transition`. `/v2/series` has always called this exact primitive a transition (`transitions: { above_sma_200: "enter" }`) and both are computed by the same shared rule, so `direction` was the lone dissenting name. Values are unchanged: `enter` / `exit`.
- Fixed`kind=signal` with `q` or `join=state` covering BOTH transitions no longer times out on high-frequency signals. Each edge branch now carries its own sort order, so the database merges two ordered streams and stops at your `limit` instead of reading the whole window and sorting it. `above_vwap` over 7 days went from a 30-second timeout to 31 ms.
- FixedEvent-trigger webhooks carrying `tickers` returned 500 (`POST /v2/events/subscribe`, `POST /v2/webhooks`) — a missing import left over from the shared-ticker-parser refactor.
- AddedRates, FX and crypto are now first-class on the ticker-scoped endpoints. `/v2/tickers/{t}`, `/v2/tickers?tickers=`, `/v2/series`, universes and ticker-scoped webhooks all accept prefixed symbols — `R:SOFR`, `R:UST10Y`, `X:EURUSD`, `X:XAUUSD`, `X:BTCUSD`. They were already returned by `/v2/scan`; now the whole surface agrees. Daily history runs deep: rates to 1990, spot metals to 1968, crypto to 2010. See Asset symbols for what each class carries.
- AddedThe signal catalog reports per-field as-of behavior: `historized` (the value at that instant), `frozen` (an identity column joined from the current row — `sector` as of 2020 is today's sector), or `unavailable`. Previously the three were indistinguishable.
- FixedAs-of scans no longer silently drop requested static columns. `?fields=ticker,sector&asof=…` returned everything except `sector`, while `_meta.frozen_fields` advertised it as available — filtering and grouping on those columns had always worked, only projection was broken.
- FixedBars for instruments with no intraday range (interest rates) return `null` for open/high/low/volume instead of `0`. A stored NULL was being coerced on serialization, which floored any min() computed over the response and drew a zero candle on charts.
- FixedWell-formed non-equity symbols get an error naming their asset class instead of "Invalid ticker". `/v2/news`, `/v2/events` and `/v2/analyst` remain equities-only by definition — a rate pays no dividend — but they now say so. `/v2/events` also validates ticker format on the first page, not only on cursor pages.
- RemovedBREAKING — `GET /v2/tickers?asset_type=crypto` is rejected. It served a separate crypto table that stopped updating on 2026-06-18, so it had been returning weeks-stale prices. Crypto now lives in the main ticker list under its `X:` symbols: use `?tickers=X:BTCUSD,X:ETHUSD` or scan for it. `asset_type` keeps its real meaning — the instrument type within equities (`CS`, `ETF`, `ADRC`, …), which is a different axis from asset class.
- Added`columns` is now the one projection parameter name across the API — on `/v2/scan` (additive to the defaults), `/v2/tickers/{t}/history` (replaces the defaults), `/v2/series`, and webhook subscribe payload fields. The original `fields` spelling is accepted forever.
- Added`/v2/news` gains the events-style scoping sugar: `ticker`, `tickers` (≤50), `universe`, and `from`/`to` (inclusive) AND into the WHERE — and `q` becomes optional once any of them is present. `?ticker=NVDA&from=2026-07-01` needs no SQL.
- Added`from`/`to` are now the canonical range names on `/v2/events` and `/v2/analyst/events` too; `since`/`until` accepted forever.
- AddedTicker subscribe (`POST /v2/tickers/{t}/subscribe`) takes `q` — it was always the scan grammar wearing the name `condition`; `condition` accepted forever.
- AddedCross-ticker context refs (`spy.sma_50`) now work identically on webhook subscribe and the docs sandbox, and signed-in users' custom signals inline on the sandbox — the same query is valid live, pushed, or sandboxed.
- AddedNew error code `expanded_query_too_large`: your `q` is under the 4,000-char limit but expands past the 100,000-char execution bound after custom-signal inlining. Distinct from `bad_request` so you can tell "too long" from "expands too much". Any q `/v2/scan` accepts is now subscribable — the old subscribe-side 1,500-char post-expansion cap is gone.
- AddedCursors everywhere they were missing: the tickers crypto list, 1q history, sandbox signals, and the admin tickets list now emit real `next_cursor` tokens.
- ChangedDate params parse ONE strict ISO subset everywhere: `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM[:SS[.sss]]Z`. `/v2/events` and `/v2/analyst/events` previously accepted loose formats ("March 3 2024") that every neighboring endpoint rejected — those now 400.
- ChangedFail-loudly pass: unknown keys in a webhook PATCH body are rejected instead of ignored (the trigger and channel are immutable by design — the error now says so); a `condition` sent with a boolean/custom signal subscribe 400s instead of being silently ignored; sending both `ticker` and `tickers` 400s on events read + subscribe (each side used to pick a different silent winner); `/v2/stream` names malformed symbols in an `invalid` array instead of silently dropping them; `/v2/events` cursor pages reject query-string filters that conflict with the cursor's pinned set; the docs sandbox 400s on unsupported params (tickers/scan/signals) instead of silently ignoring them, honors `limit` up to its 50 cap, and gains the POST scan form.
- ChangedBREAKING — `order` on `/v2/news` and `/v2/events` now takes a bare column name or SELECT alias only; SQL expressions in `order` are rejected (alias the expression in `select` and order by the alias). Expression order is also what broke news aggregate paging.
- ChangedBREAKING — one aggregate cap set everywhere: `group_by` ≤ 1,000 chars and 1-6 keys, `select` ≤ 2,000, `having` ≤ 1,000, on scan, news, and events alike.
- ChangedAnalyst events cursor pages now apply your filters on every page (page 2 of a `?ticker=AAPL` walk used to return other tickers). Resend the same filters alongside the cursor — see the pagination guide for the two cursor models.
- ChangedDELETE returns `204 No Content` uniformly (custom signals joined universes/webhooks/devices). Device records use snake_case keys (`device_name`, `created_at`, `last_seen`; `expo_push_token` on register) — camelCase spellings accepted forever on input.
- RemovedInternal identifiers (`userId`/`keyId` on webhooks, `ownerType`/`ownerId`/`createdByKeyId` on universes) no longer appear in responses. They were bookkeeping, not contract — use `system: true` to distinguish platform universes.
- ChangedAnonymous sandbox rate limits raised: 30 queries/hour (was 10) and 60/day (was 30) per IP. Signed-in callers were and remain exempt from the IP budget.
- AddedSandbox mirrors for the two new primitives: `GET /v2/sandbox/series` and `GET /v2/sandbox/events` — the docs pages for `/v2/series` and `/v2/events` now have Try-it. Sandbox clamps apply (5 tickers, 50 rows, 1-year window), and sandbox events is filter mode only: `q`, aggregates, `join=state`, and universes need an API key.
- FixedSandbox `GET /v2/sandbox/tickers/{ticker}` silently ignored `?asof=` and returned the live row. It now honors asof within the sandbox's 1-year window, same semantics as the authed read.
- ChangedThe sandbox demos for two deprecated reads were retired: per-ticker events (it served a legacy archive, not what the real endpoint returns) and per-signal history. Their successor pages — `/v2/events` and `/v2/series` — carry the live Try-it demos.
- DeprecatedFour legacy reads now carry `Deprecation` + `Sunset: 2026-10-31` + successor `Link` headers. Each has a strict superset: `GET /v2/tickers/{t}/history/{interval}` → `/v2/series?ticker=`, `GET /v2/signals/{s}/{t}/history/{interval}` → `/v2/series?ticker=&columns=`, `GET /v2/tickers/{t}/events` → `/v2/events?ticker=`, `GET /v2/analyst/events` → `/v2/events?kind=analyst`. Responses are byte-identical until the sunset date; after it the routes may return `410 Gone` with a pointer at the successor. NOT deprecated: `GET /v2/tickers/{t}/history?asof=` (the blessed second spelling of the as-of read), `/v2/news/scan` (answers forever), the `/subscribe` shortcuts, `/v2/tickers/{t}/bars`, signal spans, and `?condition=`.
- Added`GET /v2/tickers/{ticker}/coverage` — per-ticker data coverage: data spans per resolution (bars, daily, hourly, minute), minute-tier membership, and per-field measured backfill depth where the backfill engine has probed. Answers, before you query, whether "no rows" would mean "the signal never fired" or "no data at that depth". Available on every plan. See /docs/endpoints/tickers/coverage.
- Added`GET /v2/series` — the canonical series read: up to 50 tickers × 25 columns on one shared, aligned time grid. OHLCV names, schema columns, and your custom signals mix freely in `columns`; sources and per-ticker coverage are disclosed in `_meta`; a missing cell is `null`, never a silently dropped row. A bare `/v2/series?ticker=X` returns the ticker-history default columns. See /docs/endpoints/series/state.
- Added`transitions_only=true` on `/v2/series`: rows filter down to the state-change edges of boolean columns — built-in flags and custom signals both — with non-boolean columns riding along (the price at the flip). Computed by the same shared rule that fires webhooks.
- Added`interval=1w` on `/v2/series` — real weekly resampling: state columns sample the week's last observation, OHLCV aggregates (first open, max high, min low, last close, summed volume), rows keyed by the ISO week's Monday. `/v2/signals/{s}/{t}/history/1w` now applies the same rule — previously `1w` returned daily rows with no resampling.
- Added`interval=1q` on `/v2/series` — quarterly fundamentals (`eps`, `eps_estimate`, `eps_surprise`, `eps_surprise_pct`, `revenue`, `gross_profit`, `free_cash_flow`) on the fiscal grid, keyed by calendar quarter (`2026-Q2`) with each ticker's exact `fiscal_period` per row. Quarterly columns can't mix with daily ones in one request.
- AddedTwo opt-in kinds on `GET /v2/events`: `signal` — boolean-flag firings as enter/exit point-events with the price at the edge (the span shape stays first-class at `/v2/signals/{s}/{t}/events`) — and `news`, mirroring the article archive into the timeline. The default stream is unchanged: the four corporate kinds. `kind=signal` is filter-mode only: `q`, aggregates, and `join=state` over the signal log are refused with a 400 rather than timing out. See /docs/endpoints/events/signal-firings.
- Added`join=state` on `GET /v2/events`: ticker-state columns become legal in `q`/`select`/`group_by`/`having`, evaluated against each event's ticker as of the event's timestamp — point-in-time replay. "Downgrades on stocks above their 200-day" is one call. Daily resolution, echoed as `state_resolution` in the response's `query` object. Available on every plan.
- Added`q_truncated: true` on `/v2/events` responses whose `q` was too long to ride inside the cursor — resend `q` alongside `cursor` proactively instead of discovering it on a 400.
- ChangedPer-call caps unified at 1,000 rows across the cursor-paged read surfaces. Custom-signal history previously allowed 5,000 rows on one page (an accident of routing — which cap you got depended on whether the name was custom); it now pages at 1,000 like everything else. Signal spans (`/v2/signals/{s}/{t}/events`) deliberately keeps its 5,000 clamp — it has no cursor, so a lower cap would strand spans past it.
- FixedThe dashboard mirror of the per-ticker event log served a divergent legacy implementation reading a different archive. It now serves exactly what `GET /v2/tickers/{ticker}/events` serves. The public path is unchanged.
- ChangedEvent webhooks now support all four kinds — `insider` joins `dividend`, `split`, and `analyst` as subscribable. The insider archive gained the ingestion timestamp the fire-on-ingest trigger needs; only filings recorded after 2026-07-27 are eligible, so historical rows can never fire. The short-lived `kind_not_subscribable` (400) error is retired.
- AddedAggregate scans: `group_by` (with optional `select` and `having`) switches `/v2/scan` from ticker rows to rollups — market breadth by sector, counts of new highs, average RSI per group — on both the live and `asof` paths. Same grammar as `/v2/news` and `/v2/events` aggregation.
- Added`GET /v2/events` — one cross-ticker timeline over dividends, splits, insider transactions, and analyst actions, with `kind`/`tickers`/`universe`/`since`/`until` filters. It also speaks the same SQL grammar as `/v2/scan` — `q` / `select` / `group_by` / `having` over `(ticker, ts, kind, payload)` — for filtered timelines and rollups (insider buys by sector, dividend counts per month). The per-ticker `/v2/tickers/{t}/events` and `/v2/analyst/events` remain as filtered views of the same data.
- AddedEvent webhooks: `POST /v2/events/subscribe` (or `POST /v2/webhooks` with `trigger: {type: "event"}`) fires when NEW events land — with an optional row-state filter `q` (the ticker's state) and `event_q`, a filter on the event's own payload (e.g. only insider buys over $1M). Deliveries carry `event: "events.fired"`. Latency is ingest cadence: analyst ≤1h, corporate kinds daily.
- Added`POST /v2/webhooks` is now the canonical create: a webhook is a `trigger` (scan / ticker / signal / event) plus delivery fields. The per-resource `/subscribe` endpoints remain as permanent shorthand — every door creates the identical object.
- Added`GET /v2/tickers/{ticker}/history/{interval}` — true series history: up to 25 columns for one ticker, one flat row per step (1m/1h/1d), chronological, cursor-paged. The old `/v2/tickers/{ticker}/history?asof=` form (a point snapshot despite its name) keeps working forever and stays fully documented; `GET /v2/tickers/{ticker}?asof=` returns the identical response — existing integrations are unaffected.
- Changed`asof` is now one uniform capability: the same staleness rule on every endpoint — a row must be within the serving tier's window (14 days for daily) or the response is empty/404 with `last_available`. Unlimited depth on every plan; history-series endpoints are unaffected and stay all-time.
- AddedIdempotency keys on every create endpoint: send `Idempotency-Key: <unique string>` and a retry within 24h replays the original response (`Idempotency-Replayed: true`) instead of creating a duplicate; concurrent duplicates get `409 idempotency_in_flight`.
- Added`POST /v2/scan/explain` — dry-run a scan without executing: validation, the compiled predicate after custom-signal expansion, resolved scope and asof tier, and the planner's cost/row estimate. Always responds 200; failures return `{ valid: false, stage, error, message }`.
- AddedPreviously-undocumented surfaces are now in the reference: `PATCH /v2/webhooks/{id}`, the `/v2/devices` family (mobile push registration), `GET /v2/stream/usage`, and scan's `full=true`.
- ChangedThe Free plan now includes a monthly call quota of 10,000 calls per calendar month (UTC), resetting on the 1st. Every paid plan is unmetered — no monthly cap. Calls past the quota return `402 monthly_quota_exceeded` with a `resets_at` timestamp; rejected requests (429s and quota 402s) never consume quota. Free-plan responses carry `X-Quota-Limit`, `X-Quota-Remaining`, and `X-Quota-Reset` headers.
- ChangedCustom signals are no longer a paid feature and are no longer capped. Authoring and consuming them both work on every plan, including Free, with no count limit. The `custom_signals_tier_required` (403) and `custom_signal_limit_reached` (403) errors are retired; they can no longer occur. Accounts that were locked out of their own custom signals after a downgrade can use them again.
- ChangedScale is now capped at 1,000 webhooks, where it was previously unlimited. Webhooks and websocket streaming now ladder identically: 0 / 10 / 100 / 1,000 across Free / Hobby / Pro / Scale. Unlimited webhooks are available on Enterprise.
- Added`GET /v2/stream/usage` returns your current websocket consumption — `tickers`, `max_tickers`, `connections`, `max_connections`. Streaming caps are pooled per ACCOUNT, not per API key: issuing additional keys does not grant additional capacity.
- AddedTwo account-level ceilings are now documented: `too_many_universes` and `too_many_custom_signals` (both 400). These are anti-abuse guards, not plan limits — they are identical on every plan and upgrading does not raise them.
- ChangedWebhook cadence is no longer plan-gated — any webhook can use any cadence, regardless of plan. The `cadence_above_plan_max` (403) error is retired; it can no longer occur.
- ChangedThe real-time cadence is now `realtime` (the default). It is evaluated on every data refresh (~1×/min), so a match is delivered within seconds of the snapshot that first contains it — no per-minute batching. `1m` is a deprecated alias for `realtime`: it is still accepted on create/update and is normalized to `realtime`, which is what responses now return.
- ChangedPlans restructured around a new $0 Free tier, now the default on signup. All data is available on every plan, including Free: full universe, every signal, real-time data, history, `asof`, news, and analyst archives. Paid plans differ by rate limit, webhook count, and custom signal count. The 14-day trial is retired; cancelling returns an account to Free rather than revoking access.
- AddedRules are now validated at creation. Every subscribe endpoint dry-runs your query and rejects an unrunnable rule (unknown column, bad syntax) with `400 invalid_query` and the underlying message.
- AddedOptional `fields` on `/v2/tickers/{t}/subscribe`, `/v2/signals/{s}/subscribe`, and `/v2/scan/subscribe` — a comma-separated list of extra columns to include in each fired payload's match rows, beyond the standard set (`ticker`, `name`, `asset_type`, `price`, `day_change_pct`, `market_cap`). Mirrors `fields` on `/v2/scan`; each must be a real column or the subscription is rejected at creation.
- Added`GET /v2/analyst/events` — per-event analyst rating history from a ~14-year archive: upgrades, downgrades, initiations, and price-target changes, each with the firm, analyst, new and previous rating, and new and previous price target. Filter by `ticker`/`tickers` (≤50), `since`/`until`, `firm` (case-insensitive), and `action`; results are newest-first and page with `cursor`. At least one filter is required. Scale and above — the live `last_rating_*` columns on `/v2/tickers` and the `recent_*` analyst flags on `/v2/scan` remain available on every plan. See /docs/endpoints/events/analyst.
- Added`tickerbot_list_analyst_events` MCP tool wraps the endpoint above, so agents can pull analyst rating history in chat.
- AddedWebhook delivery channels. Every subscribe endpoint (`/v2/tickers/{t}/subscribe`, `/v2/signals/{s}/subscribe`, `/v2/scan/subscribe`) now takes a `channel` discriminator — `webhook` (signed POST to `target_url`), `discord` (an embed posted to a Discord channel), or `in_app` (dashboard-only). Omit it and we infer: `webhook` when `target_url` is set, `discord` when `discord_url` is set, otherwise `in_app`. See the Delivery channels guide at /docs/endpoints/webhooks/channels.
- AddedDiscord delivery. Pass `channel: "discord"` plus a `discord_url` (a Discord incoming-webhook URL) and each fire posts a formatted embed — subscription name, composed query, and the first dozen matching tickers with price and day change. No HMAC: the URL is the credential, so it is stored masked and stripped from list/get responses, which expose `channel_config_present: true` instead. `429`s honor Discord's `Retry-After`; other `4xx`s are permanent failures; `5xx`/network errors ride the standard retry ladder.
- Changed`POST /v2/webhooks/{id}/test` is channel-aware — for a Discord subscription it posts a real-shape test embed (titled with a `[TEST]` prefix) and returns the inline outcome, same as the HTTPS test fire.
- ChangedThe delivery channel is fixed at create time. `PATCH /v2/webhooks/{id}` still toggles a `webhook` subscription between POST-to-URL and in-app, but cannot switch to or from Discord — delete and re-subscribe to change channel.
- ChangedSubscribing no longer auto-fires a `webhook.ping` at your endpoint. One delivery shape — `webhook.fired` — so handler code only needs to handle one event, and the first payload your endpoint ever receives is identical to every future one. New webhooks ship in `status: "active"` immediately.
- Added`POST /v2/webhooks/{id}/test` fires a real-shape POST to your `target_url` synchronously and returns the inline HTTP outcome (`delivered`, `http_status`, `elapsed_ms`, `error`). For URL targets the body has the same field set and HMAC signature as a real fire — `webhook.fired` for condition triggers, `events.fired` for event triggers — with an `X-Tickerbot-Test: true` header as the only marker. Test fires are one-shot: failures are recorded as `permanent_failure` and never enter the retry queue or trigger auto-disable on the parent webhook.
- Added`test_url` field on subscribe responses points at the test endpoint above. The dashboard also surfaces a "Send test fire" button on the post-create modal and on the webhook detail page.
- Removed`webhook.ping` event type. The `pending_verification` status is no longer emitted on new webhooks.
- Added`GET /v2/tickers/{ticker}/bars/{interval}` — OHLCV bars at `1s`, `1m`, `5m`, `15m`, `30m`, `1h`, `1d`. Single symbol or comma-separated bulk (response keyed by symbol). Paging via `before` + `limit` (with `cursor`/`next_cursor` as sugar); `asof` returns a single point-in-time bar. `1d`/`1h` cover the full universe with full history; sub-hour covers the active universe and back-fills from the provider on first request. Available on every plan, all intervals.
- Added`1s` (1-second) interval on the bars endpoint, served on demand: from our store when present, otherwise passed through from the provider in the same call. Second data is not bulk-backfilled — it accumulates as requested. Best paired with a tight `before`+`limit` window.
- Added`POST /v2/tickers/{ticker}/subscribe`, `POST /v2/signals/{signal}/subscribe`, and `POST /v2/scan/subscribe` — subscribe to a webhook directly from the resource you want to watch. The new endpoints all return the same webhook record shape; `/v2/webhooks` becomes a pure registry for listing, inspecting, deleting, and re-enabling subscriptions.
- Removed`POST /v2/webhooks` — replaced by the resource-level subscribe endpoints above. Existing webhooks created via the old path keep firing unchanged.
- Removed`/v2/rules` — saved-bundle layer retired. Inline the `q` directly on `/v2/scan` or on a subscribe endpoint; reference a universe via `?universe=<slug>` instead of bundling it.
- Added`?asof=` is now uniform across every read: `/v2/tickers/{ticker}?asof=`, `/v2/signals/{signal}?asof=`, `/v2/scan?asof=`. Returns the universe as it stood at the close of the requested day, sourced from `signal_daily_state`. `_meta.frozen_fields` lists columns that aren't historized (sector, industry, exchange, asset_type).
- ChangedEvery paid plan now ships replayable history on every read, unlimited universes, and webhooks scaling by tier (Hobby 10 · Pro 100 · Scale unlimited). The Pro → Scale upgrade is content (news archive, custom signals).
- ChangedNews archive (`/v2/news/scan`) moved to Scale and above (previously Pro). The live `news_volume` and `news_volume_weighted_sentiment` columns on `/v2/tickers` and `/v2/scan` remain available on every paid plan.
- ChangedCustom signals (`POST/PATCH/DELETE /v2/signals/{name}`) moved to Scale and above. The unified `/v2/signals` catalog remains readable on every plan.
- ChangedWebhook visibility is account-scoped: every API key on the same account sees the same webhook registry. Rotating a key no longer strands subscriptions.
- ChangedFree trial no longer requires a credit card. New signups are placed on Hobby for 14 days; an API key is minted from the dashboard checklist with no Stripe interaction. Add a payment method anytime to keep going past the trial.
- Changed`/onboarding` retired. The dashboard is now the unified post-signup landing — trial countdown, account-status banner, and onboarding checklist all live there. Stripe Checkout success and cancel URLs now point back to `/dashboard` and `/dashboard/billing`.
- Added`402 trial_ended` error code (was `subscription_required`) returned by `/v2/*` when a no-card trial has expired. Body message links to `/dashboard/billing` to add a payment method.
- AddedCustom (expression) signals — name a SQL `WHERE`-clause expression once, then reference it as a bare identifier in any predicate context (scan `q`, another custom signal's `expr`, webhook subscribe body). Compiles + inlines at create time; recursion is detected and capped at depth 5. CRUD: `POST /v2/signals`, `GET /v2/signals` (unified catalog of built-ins + your customs), `PATCH/DELETE /v2/signals/{name}`. Cascade-safe delete refuses with 409 `signal_referenced` (carries `referencing_signals` array) — pass `?force=true` to override.
- Added`GET /v2/signals/{name}/{ticker}/events` — occurrence records (start/end timestamps + start/end prices) for a single signal on a single ticker. State-style signals return windowed regions; event-style signals return point events. Drives the dashboard chart's flag-overlay rail.
- ChangedHobby now includes full per-ticker history and historical scans (previously live-data only). Rate limit on Hobby raised from 60 to 600 req/min.
- ChangedThe demo ticker (AAPL) now bypasses the plan top-N scope filter on every plan, so the public docs and the per-signal reference page render without auth on every tier.
- Changed`GET/POST /v2/news/scan` replaces the original `/v2/news` + `/v2/news/{id}` pair. One SQL-style endpoint, two shapes — article rows by default, aggregate rollups when `group_by` is supplied. Auto-joins `UNNEST(tickers) AS tk` whenever any clause references the `tk` alias. "As-of" is just a WHERE filter on `time_published`; no separate parameter.
- ChangedNews archive access now requires Pro or above (was Hobby). The two live news columns on the ticker table (`news_volume`, `news_volume_weighted_sentiment`) remain available on every paid plan via `/v2/scan` and `/v2/tickers`.
- Added`/v2/sandbox/news/scan` — unauthenticated, IP-rate-limited mirror of the scan endpoint, capped at 50 rows.
- Added`GET /v2/news` — paginated, filterable news feed. Articles indexed back to 2015, refreshed every 15 minutes. Filterable by `tickers`, `topics`, time range, `min_relevance`, `min_sentiment`/`max_sentiment`. Hobby and above; per-call cap 50/200/1000 by plan. (Superseded the next day by `/v2/news/scan`.)
- Added`GET /v2/news/{id}` — fetch a single article by id. (Superseded the next day by `/v2/news/scan`.)
- AddedTwo new fields on the ticker schema: `news_volume` (24-hour rolling count of mentions at relevance ≥ 0.6) and `news_volume_weighted_sentiment` (relevance-weighted average sentiment, attenuated by tanh(volume/15) so quiet tickers read near zero). Both refresh every 15 minutes. Queryable through `/v2/scan` and `/v2/tickers`.
- RemovedThe four previously-stale news columns on `ticker` (`latest_news_at`, `recent_news_count`, `highly_relevant_news_count`, `news_sentiment_volatility`) have been removed. They were never reliably populated; the new two-field design above supersedes them.
- RemovedFree tier removed. Every plan (Hobby, Pro, Scale) now starts with a 14-day free trial; cancel anytime.
- Added`/v2/universes` — create and manage named ticker lists. System universes `top_10` and `top_100` (rebalanced monthly by dollar volume) are available to every account.
- Added`/v2/rules` — save a `{q, universe_id?, order, dir, fields}` bundle and reference it from `/v2/scan?rule=` or `/v2/webhooks`.
- Added`?universe=` parameter on `/v2/scan`, `/v2/signals/{signal}`, and `/v2/tickers` — scope queries to a system or owned universe.
- Added`?asof=` on `/v2/scan` — folds the historical scan into the main scan endpoint. `/v2/scan/history` remains as a backwards-compatible alias.
- AddedWebhook `cadence` field — pick `1m` (real-time, the default), `hourly`, or `nyse_open`.
- AddedWebhook `rule_id` body field — subscribe to a saved rule instead of inlining `q`.
- ChangedFree and Hobby plans now auto-scope ticker queries to the plan top-N (10 / 100). Response carries `_meta.scope` describing what got applied.
- ChangedNew pricing matrix: ticker scope, webhook cadence, max universes/rules/webhooks. See /pricing for the full grid.
- ChangedAPI base path moves from `/v1` to `/v2`. v1 keeps working at its existing path for now; new integrations should use v2.
- Added`/v2/signals/{signal}` lists tickers where a signal matches. Numeric signals take a `?condition=` (e.g. `>70`); boolean flags are auto-detected.
- Added`/v2/signals/{signal}/{ticker}/history/{interval}` returns per-signal time-series at the requested resolution (`1d` / `1h` / `1m`). Daily history covers all-time; minute history is two years for top-200 tickers.
- Added`/v2/scan/history?asof=` snapshots the universe scan at any past date, as-of-that-day. Falls under the same SQL grammar as live `/v2/scan`.
- Added`/v2/tickers/{ticker}/history?asof=` returns the wide ticker row as it stood on a past date, with quarterly fundamentals (assets, equity, EPS…) joined in by filing date.
- Added`/v2/tickers/{ticker}/events` returns a unified event log: splits, dividends, analyst rating changes, all sorted newest-first with cursor pagination.
- Changedv2 uses scanner-native column names everywhere — the legacy v1 docs vocabulary (`day_change_pct`, `asset_type`) is gone in favor of `day_change_pc`, `type`. The full set is documented on the schema page.
- ChangedWebhooks now live under `/v2/webhooks` with the same shape as `/v1/webhooks`. Existing v1 webhooks keep firing unchanged.
- AddedFree tier: live API key with no card on file. 10 req/min, 7 days of signal history, all query endpoints; no webhooks.
- AddedScale tier ($199/mo): 1,000 req/min, 1-year history, up to 100 webhook subscriptions, Slack Connect support.
- ChangedPricing: Pro replaces Core at $79/mo (down from $99), 500 req/min, 6-month history. Hobby now includes up to 3 webhook subscriptions.
- Added`/v1/signals/{ticker}/{signal}` enforces per-plan history depth. Requests with `from` older than the plan ceiling return `400 history_window_exceeded` with `max_history_days` + `earliest_allowed_from` in the response body.
- ChangedSubscription cancellation falls back to Free instead of revoking API keys — your integration keeps working at Free-tier limits. Webhooks over the new tier limit are auto-disabled (config preserved); resubscribe to re-enable.
- Fixed`GET /v1/signals/{ticker}/{signal}` route now registered (previously returned 404 despite being documented).
- AddedInitial public release of the Tickerbot API. Four endpoints under /v1: tickers, signals, scan, webhooks.
- Added`GET /v1/tickers/{ticker}` and bulk `GET /v1/tickers?tickers=...` return the full curated ticker object.
- Added`GET /v1/signals/{ticker}/{signal}` returns time-series history: numeric bars, continuous-flag windows, or edge-flag triggers depending on the signal type.
- Added`GET/POST /v1/scan` accepts a SQL WHERE clause and returns matching tickers.
- Added`POST /v1/webhooks` creates a SQL-driven webhook that POSTs to your URL once per minute when new tickers enter the match set. State-change deduplication, HMAC-SHA256 signed deliveries, 5-attempt retry.
- Added50+ boolean flags + ~50 numeric/string columns documented on the schema page.
- AddedCoverage: ~12,000 US-listed equities and the top 100 cryptocurrencies by market cap.