# Analyst Ratings API

> Wall Street analyst price targets, buy/hold/sell distribution, upgrade and downgrade actions, and forward EPS and revenue estimates with earnings surprise history.

Base URL: `https://app.sentisense.ai/api/v1/analyst`
Tier: PRO (free preview available)

All API access requires an API key via the `X-SentiSense-API-Key` header. Keys look like `ss_live_...`.

> SDKs: [Python](https://github.com/SentiSenseApp/sentisense#analyst-ratings) · [Node](https://github.com/SentiSenseApp/sentisense-node#analyst-ratings)

## Overview

The Analyst Ratings API provides programmatic access to Wall Street analyst coverage data: aggregate price target bands, the buy/hold/sell distribution, recent upgrade and downgrade actions, and forward EPS and revenue estimates with earnings surprise history.

**Use cases:**
- Pull the consensus price target and upside-to-current for any covered ticker
- Track the rate and direction of analyst revisions on a stock or across the market
- Compare actual EPS prints against the consensus estimate (earnings surprise tracking)
- Read the consensus EPS and revenue for the next two quarters and fiscal years, with the analyst range
- Cross-reference analyst signals with insider trading, institutional flows, and AI insights
- Find out who covers a stock by name, and read back everything an individual analyst has published

**Free preview:** the price target band (`targetLow`, `targetMean`, `targetHigh`, `numberOfAnalysts`, `consensusLabel`) is returned in full to free users, along with the as-of stamp (`updatedAt`, `updatedAtEpoch`) so you can always tell how fresh a row is. The buy/hold/sell distribution counts, `recommendationMean`, and `targetMedian` are PRO-only. The actions feed is limited to the 3 most recent rows for free, and the estimates endpoint returns 1 quarter + 2 surprises for free.

**Access:** PRO subscription required for full data. Free and unauthenticated users receive a preview wrapped in `{isPreview: true, data: ...}`.

> **Building an AI agent?** See [How to track analyst ratings and price targets with an API](/blog/how-to-track-analyst-ratings-with-an-api/) for the consensus-plus-sentiment divergence pattern, with a runnable snippet.

Who covers a stock, by firm and named analyst: [the analyst coverage API guide](/blog/analyst-coverage-api-who-covers-a-stock/). The Street set against the crowd, and who moved after the print: [Street vs Crowd](/blog/street-vs-crowd-analyst-ratings-and-the-market-mood/).

---

## GET /{ticker}/consensus

Returns the aggregate Wall Street consensus for a ticker: price target band, number of covering analysts, upside-to-current, and buy/hold/sell recommendation counts.

**Authentication:** PRO required. Free users receive the price target band in full but no recommendation distribution.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | Stock ticker symbol (e.g. `AAPL`) |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/AAPL/consensus"
```

```python
from sentisense import SentiSenseClient

client = SentiSenseClient(api_key="ss_live_YOUR_KEY")
result = client.get_analyst_consensus("AAPL")
band = result.data
print(f"Target low/mean/high: {band['targetLow']} / {band['targetMean']} / {band['targetHigh']}")
print(f"{band['numberOfAnalysts']} analysts, consensus: {band['consensusLabel']}")
```

**Response Schema:**

| Field | Type | Description |
|-------|------|-------------|
| `isPreview` | boolean | `true` when the caller is on the FREE tier |
| `previewReason` | string | `"PRO_REQUIRED"` or `null` |
| `upgrade` | object | Present only when `isPreview` is `true`. Carries `plan`, `message`, `price`, `url` and `relay`: surface `message` and `url` to your user in one line, then continue with the preview `data` |
| `data` | object | Consensus object (see below) |

**Consensus object:**

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | Stock ticker |
| `companyName` | string | The company the ticker resolved to. `null` when the knowledge base holds no name for it. Returned in full to free users |
| `currentPrice` | decimal | Reference price captured when the snapshot was written (dated by `updatedAt`), used for the upside calculation. Not the live quote: read `/api/v1/stocks/price` for that. |
| `targetLow` | decimal | Lowest analyst price target |
| `targetMean` | decimal | Mean analyst price target |
| `targetHigh` | decimal | Highest analyst price target |
| `targetMedian` | decimal | Median analyst price target (PRO only; null in preview) |
| `numberOfAnalysts` | int | How many analysts published a **price target**. A separate group from the five rating fields; never the total for "% of analysts" math. See [Reading the recommendation scale](#reading-the-recommendation-scale). |
| `upsidePercent` | decimal | Implied upside from current price to mean target (%) |
| `consensusLabel` | string | Consensus label in enum form, derived from `recommendationMean`: `"STRONG_BUY"`, `"BUY"`, `"HOLD"`, `"SELL"`, `"STRONG_SELL"`. Returned in full to free users. |
| `recommendationMean` | decimal | Numeric consensus rating on the 1.0 to 5.0 sell-side scale, where **lower is more bullish** (1 = Strong Buy, 5 = Strong Sell). PRO only; null in preview. See [Reading the recommendation scale](#reading-the-recommendation-scale). |
| `strongBuy` | int | Number of Strong Buy ratings (PRO only; 0 in preview) |
| `buy` | int | Number of Buy ratings (PRO only; 0 in preview) |
| `hold` | int | Number of Hold ratings (PRO only; 0 in preview) |
| `sell` | int | Number of Sell ratings (PRO only; 0 in preview) |
| `strongSell` | int | Number of Strong Sell ratings (PRO only; 0 in preview) |
| `updatedAt` | string | When the row was last refreshed, as an ISO-8601 UTC instant with a `Z` suffix (e.g. `"2026-09-01T21:04:59Z"`), to whole seconds. Returned to free users. |
| `updatedAtEpoch` | int | The same moment as `updatedAt`, in epoch seconds (e.g. `1788296699`). Prefer this if you would otherwise parse the string. Returned to free users. |

Covers individual stocks. Returns 404 when no analyst record exists for the ticker; a fund or market index symbol returns `404 ticker_is_etf` or `404 ticker_is_index` with a `seeInstead` pointer to fund-level data (see [Errors](#errors)).

### Reading the recommendation scale

`recommendationMean` is the average of the individual broker recommendations behind the ticker,
on the standard 1.0 to 5.0 sell-side scale. **The scale is inverted relative to intuition: lower
is more bullish**, so sorts, filters, and screener thresholds built on it need the comparison
flipped. (A few other fields run inverted too, notably the put/call and skew readings on the
options endpoints, where higher reads more bearish; most of the catalog runs the intuitive
direction.)

One reconciliation note: the mean is aggregated from a different underlying analyst set than the
five rating count fields, so do not expect to reproduce it as a weighted average of the counts.
The two usually point the same way but will not match exactly.

A second reconciliation note, about time: the five rating counts are a **survey panel** of broker
recommendations currently in effect, not a running tally of the actions feed. The aggregator keys
the panel by month and revises it in batches through the month as brokers submit, and we store the
current panel as received on each sweep. So the distribution can change on any sweep (a Hold
becoming a Strong Buy, a Strong Sell disappearing, a broker joining or leaving the panel) with no
matching row on `/actions` or `/activity`, because most broker submissions are never published as
a note. The two datasets come from different sources with different coverage and are never derived
from each other. Treat `/activity` and `/{ticker}/actions` as the event record (every row carries
the note's `actionDate`), and the counts as a snapshot of where the panel stood when the sweep ran.
Two mechanical details for anyone diffing daily snapshots: `recommendationMean` is stored at two
decimal places, and a ticker whose panel fetch returned nothing keeps the stored counts. History
marks that observation with `countsObserved: false`.

| Value | Meaning |
|-------|---------|
| 1.0 | Strong Buy |
| 2.0 | Buy |
| 3.0 | Hold |
| 4.0 | Sell |
| 5.0 | Strong Sell |

`consensusLabel` is derived from it with these cutoffs, so you can reproduce the label yourself
or bucket a mean you computed from your own inputs:

| `recommendationMean` | `consensusLabel` |
|----------------------|------------------|
| <= 1.5 | `STRONG_BUY` |
| <= 2.5 | `BUY` |
| <= 3.5 | `HOLD` |
| <= 4.5 | `SELL` |
| > 4.5 | `STRONG_SELL` |

**`numberOfAnalysts` belongs to the price target, not the ratings.** The analysts who published
a buy/hold/sell rating are a separate group, counted by the sum of the five rating fields. Not
every analyst publishes both, so the two totals rarely match, and the rating total is usually the
larger. For any "percent of analysts" math, divide by the sum of the five rating fields:

```python
rated = sum(band[k] for k in ("strongBuy", "buy", "hold", "sell", "strongSell"))
bullish_pct = 100 * (band["strongBuy"] + band["buy"]) / rated if rated else None
```

Dividing by `numberOfAnalysts` instead mixes the two groups and returns above 100% on many
tickers.

### Freshness and update cadence

Analyst data refreshes as a single sweep across the full covered universe (roughly a thousand US
tickers) rather than per ticker, and that sweep runs about once a day but is not anchored to a clock:
it takes roughly 30 minutes to walk the universe and lands at a different hour each day (measured over
2026-08-25 to 2026-09-04: completions between 8:48am and 8:10pm ET, occasionally more than one in a day). Read `updatedAt` rather than assuming
a schedule. Consensus bands, the recommendation distribution, upgrade and
downgrade actions, and forward estimates all land together in the same pass, so every ticker's
`updatedAt` moves at once.

Read `updatedAt` (or `updatedAtEpoch`) rather than assuming a schedule: it is the authoritative
as-of for the row, it is served on every tier, and it is an absolute UTC instant, so compare it
against `now` in UTC and not against a local wall clock. The practical consequence for
action-driven workflows is that a rating change published today can be up to about 24 hours old
before it appears in `/{ticker}/actions` or `/activity`.

---

## GET /{ticker}/consensus/history

Returns daily observations of the analyst consensus for a ticker. Rows are ordered by
`snapshotDate` ascending, and `limit` selects the most recent rows inside the requested window.

Each row is an observation of the upstream current-month recommendation panel plus the target
fields as observed on `snapshotDate`. `countsObserved: false` means the panel did not return on
that sweep and the counts are stored values from the last observed panel, or zeros when no panel
has ever been observed. It is not evidence that coverage dropped.

**Authentication:** PRO required for the full window and all fields. Free users receive the last
30 calendar dates ending at `to`, starting at the later of `from` and `to` minus 29 days;
`targetMedian`, `recommendationMean`, and the five rating counts are null
in the preview. `totalCount` still reports the full requested window.

A ticker with history outside the requested window returns `200` with an empty `history` array.
A tracked ticker with no history at all returns `404 no_coverage`.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | Stock ticker symbol, including supported aliases |
| `from` | string | No | `to` minus 90 days | First snapshot date, formatted `YYYY-MM-DD` |
| `to` | string | No | Today in America/New_York | Last snapshot date, formatted `YYYY-MM-DD` |
| `limit` | int | No | 90 | Most recent rows to return, capped at 366 |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/AAPL/consensus/history?from=2026-06-01&to=2026-09-01&limit=90"
```

```python
from sentisense import SentiSenseClient

client = SentiSenseClient(api_key="ss_live_YOUR_KEY")
result = client.get_analyst_consensus_history(
    "AAPL", from_date="2026-06-01", to_date="2026-09-01", limit=90
)
for point in result.data["history"]:
    print(point["snapshotDate"], point["targetMean"], point["countsObserved"])
```

**Response Schema:**

| Field | Type | Description |
|-------|------|-------------|
| `isPreview` | boolean | `true` when the caller receives the FREE preview |
| `previewReason` | string | `"PRO_REQUIRED"` or `null` |
| `upgrade` | object | Present only when `isPreview` is `true`; carries `plan`, `message`, `price`, `url`, and `relay` |
| `totalCount` | int | Rows in the full requested window before the `limit` or FREE clip |
| `data.ticker` | string | Canonical ticker |
| `data.from` | string | Effective first date, formatted `YYYY-MM-DD` |
| `data.to` | string | Effective last date, formatted `YYYY-MM-DD` |
| `data.count` | int | Rows returned in `data.history` |
| `data.history` | array | Consensus history points in ascending `snapshotDate` order |

**Consensus history point:**

| Field | Type | Description |
|-------|------|-------------|
| `snapshotDate` | string | America/New_York sweep date, formatted `YYYY-MM-DD` |
| `observedAt` | string | Observation time as an ISO-8601 UTC instant with a `Z` suffix, to whole seconds |
| `observedAtEpoch` | int | The same moment as `observedAt`, in epoch seconds |
| `source` | string | Observation source |
| `countsObserved` | boolean | `false` when the five counts were carried from stored values because the panel did not return |
| `currentPrice` | decimal | Reference price captured with the observation |
| `targetLow` | decimal | Lowest analyst price target |
| `targetMean` | decimal | Mean analyst price target |
| `targetMedian` | decimal | Median analyst price target; null in the FREE preview |
| `targetHigh` | decimal | Highest analyst price target |
| `numberOfAnalysts` | int | Analysts in the price-target population |
| `upsidePercent` | decimal | Implied upside from `currentPrice` to `targetMean`, in percent |
| `recommendationMean` | decimal | Numeric rating on the 1.0 to 5.0 scale; null in the FREE preview |
| `strongBuy` | int | Strong Buy count; null in the FREE preview |
| `buy` | int | Buy count; null in the FREE preview |
| `hold` | int | Hold count; null in the FREE preview |
| `sell` | int | Sell count; null in the FREE preview |
| `strongSell` | int | Strong Sell count; null in the FREE preview |
| `consensusLabel` | string | `STRONG_BUY`, `BUY`, `HOLD`, `SELL`, or `STRONG_SELL` |

---

## GET /{ticker}/called-it

Returns recorded large stock moves and the covering firms that revised their price targets in
the same direction beforehand. Moves are newest first by `moveEndDate`. Each move reports the
full firm counts for revisions with the move, against it, and targets left unchanged. The calls
list contains the same-direction revisions, with analyst names only where the published note
identified one.

**Authentication:** PRO required for full results. Free users receive the newest move only,
with up to 5 calls. Move-level counts remain intact. `totalCount` reports the servable moves
before the limit or preview is applied.

A known stock with no qualifying move returns `200` with an empty `moves` array and
`totalCount: 0`. An unknown symbol returns `404 entity_not_found` with suggestions. An ETF
returns `404 ticker_is_etf` with an analyst aggregate pointer.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | Stock ticker symbol, including supported aliases |
| `limit` | int | No | 10 | Moves to return, newest first; capped at 50, below 1 returns `400 invalid_limit` |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/AAPL/called-it?limit=10"
```

**Response Schema:**

| Field | Type | Description |
|-------|------|-------------|
| `isPreview` | boolean | `true` when the caller receives the FREE preview |
| `previewReason` | string | `"PRO_REQUIRED"` or `null` |
| `upgrade` | object | Present only in a preview; carries `plan`, `message`, `price`, `url`, and `relay` |
| `totalCount` | int | Servable moves before the limit or preview |
| `data.ticker` | string | Canonical ticker |
| `data.count` | int | Moves returned |
| `data.moves` | array | Moves ordered by `moveEndDate` descending |

**Move:**

| Field | Type | Description |
|-------|------|-------------|
| `insightId` | string | Identifier of the insight describing this move |
| `generatedAt` | int | Insight generation time in epoch seconds |
| `moveStartDate` | string | First date of the move window, `YYYY-MM-DD` |
| `moveEndDate` | string | Last date of the move window, `YYYY-MM-DD` |
| `movePct` | decimal | Signed price change over the move window, in percent |
| `moveWindowSessions` | int | Trading sessions in the move window |
| `lookbackDays` | int | Pre-move lookback for price-target revisions |
| `coveringFirms` | int | Firms with a price target in the lookback |
| `revisedWithMove` | int | Firms that revised in the move's direction |
| `revisedAgainstMove` | int | Firms that revised against the move's direction |
| `leftUnchanged` | int | Firms whose target was left unchanged |
| `calls` | array | Same-direction revisions; capped at 5 in the FREE preview |

**Call:**

| Field | Type | Description |
|-------|------|-------------|
| `firm` | string | Firm that published the target |
| `analystName` | string or null | Named analyst, or null when the note did not identify one |
| `attribution` | string | `firm` or `analyst` |
| `priceTarget` | decimal | Published revised price target |
| `priorPriceTarget` | decimal | Last price target before the lookback window, or the first target in the window when no earlier target exists |
| `publishedOn` | string | Publication date, `YYYY-MM-DD` |
| `daysBeforeMove` | int | Calendar days from publication to the start of the move |

A null `analystName` leaves the call attributed to the firm; it does not mean no call was made.

---

## GET /{ticker}/actions

Returns recent analyst upgrade and downgrade actions for a ticker, newest first.

**Authentication:** PRO required. Free users receive the 3 most recent actions in full.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | Stock ticker symbol |
| `lookbackDays` | int | No | 90 | Days of history to return |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/AAPL/actions?lookbackDays=30"
```

**Action object:**

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | Stock ticker |
| `actionDate` | string | Date of the action (ISO date) |
| `firm` | string | Brokerage/research firm name |
| `actionType` | string | `UPGRADE`, `DOWNGRADE`, `INITIATE`, `REITERATE`, or `OTHER` |
| `fromGrade` | string | Previous rating (e.g. "Hold"), nullable on initiations |
| `toGrade` | string | New rating (e.g. "Buy"), nullable on coverage drops |

**Grade vocabulary.** `fromGrade` and `toGrade` are stored with one spelling per rating, so you can
group on the string directly. The ratings sort into six tiers, and two spellings in the same tier
are the same rating even when the words differ (`Market Perform` and `Perform`). Compare tiers, not
strings, when the question is whether a desk actually moved.

| Tier | Reads as | Spellings |
|------|----------|-----------|
| 5 | Highest conviction | `Strong Buy`, `Conviction Buy`, `Top Pick` |
| 4 | Bullish | `Buy`, `Overweight`, `Outperform`, `Positive`, `Accumulate`, `Speculative Buy`, `Long-Term Buy`, `Market Outperform`, `Sector Outperform`, `Outperformer` |
| 3 | Neutral | `Neutral`, `Hold`, `Equal-Weight`, `Market Perform`, `Sector Perform`, `In-Line`, `Peer Perform`, `Perform`, `Sector Weight`, `Hold Neutral`, `Mixed`, `Fair Value` |
| 2 | Bearish | `Underweight`, `Underperform`, `Reduce`, `Sector Underperform`, `Market Underperform`, `Negative`, `Cautious` |
| 1 | Sell | `Sell` |
| 0 | Strongest conviction sell | `Strong Sell` |

A rating a research firm invents that is not on this list is passed through exactly as the firm
published it rather than being forced into a tier or discarded, so treat the table as the ratings we
recognise today, not as a closed enum your parser can rely on.

---

## GET /{ticker}/estimates

Returns forward EPS and revenue estimates and recent earnings surprise history for a ticker.

**Authentication:** PRO required. Free users receive 1 estimate (current quarter) + 2 most recent surprises.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | Stock ticker symbol |

**Response shape:**

| Field | Type | Description |
|-------|------|-------------|
| `data.estimates` | array | Forward estimate periods, at most one row per `periodType` in the order listed below (PRO: every period; FREE: 1 row) |
| `data.surprises` | array | Earnings surprise history (PRO: full history; FREE: 2 rows) |

**`estimates[]` fields:**

| Field | Type | Description |
|-------|------|-------------|
| `periodLabel` | string | Period label as sent: the scheduled report date (ISO `YYYY-MM-DD`) for a quarter row when one is known, otherwise a relative code: `"0q"`, `"+1q"`, `"0y"` or `"+1y"`. The year rows always carry the relative code. Key on `periodType`, not on this label |
| `periodType` | string | One of `CURRENT_QUARTER` (the fiscal quarter the company reports next, which may already have ended), `NEXT_QUARTER`, `CURRENT_YEAR` (the fiscal year containing `CURRENT_QUARTER`), `NEXT_YEAR` |
| `fiscalPeriodEnd` | string \| null | Last day of the fiscal period the row estimates (ISO `YYYY-MM-DD`); for a future quarter of a 52/53-week fiscal year it can differ from the company's closing date by a few days. `null` when not known |
| `estimateLow` | number \| null | Lowest analyst EPS estimate for the period, in US dollars per share |
| `estimateMean` | number \| null | Mean analyst EPS estimate for the period, in US dollars per share |
| `estimateHigh` | number \| null | Highest analyst EPS estimate for the period, in US dollars per share |
| `numberOfAnalysts` | integer \| null | Number of analysts contributing an EPS estimate |
| `revenueLow` | number \| null | Lowest analyst revenue estimate for the period, in US dollars (absolute, not millions) |
| `revenueMean` | number \| null | Mean analyst revenue estimate for the period, in US dollars (absolute, not millions) |
| `revenueHigh` | number \| null | Highest analyst revenue estimate for the period, in US dollars (absolute, not millions) |
| `numberOfAnalystsRevenue` | integer \| null | Number of analysts contributing a revenue estimate, counted separately from `numberOfAnalysts` |

A period with no consensus is omitted. A row can carry EPS without revenue, in which case the revenue fields are `null`, and it can carry revenue without EPS, in which case the EPS fields are `null`.

For a company that reports its financial statements in a currency other than the US dollar, or whose reporting currency is not known, no revenue is returned. This follows the reporting currency even when analysts publish that company's estimates in dollars. If the reporting currency of a company already confirmed in US dollars briefly cannot be checked, the values last confirmed for it, revenue included, are kept rather than replaced, for up to seven days. Its quarter rows carry the mean EPS only, with no range or analyst count, and its year rows, when present, carry EPS only: `revenueLow`, `revenueMean`, `revenueHigh`, `numberOfAnalystsRevenue` and `fiscalPeriodEnd` are `null`, and `periodLabel` is `"0y"` or `"+1y"`.

**`surprises[]` fields** (newest first):

| Field | Type | Description |
|-------|------|-------------|
| `periodLabel` | string | Period label as sent, typically the report date (ISO `YYYY-MM-DD`) |
| `reportDate` | string \| null | Earnings report date (ISO `YYYY-MM-DD`) |
| `estimateEps` | number \| null | Consensus EPS estimate before the report |
| `actualEps` | number \| null | Reported EPS |
| `surprisePercent` | number \| null | Surprise as a fraction rounded to 2 decimals (`0.07` means 7%), despite the name |
| `surprisePct` | number \| null | Signed EPS surprise as a true percent: `(actualEps - estimateEps) * 100 / abs(estimateEps)`, rounded half-up to 2 decimal places; `null` when either EPS value is missing or the estimate is zero |

```json
{
  "isPreview": false,
  "previewReason": null,
  "data": {
    "estimates": [
      { "periodLabel": "2026-04-30", "periodType": "CURRENT_QUARTER", "fiscalPeriodEnd": "2026-03-28", "estimateLow": 1.55, "estimateMean": 1.62, "estimateHigh": 1.70, "numberOfAnalysts": 25, "revenueLow": 92500000000, "revenueMean": 94800000000, "revenueHigh": 97100000000, "numberOfAnalystsRevenue": 23 }
    ],
    "surprises": [
      { "periodLabel": "2026-01-29", "reportDate": "2026-01-29", "estimateEps": 2.00, "actualEps": 2.10, "surprisePercent": 0.05, "surprisePct": 5.00 }
    ]
  }
}
```

---

## GET /activity

Returns market-wide recent analyst actions across all covered tickers, paged. Ordered by action date descending, with ties broken by ticker then id ascending, a total order so paging is stable.

**Authentication:** API key required; the first page is full data on every tier. Free keys receive the first 50 rows of the window as a complete response (`isPreview: false`); requesting `limit` above 50 or an `offset` past row 50 returns the free in-allowance slice with `previewReason: "PRO_REQUIRED"`. PRO keys page the whole window.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `lookbackDays` | int | No | 30 | Days of history to search |
| `limit` | int | No | 50 | Page size, capped at 500. Returns `400 invalid_limit` below 1 |
| `offset` | int | No | 0 | Rows to skip. Returns `400 invalid_offset` when negative |
| `actionTypes` | string | No | - | CSV filter on action type: any of `UPGRADE`, `DOWNGRADE`, `INITIATE`, `REITERATE`, `OTHER` (case-insensitive). Unknown values return `400 invalid_actionTypes` |

**Response:** `{ isPreview, previewReason, totalCount, data: [...] }`. `totalCount` is the number of actions in the whole `lookbackDays` window after the `actionTypes` filter, not the size of the page, so `offset + data.length < totalCount` tells you another page is available.

Roughly 83% of all analyst actions are `REITERATE`: an analyst confirming a rating they did not change. If you want actual rating changes (the usual question), filter for them:

```bash
# real rating changes only: upgrades, downgrades, and coverage initiations
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/activity?actionTypes=UPGRADE,DOWNGRADE,INITIATE"
```

Roughly 70 to 110 rating actions land on an active market day, and a heavy day runs a little over 100. Because rows come back newest first, the default 50-row page is typically filled by the newest day alone, and raising `lookbackDays` on its own returns no additional history. Raise `limit` for a wider slice, or walk the window with `offset`:

```bash
# every action in the last 30 days, 500 at a time
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/activity?lookbackDays=30&limit=500&offset=0"
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/activity?lookbackDays=30&limit=500&offset=500"
```

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/activity?lookbackDays=7"
```

Response shape matches the per-ticker `/actions` endpoint, with each entry's `ticker` field identifying the stock.

---

## GET /people/{slug}

Returns one analyst: the firms they have published under, the window of notes we hold at each, and the tickers they cover.

`slug` is a stable public handle derived from the analyst's name, lowercased and hyphenated: `dan-ives`, `stacy-rasgon`, `catherine-obrien`. You do not have to guess one. Every named analyst returned by `/{ticker}/coverage` carries the slug that addresses their profile.

**What this endpoint does not return:** there is no accuracy score, hit rate, ranking or any other derived judgment about the individual. This is call history: who said what, when, and where it was reported. Scored track records are a separate piece of work with its own statistical and legal review, and nothing here should be read as a rating of the person.

**Authentication:** PRO required. Free keys receive the profile with the coverage book truncated to the 5 most recently covered tickers; `totalCount` reports how many there are in full.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `slug` | path | Yes | - | Analyst slug, e.g. `dan-ives` |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/people/dan-ives"
```

**Response shape:**

| Field | Type | Description |
|-------|------|-------------|
| `data.slug` | string | Stable public handle for this analyst |
| `data.name` | string | Display name |
| `data.role` | string | `sell_side_equity` |
| `data.mostRecentFirm` | string | The firm this analyst most recently published under |
| `data.firms` | array | `{ firm, firstSeen, lastSeen, mostRecent }`, most recent first |
| `data.firstSeen` | string | ISO date of the earliest note we hold from this analyst |
| `data.lastSeen` | string | ISO date of the most recent note |
| `data.noteCount` | int | Price target notes attributed to this analyst |
| `data.tickerCount` | int | Distinct tickers covered |
| `data.coverage` | array | `{ ticker, noteCount, firstNote, lastNote, latestPriceTarget, latestFirm }` (PRO: full; FREE: 5 rows) |

**`firstSeen` and `lastSeen` are observation windows, not employment dates.** They are the dates of the first and last note we hold from that analyst at that firm. An analyst who has not published recently keeps a `lastSeen` in the past, and `mostRecentFirm` says where they last published rather than where they work today. Do not render either as a hire or departure date.

---

## GET /people/{slug}/calls

Returns one analyst's price target notes, newest first, paged.

Ordered by published date descending with the row id as the final tie-break, a total order, so walking the history with `offset` never drops or repeats a row. That matters more here than it looks: a single roundup article carries several of one analyst's notes at an identical timestamp.

**Authentication:** API key required. Free keys receive the first 25 rows of the history as a complete response (`isPreview: false`); asking for `limit` above 25 or an `offset` past row 25 returns the free in-allowance slice with `previewReason: "PRO_REQUIRED"`. PRO keys page the whole history.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `slug` | path | Yes | - | Analyst slug, e.g. `dan-ives` |
| `limit` | int | No | 25 | Page size, capped at 200. Returns `400 invalid_limit` below 1 |
| `offset` | int | No | 0 | Rows to skip. Returns `400 invalid_offset` when negative |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/people/dan-ives/calls?limit=25&offset=0"
```

**Call object:**

| Field | Type | Description |
|-------|------|-------------|
| `publishedDate` | string | ISO date the note was published |
| `ticker` | string | Stock the note is about |
| `firm` | string | Firm the analyst published under at the time |
| `priceTarget` | number | The target in the note, nullable |
| `adjPriceTarget` | number | Split-adjusted target, nullable |
| `priceWhenPosted` | number | Share price at publication, nullable |
| `newsTitle` | string | Headline of the report that carried the note |
| `newsUrl` | string | Link to that report |
| `newsPublisher` | string | Publisher that carried it |

**Response:** `{ isPreview, previewReason, totalCount, data: [...] }`. `totalCount` is the analyst's whole attributed history, not the page size, so `offset + data.length < totalCount` tells you another page is available.

Dates are day granularity on purpose. Publisher timestamps are not comparable across sources: a note filed after the US close is dated the next day by some publishers while the matching rating action keeps the session date. A time of day would advertise precision the data does not have.

A slug that matches no analyst returns `404 not_found` rather than an empty page, so you can tell "this analyst has published nothing we hold" apart from "this analyst does not exist".

---

## GET /{ticker}/coverage

Returns who covers a stock and what they most recently said, grouped by firm, most recently active firm first.

This is the one-call answer to "who covers AMD and what do they say". Each row is a firm, the individual analysts we can name on that firm's desk, that firm's most recent price target note, and that firm's most recent rating action.

**Authentication:** PRO required. Free keys receive the 5 most recently active firms, with every response-level count intact and a top-level `totalCount` carrying the full number of covering firms; the untruncated PRO response omits `totalCount`.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | Stock ticker symbol (e.g. `AMD`) |
| `lookbackDays` | int | No | 365 | Coverage window in days, capped at 1825. Returns `400 invalid_lookbackDays` below 1 |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/analyst/AMD/coverage?lookbackDays=180"
```

**Response shape:**

| Field | Type | Description |
|-------|------|-------------|
| `data.ticker` | string | Canonical ticker |
| `data.windowDays` | int | Window actually applied after clamping |
| `data.asOf` | string | ISO date the response was built |
| `data.firmCount` | int | Firms that covered the ticker in the window: at least one price target note **or** at least one rating action |
| `data.ratingOnlyFirmCount` | int | How many of `firmCount` appear on a rating action alone, with no price target note in the window. Firms that published a target are `firmCount - ratingOnlyFirmCount` |
| `data.ratingBuckets` | object | `{ buy, hold, sell, unrated, total }`: how the whole book's current ratings split. Counted over every covering firm before the FREE truncation, so `total` equals `firmCount` on both tiers |
| `data.namedAnalystCount` | int | Distinct named analysts across those firms |
| `data.noteCount` | int | Price target notes in the window |
| `data.attributedNoteCount` | int | Notes that name an individual analyst |
| `data.unattributedNoteCount` | int | Notes that name no individual |
| `data.attributionNote` | string | Plain-language statement of what an absent name means |
| `data.coverage` | array | Firm rows (PRO: all; FREE: 5) |

**Firm row:**

| Field | Type | Description |
|-------|------|-------------|
| `firm` | string | Research firm |
| `analysts` | array | `{ slug, name, noteCount, firstNote, lastNote, latestPriceTarget }`, possibly empty |
| `noteCount` | int | This firm's notes in the window |
| `attributedNoteCount` | int | Of those, how many name an analyst |
| `unattributedNoteCount` | int | Of those, how many do not |
| `firstNote` / `lastNote` | string | ISO dates bounding this firm's activity in the window |
| `latestNote` | object | The firm's most recent note: `{ publishedDate, analyst, priceTarget, adjPriceTarget, priceWhenPosted, newsTitle, newsUrl, newsPublisher }`. `analyst` is an object `{ slug, name }`, or null when the publisher named nobody |
| `firmRating` | object | The firm's most recent rating action: `{ rating, priorRating, actionType, date }`, nullable |

**Not every note names its analyst, and the ones that do not are still here.** A large share of price target notes arrive with no individual named, and whether a note is named is a property of the publisher that carried it rather than of the note itself. The share is high and varies enormously by ticker: across a 16-ticker large-cap sample on a 365-day window, 52% of notes named nobody, ranging from 11% on `CRM` to 64% on `BA`. Do not hardcode a rate; read `attributedNoteCount` and `unattributedNoteCount` on the response you actually received. Dropping unnamed notes would therefore report a firm as absent from a stock it demonstrably published on, and would do it unevenly by publisher. So they are kept and counted: a firm can appear with an empty `analysts` array and a non-zero `noteCount`, and `latestNote.analyst` is `null` when the report named nobody. An absent name means the report did not identify one, never that the note did not happen.

**A firm can cover a stock without publishing a price target.** The upstream price target feed goes quiet on a desk while that desk's rating actions keep arriving, so coverage means "published a price target **or** a rating action in the window". A firm in that state comes back as an ordinary row with `noteCount: 0`, an empty `analysts` array, `null` for `firstNote` / `lastNote` / `latestNote`, and its `firmRating` set. Rows are ordered by whichever came later, the firm's last note or its last rating action, so a rating-only firm interleaves by its rating date. Read `noteCount` on the row rather than assuming `latestNote` is present, and read `ratingOnlyFirmCount` before describing `firmCount` as firms with price targets.

**`ratingBuckets` counts the coverage book, not the vendor's consensus.** The four counts come from the `firmRating` of every firm in `data.coverage`, including the rating-only firms, and they are computed before the FREE preview cuts the list to 5 rows. A free key therefore gets 5 firm rows and the true split across all of them, which is what makes it safe to render a percentage next to a truncated list. `buy` is a rating of Strong Buy through Outperform, `hold` is Hold, Neutral, Equal-Weight and the Perform family, `sell` is Underperform through Strong Sell. `unrated` holds firms with no rating action in the window and firms whose grade spelling we do not recognise, because both mean the same thing: we cannot say what that desk calls the stock today. A grade is never guessed into a direction from its wording. `buy + hold + sell + unrated == total == firmCount`, so pick your own denominator knowingly. **This is a different population from the `strongBuy` / `buy` / `hold` / `sell` / `strongSell` counts on `/{ticker}/consensus`**, which come from the upstream consensus feed and count a different set of desks. Do not put the two side by side in one view, and do not expect them to agree.

**`firmRating` belongs to the firm, not to a person.** Rating actions are published at firm level: the upstream feed carries the firm, the grade and the date, with no individual attached. Do not render a firm rating as a named analyst's rating.

`analysts[].slug` addresses `/people/{slug}`, so a coverage response is the natural entry point into a profile. A named analyst we hold no profile for comes back with a `name` and a `null` slug rather than being dropped or linked to a guess.

For an ETF, which has no analyst desk, this returns a pointer whose first entry is the
holdings-weighted analyst consensus endpoint at `/api/v1/etfs/{ticker}/aggregates/analyst`.

---

## Errors

| Status | Code | Description |
|--------|------|-------------|
| 400 | invalid_lookbackDays | `/{ticker}/coverage` only: `lookbackDays` below 1. Values above 1825 are capped, not rejected, and `/{ticker}/actions` accepts any `lookbackDays` |
| 400 | invalid_parameter | A query parameter that cannot be parsed as its type, for example `limit=abc` |
| 400 | invalid_limit | `limit` below 1 on `/activity` (values above 500 are capped, not rejected) |
| 400 | invalid_offset | `offset` below 0 on `/activity` |
| 400 | invalid_actionTypes | `actionTypes` on `/activity` contains a value outside `UPGRADE`, `DOWNGRADE`, `INITIATE`, `REITERATE`, `OTHER` |
| 400 | invalid_lookbackDays | `lookbackDays` below 1 on `/{ticker}/coverage` (values above 1825 are capped, not rejected) |
| 400 | invalid_from | `from` on `/{ticker}/consensus/history` is not formatted `YYYY-MM-DD` |
| 400 | invalid_to | `to` on `/{ticker}/consensus/history` is not formatted `YYYY-MM-DD` |
| 400 | invalid_range | `from` is after `to` on `/{ticker}/consensus/history` |
| 400 | invalid_limit | `limit` is below 1 on `/{ticker}/consensus/history`; values above 366 are capped |
| 400 | invalid_limit | `limit` is below 1 on `/{ticker}/called-it`; values above 50 are capped |
| 401 | api_key_required | No API key on a programmatic call |
| 429 | quota_exceeded | Monthly request quota for your tier is used up |
| 404 | entity_not_found | `/{ticker}/consensus`, `/{ticker}/consensus/history`, and `/{ticker}/called-it`: no stock is tracked under that symbol. Carries up to three `suggestions`, and `reason: "non_us_listing"` when the symbol is a non-US listing; resolve with `GET /api/v1/kb/entities/search?q=` and retry |
| 404 | no_coverage | `/{ticker}/consensus`: no current analyst record exists; `/{ticker}/consensus/history`: no history exists at all. No `suggestions`, and retrying a different spelling cannot help |
| 404 | ticker_is_etf | `/{ticker}/consensus`, `/{ticker}/consensus/history`, and `/{ticker}/called-it`: the symbol is a fund. `seeInstead` starts with `/api/v1/etfs/{ticker}/aggregates/analyst`, or is `/api/v1/etfs` for a fund outside ETF coverage |
| 404 | ticker_is_index | `/{ticker}/consensus`, `/{ticker}/consensus/history`, and `/{ticker}/called-it`: the symbol is a market index (`SPX`, `^GSPC`). `seeInstead` starts with the analyst aggregate of a covered fund that tracks it, or is `/api/v1/etfs` |
| 404 | not_found | Other analyst paths: the `slug` on `/people/{slug}` matches no analyst |
