> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.scripe.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Analytics

# Analytics

Read your own LinkedIn performance and search the viral-post
inspiration feed. Everything here is read-only and gated by one scope:
`analytics:read` — grantable to
[API keys](./auth.md#12-scopes), so this whole family works
with plain keys.

Full schemas: **OpenAPI reference → Analytics**.

| Method | Path                                     | Notes                                                                                                                         |
| ------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| GET    | `/v1/analytics/overview`                 | Current vs previous-period rollup: posts, impressions, engagement rate, follower growth, activity.                            |
| GET    | `/v1/analytics/posts`                    | Per-post LinkedIn metrics (views, likes, comments, shares, engagement rate), newest first.                                    |
| GET    | `/v1/analytics/report`                   | A downloadable report — `mode=pdf` (styled document) or `mode=csv`.                                                           |
| GET    | `/v1/analytics/cross-workspace/overview` | The overview for **every workspace you can reach**, one attributed row each plus combined totals. OAuth only.                 |
| GET    | `/v1/analytics/cross-workspace/report`   | The full report per reachable workspace, as JSON. OAuth only.                                                                 |
| GET    | `/v1/viral-posts`                        | Semantic search over the inspiration feed for high-performing posts on a topic. Returns outliers only by default — see below. |

```bash theme={null}
curl -s "https://api.scripe.io/v1/analytics/overview?projectId=proj_…" \
  -H "Authorization: Bearer scripe_sk_live_…" \
  -H "Scripe-Api-Version: 2026-08-10"
```

***

## Semantics worth knowing

* **Multi-project aggregation.** All three endpoints share one resolver,
  so `projectId` accepts a comma-separated list everywhere — `/overview`,
  `/posts` and `/report` alike. `projectIds` is an accepted alias, and
  the two are merged if you send both. Omitting them aggregates across
  every project the caller can reach. Either way the set is capped at 50
  projects: more than 50 explicit ids returns `400 invalid_request`,
  while the omit-everything branch silently stops at 50.
* **Rows carry three ids; only `post_id` is actionable.** A
  `/v1/analytics/posts` row describes a post that is live on LinkedIn:
  `id` is the measurement row (no endpoint accepts it), `post_id` is
  the Scripe post (`null` for anything not published through Scripe —
  most of a mature account's back catalogue), and `permalink` opens the
  LinkedIn copy. Use `permalink` to show the user the post; use
  `post_id` with `/v1/posts/{postId}`.
* **Asking about one post? Pass `postId`.** `/v1/analytics/posts`
  takes `postId` (repeatable, up to 50 `post_…` ids) and answers with
  just those rows plus a `meta` block: `meta.unmeasured` names each
  requested post that has no numbers and why — `not_published` (never
  went out) or `not_measured` (published, but LinkedIn's sync hasn't
  landed; it usually does within the hour). An id the project does not
  contain is a `400`, never an empty page. Its `dateFrom`/`dateTo`
  filter **publication** dates, so they line up with `publishedFrom`
  windows on `/v1/posts`.
* **Post bodies are excerpts by default.** `/v1/analytics/posts` takes
  the same `content=preview|full|none` modes as `/v1/posts`, with
  `content_truncated` beside each body — see
  [Conventions § Large text fields](./conventions.md#large-text-fields-in-a-list).
* **`sort` does not rank by rate.** `impressions` ranks by views and
  `engagement` by total interactions; if your user means engagement
  *rate*, sort client-side on `metrics.engagement_rate`.
* **The report is a file.** `GET /v1/analytics/report` returns a binary
  body (`application/pdf` or `text/csv`) with a `Content-Disposition`
  filename — executive summary, earned media value, per-author
  performance, every post in the period (highest-impression first, up to
  3,000 rows — when a period holds more, the file says so and points at
  `GET /v1/analytics/posts`, which pages through all of them), daily
  engagement series, follower timeline.
  `currency` (EUR/USD/GBP) and `cpm` tune the earned-media computation. The MCP tool
  `get_analytics_report` returns the same figures as JSON, with the
  post table capped to the genuine top 20 of the period (its
  `postsTotal` counts every post; page the rest via
  `list_post_analytics`). The 3,000-row ceiling is there for pathological
  ranges, not as a ranking cut, and when it is reached the file says so
  above the post table, naming how many posts were dropped, never
  silently.
* **Two engagement rates in the report.** The daily series carries
  `engagementRate` — that day's own engagement over that day's
  impressions, `0` on a day with no impressions — and
  `cumulativeEngagementRate`, the running rate from the start of the
  range (what the in-app KPI card plots). The CSV carries both columns;
  the PDF's *Daily Metrics* table shows the per-day rate only. Read the
  per-day rate to compare days, the cumulative one for a trend line.
* **Three click figures, three sources — and the dashboard never adds
  them up.** The report's `summary` (and the `get_analytics_report` JSON)
  carries `totalLinkClicks`, LinkedIn's count of clicks on links inside
  the period's posts, attributed to each post's publish date — the
  dashboard's *Link clicks* KPI card; `totalTrackedLinkClicks`, clicks on
  the workspace's Scripe tracked links (`scripe.io/link/…` and custom
  link domains) whose *click* date falls in the period — the dashboard's
  conversion-funnel *Tracked link clicks* stage (labelled *Link clicks*
  before SCR-1719, so an older screenshot still maps) and its Click
  tracking section, always workspace-wide because tracked links are
  workspace-owned, so a
  `projectId` selection does not narrow it; and `totalPremiumCtaClicks`,
  LinkedIn's clicks on the profile's Premium call-to-action button, which
  is not a link click. Each `posts[]` row carries the first and third
  per post. A reading off the funnel can only be rebuilt from
  `totalTrackedLinkClicks`; a reading off the KPI card only from
  `totalLinkClicks`. `totalTrackedLinkClicks` is `null` only when the
  report was built without a workspace, which no request on this API is.
* **Viral-post search excludes your own author** — it's an inspiration
  feed, not self-analytics. It is bounded before it ranks (last 90
  days, the project's language, `minEngagement` ≥ 100 by default), so
  an empty result means nothing recent cleared the filters — widen
  `language` or lower `minEngagement` before concluding the topic is
  uncovered. `metrics.views` is `null`, not `0`, when LinkedIn
  withholds the count (it does for \~97% of third-party posts), and
  `meta.returned` is the number of rows served — there is no paging and
  no corpus-wide total.
* **Metrics lag LinkedIn.** Figures reflect Scripe's most recent sync,
  not a live LinkedIn call.

***

## Across every workspace you can reach

Every other endpoint on this API runs against one active workspace, and
that is structural rather than a gap: a project id from another
workspace answers `404`, on purpose. The two
`/v1/analytics/cross-workspace/*` endpoints are the exception — they
answer "how are all my clients doing" in one call instead of a
`switch → list → measure` loop per client.

```bash theme={null}
curl -s "https://api.scripe.io/v1/analytics/cross-workspace/overview" \
  -H "Authorization: Bearer scripe_oat_…" \
  -H "Scripe-Api-Version: 2026-08-10"
```

```jsonc theme={null}
{
  "data": {
    "range": { "from": "…", "to": "…", "days": 30 },
    "workspaces": [
      {
        "workspace": { "id": "org_2pYJ…", "name": "Acme Corp", "is_default": true, "reach": "member" },
        "projects": { "count": 3 },
        "posts": { "current": 12, "previous": 8 },
        "impressions": { "current": 4200, "previous": 3100 },
        "engagement": { "current_rate": 5.4, "previous_rate": 4.1 },
        "followers": { "current": 980, "growth": 35 },
        "activity": { "current_posts_per_week": 3, "previous_posts_per_week": 2 }
      },
      {
        "workspace": { "id": "org_2rLe…", "name": "Client B", "is_default": false, "reach": "agency_owner" },
        "projects": { "count": 1 },
        "…": "…"
      }
    ],
    "totals": { "…": "same figures over every project above" }
  },
  "meta": {
    "selection": "all_reachable",
    "workspaces_reachable": 2,
    "workspaces_returned": 2,
    "max_workspaces": 20,
    "truncated": false,
    "skipped": [],
    "projects_truncated": [],
    "totals_omitted_reason": null
  }
}
```

Things to know before you build on it:

* **Selection.** Omit `workspaceId` for every reachable workspace (`all`
  is an explicit spelling of the same thing), or pass one id / a
  comma-separated list. A named workspace you cannot reach is refused
  with `workspace_unavailable` — never silently omitted, because an
  omitted client reads as a client with no activity.
* **Naming more workspaces than the cap is refused, not truncated.**
  That is the opposite of the default `all_reachable` selector, which
  truncates loudly (`meta.truncated`) rather than refusing. The
  asymmetry is deliberate: silently dropping some of a list you named
  explicitly would be worse than telling you outright — split the
  request across calls instead. Omitting `workspaceId` is what truncates.
* **Reach is the same model `GET /v1/workspaces` reports**: Clerk
  membership or the billing-based agency-owner grant, echoed per row as
  `workspace.reach`. Inside each workspace the ordinary project rule
  applies, so a plain member still sees only their assigned projects.
* **`totals` is a query, not a sum.** It re-measures the union of every
  project in the response, because an engagement *rate* has no
  meaningful average and reconstructing engagement counts from
  `rate × impressions` drops every post LinkedIn withheld impressions
  for. It is `null` when there was nothing to total or the union was too
  large for another aggregate pass; `meta.totals_omitted_reason` says
  which, and you should report no total rather than adding the rows up.
* **`meta.skipped` is not zero activity.** A reachable workspace whose
  projects you cannot read is named there instead of being dropped.
* **`meta.truncated`** means you are reading the first
  `meta.max_workspaces` reachable workspaces — pass the rest explicitly
  in `workspaceId`, at most `meta.max_workspaces` per call (a longer
  list is refused, per the bullet above). The report endpoint visits
  fewer per call than the overview, because it returns a whole document
  each.
* **`meta.workspaces_reachable` is your full reach, on both selectors**
  — not the number you named, and not a truncation signal. On an
  explicit selection it normally exceeds `meta.workspaces_returned`
  while `meta.truncated` is false; read `truncated` for that.
* **`meta.projects_truncated`** names any workspace holding more
  readable projects than one call measures, whose row and share of
  `totals` therefore cover only its newest projects. Empty when every
  workspace fitted; measure those workspaces through
  `/v1/analytics/overview` with explicit `projectId`s.
* **The report variant is JSON only.** The PDF and CSV renderers lay out
  one organisation's report; fetch the file per workspace from
  `GET /v1/analytics/report` with a `Scripe-Workspace-Id` header.
  Each workspace's `report.posts[]` holds only the top
  `meta.posts_per_workspace` of the period while `report.postsTotal`
  counts them all.
* **OAuth only, and `projectId` is rejected.** An API key is bound to
  one workspace at creation, so there is nothing to aggregate across
  (`400 invalid_request`), and a project id is a workspace-scoped id
  arriving at a workspace-crossing call — use `/v1/analytics/overview`
  for specific projects. Both endpoints need `analytics:read` **and**
  `workspace:read`, since the response enumerates workspaces.
* **The single-workspace endpoints are unchanged.** Nothing above alters
  what `/v1/analytics/overview`, `/posts` or `/report` return.

## Viral-post search returns outliers, not big numbers

`GET /v1/viral-posts` narrows to posts that beat their OWN author's
median engagement by at least `meta.min_outlier_multiplier` — **2 by
default** — so a large account's ordinary post is not reported as a
hit. Each post carries that ratio as `outlier_multiplier` (capped at
10\) and the denominator behind it as `outlier_baseline`: only `AUTHOR`
compared the post to its own writer, so only `AUTHOR` supports a
sentence about that person's usual performance. `null` means the post is
not scored — too fresh, or below the scoring floor — and is reachable
only with `minOutlierMultiplier=0`, which opts out of the floor
entirely.

Three parameters narrow the search and `meta` reports all three back:
`minOutlierMultiplier`, `mediaFormats` (a comma-separated list —
`carousel` is a swipeable deck, `multi_image` several photos, `image` at
most one) and `publishedWithinDays` (90 by default, 180 max).
`meta.narrowed_to` is present when you narrowed beyond the default (a
floor above 2, any media format, or a window under 90 days) and the
answer is short, and names what to widen. One of those filters may be
why — a short answer under a `carousel` filter is more often the
corpus's shape than a missing topic — so offer to widen rather than
conclude the topic is unwritten; the search cannot tell a thin topic
from a filtered one.

Each post also carries `media_format`, classified from the stored media
payload first and the media type only as a fallback, so it agrees with
the shape the Scripe app shows for the same post. A post whose media type
says carousel but whose payload proves neither a deck nor several images
is `other`: that type alone is written for both shapes, so a `carousel`
filter never serves it.

## Agent access

`get_analytics_overview`, `list_post_analytics`, `search_viral_posts`,
`get_analytics_report`, the two cross-workspace twins
(`get_cross_workspace_analytics_overview`,
`get_cross_workspace_analytics_report`), and the plan-gated
`get_posting_times` (best posting times, Advanced+ plans) are the MCP
tools — see [MCP tools](./mcp-tools.md#1-read-tools).
