# Aggregate analytics

> Project-level aggregate endpoints for leaderboards, heatmaps, histograms, scatter charts, and membership overlap.

## Overview

The aggregate analytics endpoints return pre-computed project-level summaries that power the Analytics section (Video, Creator, and Campaign sub-tabs). They differ from the unified `/api/v2/analytics` endpoint in that they aggregate **across all entities in a project** rather than for a single entity.

All endpoints:
- Accept both **Bearer API key** and **session cookie** authentication
- Return the standard `{ data, error, meta }` envelope
- Require the caller to have access to the specified `project_id`
- Are not cached (no Vercel Runtime Cache wrapper): responses are fresh on every request

Base path: `/api/v2/analytics/aggregate`

> **Note: What counts**
>
> Every aggregate counts **tracked posts** only: status `auto_tracked`, `included` or `direct_add`, tracking not stopped, from an active creator and an active account. Posts awaiting review are never counted. Date ranges are org-time-zone calendar days, `from` and `to` inclusive. Engagement rates are `(likes + comments + shares + saves) / views` summed over posts. Full definitions: [How metrics are calculated](https://www.trackagoat.com/docs/guides/metrics.md).

> **Note: Multi-platform: the `platform` filter**
>
> Trackagoat 2.0 creators own accounts on TikTok and Instagram. The video- and creator-metric aggregates accept an optional `platform` (`tiktok` \| `instagram` \| `youtube`, where `youtube` is reserved and returns nothing) query parameter that scopes results to a single platform. It is supported on `summary`, `video-leaderboard`, `creator-leaderboard`, `account-leaderboard`, `creator-scatter`, `posting-heatmap`, `posting-consistency`, `view-distribution`, `outliers`, `over-posting-risks`, `campaign-leaderboard`, and `campaign-timeseries`. Omit it to aggregate across all platforms. `campaign-progress` (view targets cover every platform), `goal-compliance`, and `member-overlap` do not accept it.

> **Note: Scoping to one campaign: the `campaign_id` filter**
>
> `video-leaderboard`, `posting-heatmap`, `view-distribution`, `outliers`, `over-posting-risks`, `account-leaderboard`, `campaign-leaderboard`, and `campaign-timeseries` accept an optional `campaign_id` (UUID). When set, video metrics are restricted to the videos the campaign resolves to: videos added directly, every video from its member creators, and anything reached through nested campaigns. The campaign must belong to `project_id`; an id from another project matches nothing (empty results, not an error). `account-leaderboard` additionally narrows its rows to accounts that own at least one video in the campaign. The response `meta` echoes `campaign_id` (or `null`).

---

## Totals

### GET `/aggregate/summary`

Headline totals for a project, one campaign, or every campaign at once, over a date window. The dashboard, every Insights summary strip and Program Health read their headline numbers from this query, so the numbers match the app.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | Project to total |
| `from` | YYYY-MM-DD | 29 days before `to` | Period start (inclusive, org time zone) |
| `to` | YYYY-MM-DD | today (org time zone) | Period end (inclusive) |
| `campaign_id` | UUID | None | Only this campaign (sub-campaigns and member creators included) |
| `campaigns` | `all` | None | The distinct union of every campaign's videos, each video counted once (ignored with `campaign_id`) |
| `platform` | `tiktok` \| `instagram` \| `youtube` | None | Only one platform |

**Response `data`**

```json
{
  "tracked_posts": 2246,
  "views": 10171040,
  "likes": 1711759,
  "comments": 6972,
  "shares": 26461,
  "saves": 33878,
  "engagement_rate": 17.49,
  "creators_posting": 85,
  "accounts_posting": 85,
  "roster_creators": 106,
  "roster_accounts": 110,
  "platforms": 2,
  "total_followers": 208181,
  "follower_growth": 3958,
  "pending_review_posts": 61,
  "posts_without_history": 177,
  "stats": { "posts": 2246, "within_48h": 1690, "never_scraped": 0, "oldest_at": "2026-09-11T10:30:30Z", "newest_at": "2026-10-09T19:31:00Z" }
}
```

`views` is the current lifetime views of the tracked posts posted in the window (not views gained during it). `engagement_rate` is a percentage, `null` without views. `follower_growth` is measured per account (an account added mid-window counts from its first snapshot) and summed. `pending_review_posts` are posts in the window awaiting review; they are not in any other number. `stats` says how fresh the video stats behind the numbers are. `meta` echoes `from`, `to` and `timezone`.

---

## Video endpoints

### GET `/aggregate/video-leaderboard`

Returns a ranked list of tracked videos in the project for the given date range.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | Project to query |
| `from` | YYYY-MM-DD | None | Period start (inclusive) |
| `to` | YYYY-MM-DD | None | Period end (inclusive) |
| `order` | enum | `view_count` | Sort column: `view_count`, `like_count`, `comment_count`, `share_count`, `save_count`, `posted_at` |
| `platform` | `tiktok` \| `instagram` \| `youtube` | None | Restrict to videos on a single platform |
| `campaign_id` | UUID | None | Restrict to videos the campaign resolves to (must belong to `project_id`) |
| `limit` | int | 100 | Max rows (1 to 200) |
| `offset` | int | 0 | Pagination offset |

**Response `data` array**: each item:

```json
{
  "video_id": "uuid",
  "title": "string | null",
  "thumbnail_url": "string | null",
  "url": "string | null",
  "view_count": 12345,
  "like_count": 678,
  "comment_count": 45,
  "share_count": 12,
  "save_count": 89,
  "posted_at": "2026-04-01T12:00:00Z",
  "creator_id": "uuid",
  "handle": "string",
  "display_name": "string | null",
  "avatar_url": "string | null",
  "total_count": 87
}
```

`total_count` is the total matching rows (before `limit`/`offset`). `handle` is the creator's primary account handle. Pass `platform` to restrict the leaderboard to videos on one platform.

---

### GET `/aggregate/posting-heatmap`

Returns a DOW × hour grid of post counts and average view counts, bucketed in the org's timezone.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | |
| `from` | YYYY-MM-DD | required | |
| `to` | YYYY-MM-DD | required | |
| `tz` | IANA string | org timezone | Override timezone |
| `platform` | `tiktok` \| `instagram` \| `youtube` | None | Restrict to posts on a single platform |

**Response `data` array**: each item:

```json
{ "dow": 1, "hour": 14, "post_count": 5, "avg_views": 12430.50 }
```

`dow`: 0=Sunday … 6=Saturday. Only cells with at least one post are returned; missing cells = 0.

---

### GET `/aggregate/view-distribution`

Returns a histogram of video view counts with percentile markers.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | |
| `from` | YYYY-MM-DD | required | |
| `to` | YYYY-MM-DD | required | |
| `bin_count` | int | 20 | Number of histogram bins (5 to 50) |
| `platform` | `tiktok` \| `instagram` \| `youtube` | None | Restrict to videos on a single platform |

**Response `data` object:**

```json
{
  "total": 87,
  "min": 100,
  "max": 5200000,
  "p50": 12000,
  "p75": 48000,
  "p90": 210000,
  "p99": 1800000,
  "bins": [
    { "bin_index": 0, "bin_lower": 100, "bin_upper": 260250, "video_count": 61 },
    ...
  ]
}
```

---

### GET `/aggregate/outliers`

Returns videos that outperform their creator's median view count by at least `min_x_multiple`, sorted by `x_multiple` descending.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | |
| `from` | YYYY-MM-DD | None | Optional period filter |
| `to` | YYYY-MM-DD | None | Optional period filter |
| `min_x_multiple` | number | 1.5 | Minimum outperformance ratio |
| `platform` | `tiktok` \| `instagram` \| `youtube` | None | Restrict to videos on a single platform |
| `limit` | int | 50 | Max rows (1 to 100) |

**Response `data` array**: each item includes `video_id`, `creator_id`, `view_count`, `median_views`, `x_multiple`, `posted_at`, plus enriched `title`, `thumbnail_url`, `url`, `handle`, `display_name`, `avatar_url`.

---

## Creator endpoints

### GET `/aggregate/creator-leaderboard`

Returns creators in the project ranked by views in the period, with follower delta and engagement metrics.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | |
| `from` | YYYY-MM-DD | None | |
| `to` | YYYY-MM-DD | None | |
| `platform` | `tiktok` \| `instagram` \| `youtube` | None | Restrict to creators/videos on a single platform |
| `limit` | int | 100 | Max rows (1 to 200) |
| `offset` | int | 0 | |

**Response `data` array**: each item:

```json
{
  "creator_id": "uuid",
  "handle": "string",
  "display_name": "string | null",
  "avatar_url": "string | null",
  "current_followers": 45000,
  "follower_delta": 1200,
  "total_views": 980000,
  "posts_in_period": 14,
  "posts_per_week": 1.0,
  "engagement_rate": 3.45,
  "growth_rate": 2.74,
  "total_count": 7
}
```

`follower_delta` = follower count at end of period minus start of period (from `creator_stats_history`). Zero when no snapshots in range.

`engagement_rate` = (likes + comments + shares) ÷ views × 100 for videos posted in the period.

---

### GET `/aggregate/posting-consistency`

Returns per-creator, per-day post counts for building a punch-card matrix.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | |
| `from` | YYYY-MM-DD | required | |
| `to` | YYYY-MM-DD | required | |
| `tz` | IANA string | org timezone | |
| `platform` | `tiktok` \| `instagram` \| `youtube` | None | Restrict to posts on a single platform |

**Response `data` array**: sparse (only days with posts):

```json
{ "creator_id": "uuid", "day": "2026-04-05", "post_count": 2 }
```

Every (creator, day) pair in the range is returned, ordered by creator then day. (Before 2026-10-08 the response was silently truncated at 1,000 rows.)

---

### GET `/aggregate/over-posting-risks`

Flags creator/day combinations where a creator posted enough times in a single day that some videos were held back from tracking (suppressed) to control scrape cost.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | |
| `from` | YYYY-MM-DD | required | |
| `to` | YYYY-MM-DD | required | |
| `tz` | IANA string | org timezone | Override timezone |
| `platform` | `tiktok` \| `instagram` \| `youtube` | None | Restrict to posts on a single platform |

**Response `data` array**: each item:

```json
{
  "creator_handle": "string",
  "creator_id": "uuid",
  "post_date": "2026-04-05",
  "total_posts": 9,
  "suppressed_count": 3,
  "suppressed_video_ids": ["uuid", "uuid", "uuid"],
  "creator_last_scraped_at": "2026-04-06T02:00:00Z"
}
```

`creator_last_scraped_at` is the most recent scrape across all of the creator's platform accounts (`null` if never scraped).

---

### GET `/aggregate/creator-scatter`

Returns per-creator (follower change, engagement rate) pairs for scatter chart rendering.

**Query parameters**: `project_id`, optional `from`/`to`, optional `platform` (`tiktok` \| `instagram` \| `youtube`) to restrict to a single platform.

**Response `data` array**:

```json
{
  "creator_id": "uuid",
  "handle": "string",
  "follower_delta": 1200,
  "engagement_rate": 3.45,
  "posts_count": 14
}
```

Only creators with at least one tracked post in the period are included.

---

### GET `/aggregate/goal-compliance`

Returns a project-wide roll-up of creator goal statuses across every creator with active goals.

**Query parameters**: `project_id`.

**Response `data` object:**

```json
{
  "on_track": 3,
  "at_risk": 1,
  "missed": 1,
  "total_goals": 5,
  "creators_with_goals": 5,
  "with_advisories": 4
}
```

`on_track` includes `completed` statuses. `at_risk` includes `behind_pace`.

`with_advisories` is the count of goals where the `advisory.has_advisory` flag is true, i.e., goals whose displayed progress may not yet be final (period in progress, period recently closed, stale scrape, or goal not yet started). See the [Goal Compliance](https://www.trackagoat.com/docs/api-reference/goals.md) endpoint for per-goal advisory detail.

---

## Campaign endpoints

### GET `/aggregate/campaign-leaderboard`

Returns campaigns in the project sorted by views, with prior-period delta comparison.

**Query parameters**: `from`, `to` (optional; default the last 30 days including today, org time zone); `project_id` and/or `campaign_id`. Pass `project_id` alone for every campaign in the project, or `campaign_id` to return just that one campaign's row, in which case `project_id` is optional: it's resolved from the campaign (a campaign outside your org returns `404`). Add `platform` (`tiktok` \| `instagram` \| `youtube`) to count only that platform's posts, in both the current and the prior period. The response `meta` echoes the resolved `project_id` and `platform`.

**Response `data` array:**

```json
{
  "campaign_id": "uuid",
  "name": "string",
  "total_views": 1200000,
  "total_likes": 45000,
  "video_count": 32,
  "creator_count": 5,
  "preview_thumbnail_url": "string | null",
  "prior_views": 980000,
  "views_delta": 220000,
  "views_delta_pct": 22
}
```

Prior period is automatically computed as the same duration immediately preceding `from`. Each row counts its campaign's videos once; a video in two campaigns appears in both rows, so don't add rows up for a total: use [`/aggregate/summary`](#get-aggregatesummary) with `campaigns=all`. `creator_count` is the number of creators with a tracked post in the window.

---

### GET `/aggregate/campaign-timeseries`

Returns the trend series behind the Campaign Insights charts: impressions, posts, distinct creators posting, and first-time creators per time bucket, zero-filled across the range.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | Project to query |
| `campaign_id` | UUID | None | Scope to one campaign's resolved videos. Omit for every active tracked video in the project |
| `from` | YYYY-MM-DD | required | Range start (inclusive, org-TZ calendar day) |
| `to` | YYYY-MM-DD | required | Range end (inclusive) |
| `granularity` | `day` \| `week` \| `month` \| `year` | `day` | Bucket width. Weeks start on Monday |
| `tz` | IANA tz | None | Override the org timezone used for day boundaries |
| `platform` | `tiktok` \| `instagram` \| `youtube` | None | Only count posts on this platform (every series, including first-time creators) |

**Response `data` array**: one item per bucket, ordered by `bucket_start`:

```json
{
  "bucket_start": "2026-08-10",
  "impressions": 456467,
  "posts": 436,
  "creators_posting": 39,
  "new_creators": 4
}
```

- `impressions` is the views gained during the bucket, attributed to the day the snapshot moved (not the video's posting day). A video's first snapshot (its pre-tracking views) is credited to its posting day when that day is inside the range.
- `posts` and `creators_posting` are by posted-at date.
- `new_creators` counts creators whose earliest post in the scope falls in the bucket; each creator counts once over all time, not once per range.
- Impressions per post is not returned; compute `impressions / posts` per bucket client-side and treat buckets with `posts = 0` as undefined.

`meta` echoes `from`, `to`, `tz`, `granularity`, and `campaign_id`.

---

### GET `/aggregate/member-overlap`

Returns entities (creators or videos) that appear as direct members in campaigns, with the list of campaigns each belongs to. Capped at 50 rows.

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | |
| `dimension` | `creator` \| `video` | `creator` | Entity type to list |

**Response `data` array:**

```json
{
  "entity_id": "uuid",
  "entity_label": "handle or title",
  "entity_avatar": "string | null",
  "campaign_ids": ["uuid", "uuid"],
  "campaign_names": ["Campaign A", "Campaign B"],
  "campaign_count": 2
}
```

Only **direct** membership (via `campaign_items`) is considered, not recursive resolution through nested campaigns.

---

## Conversion aggregates

Three endpoints summarise recorded conversions. All of them ignore voided
conversions entirely, because a void is a retraction and never appears in a total.

> **Warning: One row per currency: never sum across them**
>
> Currency lives on the conversion event, not the conversion, so a project may
> hold a USD event and a EUR event at once. Every conversion aggregate therefore
> returns **one row per currency** rather than a single blended total. Render them
> side by side; adding them together produces a number that means nothing.
>
> A single-currency project (the common case) simply gets one row.

Unlike the [conversions list](https://www.trackagoat.com/docs/api-reference/conversions.md), which takes full
ISO-8601 timestamps, these take bare `YYYY-MM-DD` dates like the other
aggregates. `to` is **inclusive**, resolved against the organization's timezone.

### GET `/api/v2/analytics/aggregate/conversion-summary`

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | UUID | required | |
| `from` | `YYYY-MM-DD` | None | Inclusive start |
| `to` | `YYYY-MM-DD` | None | Inclusive end |
| `conversion_event_id` | UUID | None | Restrict to one event type |

Returns zero rows when nothing was recorded in range: there is no correct
currency to denominate an empty total in, so render an empty state rather than
a zero.

```json
{
  "total_conversions": 128,
  "total_quantity": 194,
  "total_value_cents": 486000,
  "currency": "USD",
  "distinct_creators": 12,
  "distinct_events": 3
}
```

### GET `/api/v2/analytics/aggregate/conversion-leaderboard`

Creators ranked by conversion value. Accepts the summary parameters plus
`limit` and `offset`.

Rows are per **(creator, currency)**, so a creator with conversions in two
currencies appears twice. `total_count` counts groups, not creators.

Totals include the full rollup: a creator's figure covers conversions recorded
against their accounts and their individual videos, not just those attributed
to the creator directly.

```json
{
  "creator_id": "uuid",
  "display_name": "Jules",
  "handle": "wyndywithjuls",
  "avatar_url": "string | null",
  "conversion_count": 34,
  "total_quantity": 41,
  "total_value_cents": 128000,
  "currency": "USD",
  "total_count": 12
}
```

### GET `/api/v2/analytics/aggregate/conversion-timeseries`

Daily buckets in the organization's timezone, one row per **(day, currency)**.
The series is **sparse**: days with no conversions are omitted, so fill the
spine client-side.

```json
{ "day": "2026-07-18", "conversion_count": 9, "total_value_cents": 22500, "currency": "USD" }
```

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