The canonical analytics endpoint. Returns bucketed time-series data for any combination of metric, entity, granularity, and mode. Suitable for agent/MCP consumption and chart rendering.
breakdown_series returns up to 10 per-video series. If the campaign has more than 10 videos, the remainder is aggregated into an entry with entity_ref.id = "__other__" and entity_ref.label = "Other".
Pass format=csv to receive a flat CSV file instead of JSON. The response has Content-Type: text/csv and a Content-Disposition: attachment header with a generated filename.
When compare=prior_period or breakdown is active, those series are appended after the main series rows, distinguished by the series_kind column. All other query parameters (granularity, mode, breakdown, compare) are respected exactly as for JSON.
Campaign response freshness
For entity=campaign queries, historical buckets (any day before today in the campaign's org timezone) are served from a pre-aggregated rollup table. The current day's partial bucket is always served from live data.
The rollup is rebuilt nightly and updated within ~5 minutes of any campaign membership change. The response shape is identical whether the data came from the rollup or the live path — callers do not need to handle either case differently.
Tier retention
tier_retention_days in the response is populated from the caller's org tier (it was always null before Phase 6). Use it to pre-check whether a requested date range falls within the available history:
javascript
const { data } = await fetch('/api/v1/analytics?entity=creator&entity_id=<uuid>&metric=views').then(r => r.json());const maxDays = data.tier_retention_days; // e.g. 90 for Free, null for unlimitedif (maxDays !== null) { const oldestAllowed = new Date(); oldestAllowed.setDate(oldestAllowed.getDate() - maxDays);
Server-side enforcement is active. Requests with from older than the retention window return HTTP 402 with meta.code = 'retention_exceeded'.
Annotations in the response
Every /api/v1/analytics response includes an annotations array. These are time-ordered event markers within the requested date range.
cache_ttl_seconds — always 300 (5 minutes). This is the maximum TTL; tag-based invalidation can evict entries sooner.
cached — true when data was served from a warm cache hit; false on a cold resolver run (first request, or immediately after invalidation).
What triggers cache invalidation:
Event
Tags evicted
Scraper writes video stats
analytics:org:{orgId}
Scraper updates creator profile
analytics:org:{orgId}
Scraper rebuilds campaign rollup
analytics:org:{orgId}:campaign:{campaignId}
Campaign item added or removed
analytics:org:{orgId}:campaign:{campaignId}
Annotation created/updated/deleted
analytics:org:{orgId}:{entityType}:{entityId}
Org analytics_tz changed
analytics:org:{orgId} (full org eviction)
For agents and automated consumers: treat cached: false as a signal that this is fresh data. A cached: true response is at most 5 minutes old and was likely invalidated-then-refetched at a real event boundary.
MCP stability
MCP-stable contract
The response shape of /api/v1/analytics is MCP-stable as of OpenAPI v1.4.0. Key names and types will not change without a major version bump. Additive fields will be introduced without breaking existing consumers.
The machine-readable OpenAPI 3.1 schema is served at GET /api/v1/analytics/schema (version 1.4.0).
new, cumulative
all
Comment count
shares
count
new, cumulative
all
Share count
saves
count
new, cumulative
all
Save count
followers
count
new, cumulative
creator
Follower count (not monotonic — real drops are valid)
Time-ordered event markers (user-authored and auto-generated) within the requested date range. Empty array when no annotations exist. Pass hide_auto=1 to suppress auto annotations.
partial_bucket
object | null
Describes the last in-progress bucket when the range ends on or after today. bucket_end is the final day of that bucket (e.g. last day of the month for granularity=month).
first_data_date
string | null
Earliest non-baseline date across all queried entities. Use for "All time" date presets.
tier_retention_days
integer | null
History window enforced by the org's plan tier. null means unlimited (Ultra). Use this to pre-check whether a date range is in-window before issuing a long-range query. Requests outside this window return HTTP 402 (retention_exceeded).
breakdown != 'none' requested for a rate-only metric (e.g. engagement_rate_by_views). Error path is breakdown; message: "Breakdown is not supported for '<metric>'. Use a count-based metric (views, likes, etc.)"
count
percent
per_week
mode
new, cumulative, or rate
value
Numeric value, or empty string for null (divide-by-zero / missing data)
is_partial
true if this bucket is still in progress
// clamp your from date to oldestAllowed
}
"author_name": null
},
{
"id": "550e8400-...",
"source": "user",
"kind": "user",
"scope": "creator",
"entity_id": "abc-uuid",
"at": "2026-03-20T14:30:00Z",
"title": "Launched new video series",
"body_md": "## Notes\nStarted the weekly Q&A format.",
"author_id": "user-uuid",
"author_name": null
}
]
entity_id
uuid
Entity the annotation belongs to
at
ISO datetime
Timestamp of the event
title
string
Short label (max 200 chars)
body_md
string | null
Optional markdown body (max 8000 chars)
entity_label
string?
Present when cascade-down resolved the annotation from a parent entity