# 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.

```bash
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`.

> **Note: Multi-platform aggregation**
>
> Trackagoat 2.0 creators own accounts across TikTok and Instagram. This unified endpoint aggregates a metric across **all** of an entity's platforms; it does not accept a `platform` filter. To scope a project-level view to a single platform, use the [aggregate endpoints](https://www.trackagoat.com/docs/api-reference/aggregate.md), which accept an optional `platform` query parameter.

---

## 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](#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

```json
{
  "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

```bash
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

```bash
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

```bash
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

```bash
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`](https://www.trackagoat.com/docs/api-reference/exports.md) (with paging), which is what the MCP tool `export_csv` uses.

```bash
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.csv
```

### CSV 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:

```javascript
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.

```json
"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.

```bash
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.

```bash
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.

```bash
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.

```bash
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:

```json
"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`: `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

> **Tip: MCP-stable contract**
>
> The response shape of `/api/v2/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/v2/analytics/schema` (version `1.4.0`).

---
Source: https://www.trackagoat.com/docs/api-reference/analytics
