Zernio
Zernio
OverviewBuild a PlatformPublishingAnalyticsInbox and DMs
Dashboard
llms.txtOpenAPI
OverviewPlatformsAPI ReferenceResources
Build a Platform

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:

EndpointWhat it gives a customer's dashboard
GET /v1/analytics/dashboardTotals, follower growth, a daily series, top and recent posts, and the previous period, in one call
GET /v1/analyticsPer-post metrics with overview stats, paginated
GET /v1/analytics/daily-metricsDay-by-day sums for charts
GET /v1/accounts/follower-statsFollower counts and growth at daily, weekly or monthly granularity
GET /v1/analytics/best-timeBest 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:

WindowCadenceCovers
HotHourlyPosts from the last 30 days, where metrics still move
Long tailWeeklyEverything older
BackfillOnce, at onboardingThe 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.

ParameterMeaning
profileIdThe customer's profile
fromDate, toDateRequired. UTC days, YYYY-MM-DD, both inclusive, at most 366 days apart. They can be the same day.
compare=previous_periodAlso return previousTotals and previousFollowers for the window of the same length that ends the day before fromDate
topPosts, recentPostsHow many posts to list, 0 to 25 (defaults 5 and 10). Pass 0 to skip a list.
platformOne 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, previousTotals and daily count the engagement that arrived in the window, on any post, the same as attribution=received in Step 4. A post from last month that gets 10 likes this week adds 10 to this week. totals is always the sum of daily, and engagement in daily is likes, comments, shares and saves.
  • topPosts and recentPosts list 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 meansThe 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=receivedEngagement 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':

DataFreshness
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 statsRefreshed once per day
External postsBackground sync about every 90 minutes per account
YouTube daily views2 to 3 days behind YouTube's Analytics API

Store and render the freshness fields the API returns:

  • overview.lastSync and overview.dataStaleness on list responses feed a "data as of" label.
  • syncStatus per platform entry (synced, pending, unavailable): show a spinner on pending instead of a zero.
  • analytics.lastUpdated per 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.
Was this page helpful?

Publishing

Publish for every customer with an idempotency key, one media upload per asset, each customer's own queue and outcomes from webhooks.

Inbox and DMs

Build one inbox per customer, written by webhooks and read from your database, with replies and backfills through the inbox API.

On this page

Step 1: Pull a customer's postsStep 2: Run a sync workerStep 3: Pull a dashboard summary in one callStep 4: Build custom charts from daily metricsStep 5: Learn about new posts from webhooksStep 6: Show freshness instead of promising real timeIf it failsRelated