Analytics
Unified time-series analytics endpoint: metrics, granularity, compare, and breakdown.
GET/api/v2/analytics#
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.
curl -H "Authorization: Bearer tga_<key>" \
"https://www.trackagoat.com/api/v2/analytics?entity=creator&entity_id=<uuid>&metric=views&mode=new&granularity=day&from=2026-01-01&to=2026-03-31"The machine-readable OpenAPI 3.1 schema is available at GET /api/v2/analytics/schema.
Query parameters#
| Parameter | Type | Default | Required | Description |
|---|---|---|---|---|
entity | video | creator | campaign | None | Yes | Entity type to query |
entity_id | uuid | None | One of | Single entity. Mutually exclusive with entity_ids. |
entity_ids | CSV of uuids | None | One of | Up to 5 entities for side-by-side comparison. Cannot be combined with breakdown. |
metric | string | None | Yes | Metric ID. See Metrics table. |
mode | new | cumulative | rate | metric default | No | Aggregation mode. Rate-only metrics always use rate. In new mode each video's first-day gain is measured against its last snapshot from before from; a video with no earlier snapshot (posted inside the range) counts from zero. |
granularity | day | week | month | year | cumulative | custom | day | No | Time bucket granularity. cumulative returns a running total per day. custom requires bucket_days. |
bucket_days | integer 1 to 90 | None | If custom | Days per bucket when granularity=custom. |
from | YYYY-MM-DD | 30 days ago | No | Range start, inclusive. |
to | YYYY-MM-DD | today | No | Range end, inclusive. Clamped to today. |
tz | IANA timezone | org setting | No | Timezone for bucketing. Defaults to your org's analytics_tz. from, to and every bucket_start are calendar days in this timezone. |
compare | none | prior_period | none | No | Include a shifted prior-period series in compare_series. |
breakdown | none | video | creator | none | No | Break down into per-constituent series. Top-N kept; remainder rolled into synthetic "Other". |
top_n | integer 1 to 50 | 10 | No | Max breakdown constituents before "Other" rollup. |
formula | by_views | by_followers | None | No | Formula variant for engagement_rate_* metrics. |
base_metric | metric ID | None | If growth_rate_pop | Specifies which raw metric to compute period-over-period growth for. |
format | json | csv | json | No | Response format. Use csv to download a flat CSV file instead of JSON. |
Metrics#
| Metric ID | Unit | Modes | Entities | Notes |
|---|---|---|---|---|
views | count | new, cumulative | all | View count (summed across the entity's platforms) |
likes | count | new, cumulative | all | Like count |
comments | count | 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) |
video_count | count | new, cumulative | creator, campaign | Total videos posted |
engagement_rate_by_views | percent | rate | all | (likes+comments+shares+saves)/views×100 |
engagement_rate_by_followers | percent | rate | creator, campaign | (likes+comments+shares+saves)/followers×100 |
posts_per_week | per_week | rate | creator, campaign | Posts on that day × 7 |
avg_views_per_post | count | rate | creator, campaign | Views gained / cumulative videos through that day |
growth_rate_pop | percent | rate | all | Period-over-period Δ%. Requires base_metric=<metric_id>. |
Response shape#
{
"data": {
"series": [
{
"entity_ref": { "type": "creator", "id": "uuid", "label": "@handle" },
"metric": "views",
"unit": "count",
"mode": "new",
"granularity": "day",
"timezone": "America/New_York",
"points": [
{ "bucket_start": "2026-01-01", "value": 12400 },
{ "bucket_start": "2026-01-02", "value": null }
]
}
],
"compare_series": [],
"breakdown_series": [],
"baselines": [
{
"entity_id": "video-uuid",
"snapshot_at": "2026-01-01T08:00:00Z",
"reason": "tracking_started"
}
],
"annotations": [],
"partial_bucket": {
"bucket_start": "2026-04-01",
"bucket_end": "2026-04-30",
"as_of": "2026-04-20T14:30:00.000Z"
},
"first_data_date": "2026-01-02",
"tier_retention_days": null
},
"error": null,
"meta": { "cache_ttl_seconds": 300, "cached": false }
}Response fields#
| Field | Type | Description |
|---|---|---|
series | array | Primary metric series, one entry per entity. |
compare_series | array | Prior-period series when compare=prior_period. Bucket starts are shifted to align with the main period for easy chart overlay. |
breakdown_series | array | Per-constituent breakdown when breakdown=video|creator. Last entry may be the synthetic "Other" rollup with entity_ref.id = "__other__". |
baselines | array | Dates where tracking started or reset. Use as chart annotation markers to suppress spurious delta spikes. |
annotations | array | 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). |
Series point fields#
| Field | Type | Description |
|---|---|---|
bucket_start | YYYY-MM-DD | Start date of the bucket. For week, this is Monday. For month, the first of the month. |
value | number | null | Metric value. null indicates missing data or divide-by-zero (e.g. 0 views for a rate metric). |
Common examples#
Multi-entity comparison#
curl -H "Authorization: Bearer tga_<key>" \
"https://www.trackagoat.com/api/v2/analytics?entity=creator&entity_ids=<uuid1>,<uuid2>,<uuid3>&metric=views&mode=new&granularity=week"Returns three series entries, one per creator. Use entity_ref.label for chart legends.
Prior-period comparison#
curl -H "Authorization: Bearer tga_<key>" \
"https://www.trackagoat.com/api/v2/analytics?entity=campaign&entity_id=<uuid>&metric=views&compare=prior_period&from=2026-04-01&to=2026-04-20"compare_series contains the prior period (2026-03-12 to 2026-04-01) with bucket starts shifted to match the main period.
Campaign breakdown#
curl -H "Authorization: Bearer tga_<key>" \
"https://www.trackagoat.com/api/v2/analytics?entity=campaign&entity_id=<uuid>&metric=views&breakdown=video&top_n=10"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".
Custom bucket#
curl -H "Authorization: Bearer tga_<key>" \
"https://www.trackagoat.com/api/v2/analytics?entity=video&entity_id=<uuid>&metric=views&mode=new&granularity=custom&bucket_days=7&from=2026-01-01&to=2026-03-31"Error codes#
| Status | meta.code | Cause |
|---|---|---|
| 400 | invalid_params | Missing required params, unknown metric, invalid UUID, constraint violation (see meta.issues). |
| 401 | invalid_api_key | Missing or invalid Bearer token. |
| 404 | None | Entity not found or not accessible under your API key's org. |
| 410 | gone | Removed endpoint (/api/v2/creators/{id}/stats, /api/v2/campaigns/{id}/stats). |
| 400 | invalid_params | 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.)" |
CSV export#
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. The same file is also available as GET /api/v2/exports/analytics (with paging), which is what the MCP tool export_csv uses.
curl -H "Authorization: Bearer tga_<key>" \
"https://www.trackagoat.com/api/v2/analytics?entity=creator&entity_id=<uuid>&metric=views&from=2026-01-01&to=2026-03-31&format=csv" \
-o views.csvCSV columns#
| Column | Description |
|---|---|
series_kind | main, compare, or breakdown |
bucket_start | YYYY-MM-DD start of the time bucket |
entity_id | UUID of the entity |
entity_label | Human-readable label (e.g. @handle) |
metric | Metric ID (e.g. views) |
unit | count, percent, or 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 |
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:
const { data } = await fetch('/api/v2/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 unlimited
if (maxDays !== null) {
const oldestAllowed = new Date();
oldestAllowed.setDate(oldestAllowed.getDate() - maxDays);
// clamp your from date to oldestAllowed
}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/v2/analytics response includes an annotations array. These are time-ordered event markers within the requested date range.
"annotations": [
{
"id": "auto:creator_profile_change:abc:2026-03-15T10:00:00Z",
"source": "auto",
"kind": "creator_profile_change",
"scope": "creator",
"entity_id": "abc-uuid",
"at": "2026-03-15T10:00:00Z",
"title": "Profile bio changed",
"body_md": null,
"author_id": null,
"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
}
]Pass hide_auto=1 to suppress auto-generated annotations and receive only user-authored ones.
| Field | Type | Description |
|---|---|---|
id | string | Row UUID for user annotations; stable synthetic key for auto |
source | user | auto | Who generated this annotation |
kind | string | Event category: see table below |
scope | creator | campaign | video | Entity type the annotation is attached to |
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 |
Annotation kinds#
| Kind | Auto source |
|---|---|
user | Manually authored |
creator_profile_change | creator_events with event_category=profile_change |
creator_milestone | creator_events with event_category=milestone |
campaign_created | campaigns.created_at |
readme_changed | entity_events with event_type=readme_changed |
Annotation CRUD endpoints#
GET/api/v2/analytics/annotations#
List annotations for a specific entity within an optional date range.
curl -H "Authorization: Bearer tga_<key>" \
"https://www.trackagoat.com/api/v2/analytics/annotations?scope=creator&entity_id=<uuid>&from=2026-01-01&to=2026-04-01"Query parameters: scope (required), entity_id (required), from, to.
POST/api/v2/analytics/annotations#
Create a user-authored annotation.
curl -X POST -H "Authorization: Bearer tga_<key>" \
-H "Content-Type: application/json" \
-d '{"scope":"creator","entity_id":"<uuid>","annotation_at":"2026-03-20T14:30:00Z","title":"Launched series","body_md":"**Note:** weekly format"}' \
"https://www.trackagoat.com/api/v2/analytics/annotations"Body fields: scope, entity_id, annotation_at (ISO datetime), title (1 to 200 chars), body_md (optional, ≤8000 chars).
PATCH/api/v2/analytics/annotations/:id#
Update an existing user annotation. Partial updates: only send fields you want to change.
curl -X PATCH -H "Authorization: Bearer tga_<key>" \
-H "Content-Type: application/json" \
-d '{"title":"Updated title"}' \
"https://www.trackagoat.com/api/v2/analytics/annotations/<uuid>"DELETE/api/v2/analytics/annotations/:id#
Delete an annotation permanently.
curl -X DELETE -H "Authorization: Bearer tga_<key>" \
"https://www.trackagoat.com/api/v2/analytics/annotations/<uuid>"Returns {"data":{"id":"<uuid>"},"error":null} on success. Returns 404 if not found.
Cache semantics#
Every JSON response includes:
"meta": { "cache_ttl_seconds": 300, "cached": true }cache_ttl_seconds: always 300 (5 minutes). This is the maximum TTL; tag-based invalidation can evict entries sooner.cached:truewhen data was served from a warm cache hit;falseon 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.