Tenant Analytics
Show each of your customers their own social analytics - profileId filters, the tiered sync-worker pattern, request budgets, and data freshness expectations.
You want each of your customers to open a dashboard in your app and see their numbers: post performance, engagement trends, follower growth, best time to post. This recipe shows how to build that on the profile-per-customer model without hitting rate limits or unknowingly serving stale data.
Every analytics endpoint takes a tenant filter
Because each customer is a profile, profileId is your tenant filter — no extra bookkeeping needed:
| Endpoint | What it gives a tenant dashboard |
|---|---|
GET /v1/analytics | Per-post metrics with overview stats, paginated |
GET /v1/analytics/daily-metrics | Day-by-day aggregates, ideal for charts |
GET /v1/accounts/follower-stats | Follower counts and growth, daily/weekly/monthly granularity |
GET /v1/analytics/best-time | Best posting times |
They also accept a source filter: late for posts published through your app, external for posts your customer published natively on the platform, all (the default) for both. Agency-style dashboards usually want both; a pure scheduling tool may prefer source=late.
const { data } = await zernio.analytics.getAnalytics({
query: {
profileId: customer.zernioProfileId,
fromDate: '2026-06-14', // YYYY-MM-DD, max 366 days before toDate
limit: 50,
page: 1,
},
});
// data.posts, data.overview, data.paginationdata = client.analytics.get_analytics(
profile_id=customer.zernio_profile_id,
from_date="2026-06-14", # YYYY-MM-DD, max 366 days before to_date
limit=50,
page=1,
)
# data["posts"], data["overview"], data["pagination"]curl "https://zernio.com/api/v1/analytics?profileId=PROFILE_ID&fromDate=2026-06-14&limit=50" \
-H "Authorization: Bearer YOUR_API_KEY"Don't proxy dashboard renders to the API
The tempting architecture — every widget on your customer's dashboard calls Zernio directly on page load — falls over for two reasons:
- Analytics endpoints run on a per-second window (6–20 req/s depending on your tier), shared across all your tenants. Twenty customers opening a four-widget dashboard at the same moment is 80 near-simultaneous requests — most of them get
429. - Live calls don't buy real-time data anyway. Post analytics are cached for ~60 minutes upstream (a stale request triggers a background refresh), and the platforms themselves delay insights. You'd spend your budget re-fetching the same numbers.
The pattern that works: serve dashboards from your own database, and refresh it with a background sync worker. Dashboard renders never call Zernio — only the worker and your webhook endpoint do:
The sync-worker pattern
One thing to internalize first: there is no "changed since" cursor for metrics. fromDate/toDate bound the publish dates of the posts returned — but likes and comments keep arriving on posts published weeks ago. A naive watermark (fromDate = last sync time) only picks up newly published posts and silently stops refreshing everything older.
So sync in tiers, matched to how fast metrics actually move:
| Tier | Cadence | Covers | Why |
|---|---|---|---|
| Hot window | Hourly | Posts from the last ~30 days | Metrics still change meaningfully |
| Long tail | Weekly | Everything older | Engagement has decayed to a trickle |
| Backfill | Once, at onboarding | The tenant's full history | Max fromDate→toDate range is 366 days — page through history in year-sized chunks |
The implementation is one worker with a small concurrency cap, iterating your tenants. The same function serves both recurring tiers — only the window changes. On a 429, it waits for the Retry-After header and retries; with a cap of 5 you'll rarely see one.
import pLimit from 'p-limit';
const limit = pLimit(5); // stay well under the per-second analytics cap
const daysAgo = (n: number) =>
new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10);
// Hourly cron: syncAllTenants(customers, 30)
// Weekly cron: syncAllTenants(customers, 366)
export async function syncAllTenants(customers: Customer[], windowDays: number) {
await Promise.all(
customers.map((c) => limit(() => syncTenant(c, windowDays)))
);
}
async function syncTenant(customer: Customer, windowDays: number) {
for (let page = 1; ; page++) {
const { data } = await withRateLimitRetry(() =>
zernio.analytics.getAnalytics({
query: {
profileId: customer.zernioProfileId,
fromDate: daysAgo(windowDays),
limit: 50,
page,
},
})
);
await upsertPosts(customer.id, data.posts); // key on post id + platform
if (page >= data.pagination.pages) break;
}
}
// 429s carry Retry-After (seconds); anything else bubbles up.
// Adapt the status/header access to how your HTTP client surfaces them.
async function withRateLimitRetry<T>(call: () => Promise<T>): Promise<T> {
while (true) {
try {
return await call();
} catch (err: any) {
if (err.status !== 429) throw err;
const seconds = Number(err.headers?.get('Retry-After') ?? 5);
await new Promise((r) => setTimeout(r, seconds * 1000));
}
}
}import time
from datetime import date, timedelta
def days_ago(n: int) -> str:
return (date.today() - timedelta(days=n)).isoformat()
# Hourly cron: sync_all_tenants(customers, 30)
# Weekly cron: sync_all_tenants(customers, 366)
def sync_all_tenants(customers, window_days: int):
for customer in customers:
sync_tenant(customer, window_days)
def sync_tenant(customer, window_days: int):
page = 1
while True:
try:
data = client.analytics.get_analytics(
profile_id=customer.zernio_profile_id,
from_date=days_ago(window_days),
limit=50,
page=page,
)
except ApiError as err:
if err.status != 429:
raise
# 429s carry Retry-After (seconds); retry the same page.
# Adapt to how your HTTP client surfaces status and headers.
time.sleep(int(err.headers.get("Retry-After", 5)))
continue
upsert_posts(customer.id, data["posts"]) # key on post id + platform
if page >= data["pagination"]["pages"]:
break
page += 1Fetching a single post (GET /v1/analytics?postId=...) can return 202 while its analytics sync is still pending — treat that as "try again shortly", not a failure — and 424 when every platform failed to sync it.
The budget math works out fine. Say you have 1,000 customers with ~2,000 connected accounts (the 600 req/min tier, 10 req/s on analytics). An hourly hot pass at ~3 requests per tenant (one analytics page, one daily-metrics call, one follower-stats call) is ~3,000 requests — about 5 minutes of steady work at 10 req/s. There is no scale at which you need a second API key.
One more saving: follower stats refresh once per day upstream — syncing them hourly re-reads the same number 23 times. Put them on the daily pass.
Charts come from daily metrics
Don't build time-series charts by aggregating per-post rows yourself — GET /v1/analytics/daily-metrics returns day-by-day sums with a per-platform breakdown, one call per tenant. Its attribution parameter decides what a "day" means, and picking the right one is the difference between a chart that answers the tenant's question and one that confuses them:
| A "day" means | The question it answers | |
|---|---|---|
attribution=publish | 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 actually arrived | "How much engagement did my account get this week?" |
Concretely: a post published July 1 gets 40 likes that day and 10 more on July 20. Under publish, all 50 sit on July 1 — the July 20 bar never moves. Under received, July 1 shows 40 and July 20 shows 10 — the chart keeps moving even in weeks the tenant didn't post, which is why it's the right choice for an account-overview widget.
curl "https://zernio.com/api/v1/analytics/daily-metrics?profileId=PROFILE_ID&fromDate=2026-06-14&attribution=received" \
-H "Authorization: Bearer YOUR_API_KEY"Each entry in dailyData is one ready-made data point — map it straight onto your charting library:
{
"dailyData": [
{
"date": "2026-07-13",
"postCount": 3,
"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 a granularity of daily, weekly, or monthly — pick the granularity that matches the chart instead of aggregating daily points yourself.
Learn about new posts from webhooks, not polling
Don't poll to discover that a tenant published something. Subscribe to post.published and insert the row into your DB the moment it fires — the payload carries an accountId on each platform entry, which maps back to your tenant. The analytics for that post will populate on your next sync pass, once the platform makes them available.
If content published outside your app matters to your dashboards (it usually does for agencies), external posts are picked up by a background sync roughly every 90 minutes per account. For the one moment where that's too slow — a customer pastes a URL of a post they just published and expects to see it — call POST /v1/posts/sync-external with the accountId and url to fetch it on demand. It's debounced (~15s per account), so use it for that verification moment, not as a bulk refresh mechanism.
Show freshness instead of promising real-time
Some delays are Zernio's caching; most are the platforms themselves:
| Data | Freshness |
|---|---|
| Post analytics (likes, comments) | Cached ~60 minutes; stale requests trigger a background refresh |
| Post insights (reach, impressions) | Platform-dependent — e.g. ~24h delay on Instagram |
| Follower stats | Refreshed once per day |
| External posts | Background sync every ~90 minutes per account |
| YouTube daily views | 2–3 day delay from YouTube's Analytics API |
The API tells you how fresh each number is — store these fields and render them, instead of guessing:
overview.lastSyncandoverview.dataStalenesson list responses — feed your dashboard's "data as of …" label.syncStatusper platform entry (synced,pending,unavailable) — show a subtle spinner onpendinginstead of a zero.analytics.lastUpdatedper post — for post-detail views.
Checklist
- Dashboards read from your database; a background worker fills it
- Tiered sync: hot window (~30 days) hourly, long tail weekly, backfills chunked ≤ 366 days — no
fromDatewatermarks -
429→ waitRetry-Afterseconds;202on single-post fetches → retry shortly - New posts arrive via
post.publishedwebhooks, not discovery polling - Charts from
daily-metrics(pickattributiondeliberately); follower growth fromfollower-statson the daily pass - Surface
dataStaleness/syncStatus/lastUpdatedas "data as of …" in your tenant UI
Related
- Multi-tenant integration - the core profile-per-customer model
- Tenant inbox & DMs - the same architecture applied to messaging
- Rate limits - windows, headers, and the account-based ladder
- Post webhooks - payloads for the publishing lifecycle
Tenant Publishing
Publish and schedule on behalf of every customer - retry-safe creation, media done once, per-tenant queues instead of cron storms, and outcomes from webhooks.
Tenant Inbox & DMs
Build a unified social inbox for each of your customers - webhook-first message ingestion, routing events to the right tenant, replying, and backfills.