Analytics
Serve each customer their own analytics dashboard from your database, filled by a sync worker that reads GET /v1/analytics with profileId.
When you finish this page each customer's dashboard reads their own metrics from your database, and a background worker keeps it fresh with GET /v1/analytics?profileId=. You need the customer's profileId from the core model. Dashboard renders never call Zernio: the analytics endpoints share one per-second budget across all your customers, and post analytics are cached for about 60 minutes upstream anyway, so a live call buys nothing.
Every analytics endpoint takes profileId, so a customer's profile is the filter with no extra bookkeeping:
| Endpoint | What it gives a customer's dashboard |
|---|---|
GET /v1/analytics/dashboard | Totals, follower growth, a daily series, top and recent posts, and the previous period, in one call |
GET /v1/analytics | Per-post metrics with overview stats, paginated |
GET /v1/analytics/daily-metrics | Day-by-day sums for charts |
GET /v1/accounts/follower-stats | Follower counts and growth at daily, weekly or monthly granularity |
GET /v1/analytics/best-time | Best posting times |
They also take source: late (the legacy value for posts published through Zernio) for posts your app created, external for posts the customer published on the platform itself, all (the default) for both.
Step 1: Pull a customer's posts
Call GET /v1/analytics with profileId, fromDate and page. fromDate and toDate bound the publish dates of the posts returned, at most 366 days apart, and fromDate defaults to 90 days ago.
import Zernio from '@zernio/node';
const zernio = new Zernio();
const profileId = '66a1f0c2a4b9d3e8f1a2b3c4';
const { data: analytics } = await zernio.analytics.getAnalytics({
query: {
profileId,
fromDate: '2026-08-09',
limit: 50,
page: 1,
},
});
console.log(analytics.pagination.pages);Response (200):
{
"overview": {
"totalPosts": 142,
"publishedPosts": 140,
"scheduledPosts": 2,
"lastSync": "2026-09-08T09:12:00Z",
"dataStaleness": { "staleAccountCount": 0, "syncTriggered": false }
},
"posts": [
{
"_id": "65f1c0a9e2b5af0012ab34cd",
"platform": "instagram",
"publishedAt": "2026-09-01T14:00:03Z",
"platformPostUrl": "https://www.instagram.com/p/...",
"analytics": { "likes": 312, "comments": 41, "impressions": 4210, "reach": 3105 },
"platforms": [
{ "platform": "instagram", "syncStatus": "synced" }
]
}
],
"pagination": { "page": 1, "limit": 50, "total": 142, "pages": 3 }
}Step 2: Run a sync worker
There is no "changed since" cursor for metrics. fromDate filters by publish date, and likes keep arriving on posts published weeks ago, so a watermark of fromDate = last sync time only picks up new posts and stops refreshing everything older. Sync in 3 windows instead:
| Window | Cadence | Covers |
|---|---|---|
| Hot | Hourly | Posts from the last 30 days, where metrics still move |
| Long tail | Weekly | Everything older |
| Backfill | Once, at onboarding | The customer's full history, in chunks of at most 366 days |
One worker with a small concurrency cap iterates your customers; the same function serves both recurring windows. On a 429, wait until the limit resets and retry the same page. With a cap of 5 you rarely see one. Follower stats refresh once per day upstream, so put GET /v1/accounts/follower-stats on a daily pass, not the hourly one.
import pLimit from 'p-limit';
import { RateLimitError } from '@zernio/node';
const limit = pLimit(5);
const daysAgo = (n: number) =>
new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10);
// Hourly cron: syncAllCustomers(customers, 30)
// Weekly cron: syncAllCustomers(customers, 366)
export async function syncAllCustomers(customers: Customer[], windowDays: number) {
await Promise.all(
customers.map((c) => limit(() => syncCustomer(c, windowDays)))
);
}
async function syncCustomer(customer: Customer, windowDays: number) {
for (let page = 1; ; page++) {
const { data: pageData } = await withRateLimitRetry(() =>
zernio.analytics.getAnalytics({
query: {
profileId: customer.zernioProfileId,
fromDate: daysAgo(windowDays),
limit: 50,
page,
},
})
);
await upsertPosts(customer.id, pageData.posts); // key on post id + platform
if (page >= pageData.pagination.pages) break;
}
}
async function withRateLimitRetry<T>(call: () => Promise<T>): Promise<T> {
while (true) {
try {
return await call();
} catch (err: unknown) {
if (!(err instanceof RateLimitError)) throw err;
// Reads X-RateLimit-Reset, and is undefined when that header is missing
const seconds = err.getSecondsUntilReset() ?? 5;
await new Promise((r) => setTimeout(r, seconds * 1000));
}
}
}A single-post fetch (GET /v1/analytics?postId=) returns 202 while its sync is pending, so retry it shortly, and 424 when every platform failed to sync it.
Step 3: Pull a dashboard summary in one call
An overview screen usually needs the same six things: totals for the period, the same totals for the period before, follower growth, a daily series, the best posts and the latest posts. GET /v1/analytics/dashboard returns all of them for one customer in a single call, so your worker makes one request per customer per pass instead of paging posts and calling three other endpoints.
| Parameter | Meaning |
|---|---|
profileId | The customer's profile |
fromDate, toDate | Required. UTC days, YYYY-MM-DD, both inclusive, at most 366 days apart. They can be the same day. |
compare=previous_period | Also return previousTotals and previousFollowers for the window of the same length that ends the day before fromDate |
topPosts, recentPosts | How many posts to list, 0 to 25 (defaults 5 and 10). Pass 0 to skip a list. |
platform | One platform, or omit for all of them |
const { data: dashboard } = await zernio.analytics.getAnalyticsDashboard({
query: {
profileId,
fromDate: '2026-09-01',
toDate: '2026-09-30',
compare: 'previous_period',
},
});
console.log(dashboard.totals.impressions, dashboard.previousTotals?.impressions);Response (200), shortened to a two-day window:
{
"dateRange": { "fromDate": "2026-09-01", "toDate": "2026-09-02" },
"totals": { "impressions": 5400, "reach": 4100, "likes": 320, "comments": 41, "shares": 18, "saves": 25, "clicks": 60, "views": 2200, "engagementRate": 7.48 },
"previousTotals": { "impressions": 4800, "reach": 3700, "likes": 290, "comments": 35, "shares": 12, "saves": 20, "clicks": 51, "views": 1900, "engagementRate": 7.44 },
"followers": {
"current": 12840,
"gained": 96,
"byAccount": [
{ "accountId": "64e1f0a9c2b4d5e6f7a8b9c0", "platform": "instagram", "current": 12840, "gained": 96 }
]
},
"previousFollowers": {
"current": 12744,
"gained": 71,
"byAccount": [
{ "accountId": "64e1f0a9c2b4d5e6f7a8b9c0", "platform": "instagram", "current": 12744, "gained": 71 }
]
},
"daily": [
{ "date": "2026-09-01", "impressions": 2900, "reach": 2200, "engagement": 210, "views": 1200, "followersGained": 0 },
{ "date": "2026-09-02", "impressions": 2500, "reach": 1900, "engagement": 194, "views": 1000, "followersGained": 96 }
],
"topPosts": [
{
"postId": "65f1c0a9e2b4d5f6a7b8c9d0",
"platform": "instagram",
"publishedAt": "2026-09-01T15:00:00.000Z",
"metrics": { "impressions": 3100, "reach": 2600, "likes": 210, "comments": 22, "shares": 9, "saves": 14, "clicks": 30, "views": 1500, "engagementRate": 8.23 }
}
],
"recentPosts": [],
"dataAsOf": "2026-09-02T23:10:00.000Z"
}The response mixes two kinds of numbers, and each answers a different question:
totals,previousTotalsanddailycount the engagement that arrived in the window, on any post, the same asattribution=receivedin Step 4. A post from last month that gets 10 likes this week adds 10 to this week.totalsis always the sum ofdaily, andengagementindailyis likes, comments, shares and saves.topPostsandrecentPostslist posts published in the window, each with its lifetime metrics. Top posts are ranked by likes, comments, shares and saves. A post sent to several platforms appears once per platform.
So the sum of topPosts metrics is not expected to match totals.
engagementRate is a percentage: likes, comments, shares and saves over impressions, falling back to reach and then views on platforms that report no impressions. followers.gained is the last follower count inside the window minus the first, and the followersGained values in daily add up to it. followers.current is the live count when the window includes today, otherwise the count the window ended on. dataAsOf is when the most recently synced account of the customer was last synced; render it as the "data as of" label.
Step 4: Build custom charts from daily metrics
When a chart needs more than the dashboard summary gives, such as clicks per day, a per-platform split, or a window longer than a year, call GET /v1/analytics/daily-metrics with profileId for day-by-day sums and a per-platform breakdown, one call per customer, instead of summing per-post rows yourself. attribution decides what a day means:
| A day means | The question it answers | |
|---|---|---|
attribution=publish (default) | Each post's lifetime totals, summed on its publish date | "How did the content I published this week perform?" |
attribution=received | Engagement bucketed by the day it arrived | "How much engagement did my account get this week?" |
A post published July 1 gets 40 likes that day and 10 more on July 20. Under publish, all 50 sit on July 1 and the July 20 bar never moves. Under received, July 1 shows 40 and July 20 shows 10, so the chart moves in weeks the customer did not post, which is what an account-overview widget wants.
const { data: daily } = await zernio.analytics.getDailyMetrics({
query: { profileId, fromDate: '2026-08-09', attribution: 'received' },
});
console.log(daily.dailyData.length);Response (200), one ready-made point per day:
{
"dailyData": [
{
"date": "2026-08-13",
"postCount": 3,
"platforms": { "instagram": 2, "twitter": 1 },
"metrics": { "impressions": 4210, "reach": 3105, "likes": 312, "comments": 41, "shares": 18, "saves": 27, "clicks": 63, "views": 3890 }
}
],
"platformBreakdown": [
{ "platform": "instagram", "postCount": 2, "impressions": 2884, "likes": 240 }
]
}For follower-growth charts, GET /v1/accounts/follower-stats takes the same profileId plus granularity (daily, weekly or monthly), so pick the granularity that matches the chart.
Step 5: Learn about new posts from webhooks
Subscribe to post.published and insert the row when it fires; the accountId on each platforms[] entry maps back to the customer. Its analytics populate on the next sync pass, once the platform makes them available.
Posts the customer publishes outside your app are picked up by a background sync roughly every 90 minutes per account. When a customer pastes the URL of a post they published a moment ago and expects to see it, call POST /v1/posts/sync-external with accountId and url to fetch it on demand. It is debounced per account (about 15 seconds), so use it for that moment and not as a bulk refresh.
An inactive account or one that needs reconnection returns 409 with code: "ads_connection_required", even inside the debounce window. Stop polling that account until it is reconnected, then read GET /v1/accounts for its current ID before resuming. Treat this as a connection problem, not a missing post or a transient sync failure.
Step 6: Show freshness instead of promising real time
Some delays are Zernio's caching; most are the platforms':
| Data | Freshness |
|---|---|
| Post analytics (likes, comments) | Cached about 60 minutes; a stale request triggers a background refresh |
| Post insights (reach, impressions) | Platform-dependent, about 24 hours on Instagram |
| Follower stats | Refreshed once per day |
| External posts | Background sync about every 90 minutes per account |
| YouTube daily views | 2 to 3 days behind YouTube's Analytics API |
Store and render the freshness fields the API returns:
overview.lastSyncandoverview.dataStalenesson list responses feed a "data as of" label.syncStatusper platform entry (synced,pending,unavailable): show a spinner onpendinginstead of a zero.analytics.lastUpdatedper post, for post-detail views.
If it fails
A 429 means the worker is over the per-second analytics budget for your team:
{
"error": "Rate limit exceeded. Please retry after 12 seconds.",
"details": {
"currentCount": 601,
"limit": 600,
"retryAfterSeconds": 12
}
}The response carries a Retry-After header and an X-RateLimit-Reset timestamp; the SDK errors carry that timestamp as getSecondsUntilReset() in Node.js and reset_time in Python. Sleep that long and retry the same page, as the worker above does. Lower the concurrency cap if it keeps happening. The rate limits guide lists the budget per account count.
Related
- Build a platform: the profile-per-customer model.
- Inbox and DMs: the same architecture applied to messaging.
- Rate limits: windows, headers and the account-based ladder.
- Post webhooks: payloads for the publishing lifecycle.