Skip to content

Analytics API

The Analytics API serves persisted multi-channel analytics from daily snapshots collected by the Inngest analytics-collection cron function.

There is one analytics surface: every route lives under /public/v1/analytics/* and is consumed by the dashboard (session cookie), integrators (API key), and MCP/OAuth clients (pos_ tokens) alike, through the dual-auth public middleware. All endpoints are scoped to the org resolved from the credential or session. Public share report endpoints are unauthenticated and token-gated.

Authorization

Three credential types reach the same routes:

CredentialPresented asGatesThrottle
API key (pm_live_)Authorization headerNo entitlement/RBAC gate — throttled read parity60/min per route
OAuth token (pos_)Authorization headerNo entitlement/RBAC gate — throttled read parity60/min per route
Dashboard session cookieCookieRBAC + entitlement enforced; x-csrf-token required on mutationsApp-route limits
  • Cookie-authenticated reads are org routes. /narrate additionally requires the analytics:read RBAC permission.
  • Cookie-authenticated mutating routes (POST /public/v1/analytics/share, DELETE /public/v1/analytics/share, POST /public/v1/analytics/alert-rules, PUT/DELETE /public/v1/analytics/alert-rules/:id, POST /public/v1/analytics/anomalies/:id/dismiss, POST /public/v1/analytics/refresh/:integrationId, and all /public/v1/analytics/watchlist* writes) require the analytics:update RBAC permission and the x-csrf-token header.
  • API-key/OAuth callers get throttled, ungated read parity (the pre-existing public posture). The RBAC permissions listed in the tables below apply to cookie-authenticated callers only.
  • Public read route (/public/analytics-report/:token) is unauthenticated and token-gated.

Date validation

All date-range endpoints validate:

  • from and to are required (400 if missing).
  • Both must be valid dates parsable by dayjs.
  • to must be greater than or equal to from.
  • The window is capped at 400 days.

integrations is a comma-separated list of integration ids. campaigns is a comma-separated list of campaign UUIDs; malformed ids return 400.

Overview

MethodPathPurpose
GET/public/v1/analytics/overviewDashboard overview

Params: from, to, integrations?, compare?, campaigns?

Returns aggregated metrics across all or filtered integrations for a date range with optional period-over-period comparison. Results are cached in Redis for 60s using key analytics:overview:{orgId}:{sha256(JSON params)}. No cache in dev mode.

Channel analytics

MethodPathPurpose
GET/public/v1/analytics/channel/:integrationIdSingle channel analytics
GET/public/v1/analytics/channel/:integrationId/metric/:metricSpecific metric for a channel

Params: from, to, compare?

Returns channel-level analytics with daily breakdowns. The metric detail variant returns data for a single metric (e.g. views, likes, comments) across the date range.

Post analytics

MethodPathPurpose
GET/public/v1/analytics/postsPaginated post list with metrics
GET/public/v1/analytics/post/:postIdSingle post detail

Params (posts): from, to, integrations?, campaigns?, sort?, dir?, page?, limit?
Params (post): date?

  • Posts list: paginated list of posts with aggregated metrics. Sort by engagement, views, date, etc. Max 100 per page.
  • Post detail: returns post KPI header plus daily metric breakdown. Has a live fallback — if no PostAnalyticsSnapshot exists for the post, it fetches from the live provider using the checkPostAnalytics() path.

Metric and day detail

MethodPathPurpose
GET/public/v1/analytics/metric/:metricMetric detail across date range
GET/public/v1/analytics/dayDay-level detail

Params (metric): from, to, integrations?, compare?, campaigns?
Params (day): date, metric, integrations, campaigns?

The metric endpoint provides cross-channel detail for a single metric. The day endpoint provides a per-channel breakdown for one metric on one date.

Insights

MethodPathPurpose
GET/public/v1/analytics/best-timeBest-time-to-post heatmap
GET/public/v1/analytics/recommendationsAI-powered recommendations
GET/public/v1/analytics/content-insightsContent-attribute intelligence
POST/public/v1/analytics/narrateLLM-narrated summary
  • Best time: ?integrations=&integration=&tz= returns a structured day × hour engagement heatmap plus a list of bestSlots. Pass tz as an IANA timezone; without it, post dates are interpreted as UTC.
  • Recommendations: returns prioritized actions (underperforming channels, top patterns, best-time opportunities, missing coverage, comment backlog), each deep-linking to the relevant view.
  • Content insights: surfaces which post attributes are correlated with performance.
  • Narrate: budget-gated (returns 429 if AI budget exceeded). Requires analytics:read for cookie callers. The no-provider rule is enforced in the service.

Health and refresh

MethodPathPermissionPurpose
GET/public/v1/analytics/healthData-health panel
POST/public/v1/analytics/refresh/:integrationIdanalytics:updateOn-demand live channel refresh (~6/hour)

Export

MethodPathPurpose
GET/public/v1/analytics/exportCSV/JSON export

Params: from, to, integrations?, format? (csv or json), compare?, campaigns?

Returns a file download with Content-Disposition: attachment.

MethodPathPurpose
GET/public/v1/analytics/shortlinksShort-link aggregate stats
GET/public/v1/analytics/shortlinks/timeseriesShort-link click time series

Both accept from? and to? and default to the last 30 days.

Anomalies

MethodPathPermissionPurpose
GET/public/v1/analytics/anomaliesList detected anomalies
POST/public/v1/analytics/anomalies/:id/dismissanalytics:updateDismiss an anomaly

GET /public/v1/analytics/anomalies returns stored AnalyticsAnomaly rows with integrationId, metric, date, value, baseline, signed deviation, direction (spike | drop), and optional topPostId. includeDismissed=true includes already-dismissed rows.

Alert rules

MethodPathPermissionPurpose
GET/public/v1/analytics/alert-rulesList user-defined alert rules
POST/public/v1/analytics/alert-rulesanalytics:updateCreate a rule
PUT/public/v1/analytics/alert-rules/:idanalytics:updateUpdate a rule
DELETE/public/v1/analytics/alert-rules/:idanalytics:updateDelete a rule

Rule body:

FieldTypeNotes
integrationIdstring?cuid; omit for "all channels".
metricstringMust be a known metric.
comparatorstringgte, lte, or change_pct.
thresholdnumber0 to 1,000,000,000.
directionstring?up or down; required for change_pct.
enabledboolean?Defaults true.

gte/lte compare the latest snapshot value against threshold. change_pct compares trailing-7-day sum vs prior-7-day sum and fires when the signed percentage change crosses threshold in the specified direction.

Watchlist

MethodPathPermissionPurpose
GET/public/v1/analytics/watchlistList watched accounts
POST/public/v1/analytics/watchlistanalytics:update + competitorsAdd account
GET/public/v1/analytics/watchlist/:id/seriesWatched-account series + own follower series
PUT/public/v1/analytics/watchlist/:idanalytics:updateUpdate account
DELETE/public/v1/analytics/watchlist/:idanalytics:updateRemove account

Body (POST): { provider, handle, displayName? } where provider is one of twitter, linkedin, instagram, facebook, youtube, tiktok.

Body (PUT): { displayName?, enabled? }

Watched accounts have their public metrics probed during the analytics collection sweep. Probe failures (403/unsupported) auto-disable the capability and record lastError without crashing the sweep.

Public share

MethodPathAuthPurpose
GET/public/v1/analytics/sharecookie + analytics:updateGet current share token
POST/public/v1/analytics/sharecookie + analytics:updateMint or rotate share token
DELETE/public/v1/analytics/sharecookie + analytics:updateDisable sharing
GET/public/analytics-report/:tokennoneRead-only public share report

POST /public/v1/analytics/share body: { integrations?: string[], rangePreset?: '7d' | '30d' | '90d' }.

GET /public/analytics-report/:token returns the org's public analytics report if sharing is enabled and the token is valid; otherwise 404.

Campaign analytics

MethodPathPurpose
GET/campaigns/:id/analyticsCampaign-scoped analytics (cookie-authed campaign hub)
GET/public/v1/analytics/campaign/:idCampaign-scoped analytics (unified surface)

Params: from?, to? (default to the last 90 days, clamped to snapshot retention).

Campaign scoping runs only over PostAnalyticsSnapshot rows for posts belonging to the campaign. The live provider fallback is skipped, so a campaign view never fans out to live platform queries. Channel-level metrics (e.g. followers) are omitted.

Because PostAnalyticsSnapshot.value is a cumulative lifetime level, campaign totals are computed as lastLevelInWindow − baseline(post) per post, summed across posts. Series are per-day deltas. This makes a campaign KPI equal to the window delta, not the running total.

Collection and anomaly pipeline

Analytics are collected by the Inngest analytics-collection cron function (apps/backend/src/inngest/functions/analytics-collection.ts, daily 02:00 UTC — requires USE_INNGEST=true). Each sweep runs snapshot collection, prune/rollup, and anomaly detection.

Anomaly detection (AnalyticsActivity.detectAnomalies):

  1. Loads ~35 days of channel snapshots.
  2. Per (integration, metric) series, tests the latest day against a trailing 28-day baseline (mean/σ).
  3. Flow metrics test the raw value; stock metrics are day-over-day differenced first.
  4. A point fires when both the z-test (|z| ≥ ANALYTICS_ANOMALY_Z) and an absolute per-kind floor pass.
  5. Fired points are persisted idempotently on (integrationId, metric, date).
  6. Notifications are cooldown-deduped (ANALYTICS_ANOMALY_COOLDOWN_DAYS) and capped at 3 per org per day, dispatched via NotificationService.notifyAnalyticsAnomaly.
  7. Detection never throws; a failure logs and returns without failing the sweep.

Backfill on connect

Connecting a new social channel emits an analytics/backfill Inngest event, gated on isInngestEnabled() (USE_INNGEST=true), so a fresh channel gets ~90 days of history immediately. The send is non-fatal.

Rollup and retention

  • Daily AnalyticsSnapshot rows older than ANALYTICS_DAILY_RETENTION_DAYS (default 548) are rolled into one weekly row per (integration, metric, ISO week). Flow metrics are summed; stock metrics keep the week's latest.
  • Daily PostAnalyticsSnapshot rows older than ANALYTICS_POST_RETENTION_DAYS (default 90) are pruned after weekly rollup. Because post-snapshot values are cumulative levels, weekly rows keep the week's latest level so read-time level-differencing works unchanged across the daily→weekly seam.

Environment variables

VarDefaultPurpose
ANALYTICS_ANOMALY_Z3z-score threshold for anomaly detection
ANALYTICS_ANOMALY_COOLDOWN_DAYS3Cooldown between anomaly notifications for the same (channel, metric)
ANALYTICS_DAILY_RETENTION_DAYS548Keep raw daily channel snapshots this long before weekly rollup
ANALYTICS_POST_RETENTION_DAYS90Prune per-post daily snapshots older than this

Verified against v1.0.0

The AI-native social media management platform — postmill.ai