- 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/all), which was always correct.
Reference
Changelog
Notable changes to the API. Newest first. Backward-incompatible changes ship as a new path version; everything within a major version is additive after launch.
- 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/all) — 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/asof), 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/get.
- 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.