Skip to main content

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, so this whole family works with plain keys. Full schemas: OpenAPI reference → Analytics.

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.
  • 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.
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 projectIds.
  • 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_multiplier2 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.