Analytics changed since a cursor
Cursor feed of the analytics snapshots that CHANGED, across every account you can
read, in one paginated stream. Built for integrations that would otherwise call
GET /v1/analytics once per connected account. Each page carries changes from
many accounts at once, so your call count scales with how much actually changed
rather than with how many accounts you have. Measured against a fleet of roughly
1,600 connected accounts: about 1,599 per-account analytics calls an hour became
about 205 delta calls an hour, a 7.8x reduction.
Bootstrap once, then stay in sync. Load your baseline from
GET /v1/analytics, which is the historical endpoint. This one is a rolling
7-day change log and cannot replay history. Then call this endpoint with NO
cursor: it answers with an empty data array plus the feed's current position
in nextCursor. Send that nextCursor back on the next call and you receive
everything written since. nextCursor is present on every response, empty pages
included, so you always have something to advance with.
Ordering. Entries come back oldest first, in the order the feed received
them. That order is NOT syncedAt: syncedAt is stamped when an account's sync
cycle started, and a slow cycle writes its rows after a faster cycle that started
later, so syncedAt can go backwards between consecutive entries. Do not sort,
filter or resume on it. The cursor is the only stable position, and it is opaque:
pass it back verbatim, and do not parse, construct or compare cursors.
hasMore: false does not mean the feed ended. This stream has no end and
nextCursor is never null. hasMore: true means more changes are already
waiting, so call again straight away. hasMore: false means you are caught up:
keep the cursor and poll again on your normal interval.
The newest changes settle before they are served. The feed deliberately holds
back its last few seconds of writes, so that a row can never become visible
behind a cursor you have already advanced past. A read issued the instant an
analytics.synced webhook lands will therefore often return an empty page for
that account. Do not read an empty page as "nothing changed": poll again with the
SAME cursor you just used rather than advancing.
Repeats inside one instant. A sync cycle occasionally records the same post twice at the same feed position. When that happens the feed delivers one of those rows, not both. Measured over a day of production traffic, about 1.3% of rows fall in such a group and 99.4% of those groups are identical rows, so this is far more often deduplication than loss. Metrics are absolute values rather than increments, so a later entry for the same post supersedes an earlier one.
Retention is 7 days. Changes older than that leave the feed. A cursor older
than 6 days is rejected with a 400 (a day of margin, because expiry is lazy).
Recover by re-bootstrapping from GET /v1/analytics and taking a fresh cursor
from a call to this endpoint with no cursor. A consumer that polls at least
daily never reaches this.
Pairs with the analytics.synced webhook, so changes can be read on notification
instead of on a timer. That event carries no cursor of its own: keep using the
nextCursor this endpoint gave you.
Requires the same analytics access as GET /v1/analytics, and shares the
stricter per-second rate-limit window applied to analytics endpoints.
API key authentication - use your Zernio API key as a Bearer token
In: header
Query Parameters
Opaque cursor from a previous response's nextCursor. Omit it to start from
now: the response is then an empty page carrying the feed's current position.
Rejected with a 400 when malformed, or when older than the retention window.
Page size. Out-of-range values are a 400, never a silent clamp.
501 <= value <= 100Filter to a single platform (for example "youtube"). Omit for every platform.
Filter by profile ID (default "all"). Must be a valid profile ID or "all".
"all"Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
import Zernio from '@zernio/node';const zernio = new Zernio({ apiKey: process.env.ZERNIO_API_KEY });const { data } = await zernio.analytics.getAnalyticsDelta();console.log(data);{
"data": [],
"nextCursor": "v1.WyIyMDI2LTA5LTAxIDE3OjA4OjUxIiwiIl0",
"hasMore": false
}{
"error": "string",
"type": "invalid_request_error",
"code": "string",
"param": "string",
"platform": "string",
"platformError": {},
"details": {}
}{
"error": "Unauthorized"
}{
"error": "Analytics add-on required",
"code": "analytics_addon_required"
}{
"error": "string",
"type": "invalid_request_error",
"code": "string",
"param": "string",
"platform": "string",
"platformError": {},
"details": {}
}{
"error": "Not found"
}{
"error": "string",
"type": "invalid_request_error",
"code": "string",
"param": "string",
"platform": "string",
"platformError": {},
"details": {}
}{
"error": "string",
"type": "invalid_request_error",
"code": "string",
"param": "string",
"platform": "string",
"platformError": {},
"details": {}
}Get post analytics GET
Returns analytics for posts. With postId, returns a single post. Without it, returns a paginated list with overview stats. Accepts both Zernio Post IDs and External Post IDs (auto-resolved). fromDate defaults to 90 days ago if omitted, max range 366 days. Single post lookups may return 202 (sync pending) or 424 (all platforms failed). For follower stats, use /v1/accounts/follower-stats. LinkedIn personal accounts: Analytics are only available for posts published through Zernio. LinkedIn's API only returns metrics for posts authored by the authenticated user. Organization/company page analytics work for all posts.
Sync an external post POST
Fetch an account's latest external posts (published directly on the platform, not through Zernio) on demand, so a just-published post is retrievable within seconds instead of waiting for the background sync (which refreshes each account at most every ~90 minutes). Primary use case: verifying a submitted post. When a user publishes on the platform and immediately pastes the post URL into your app, call this with `accountId` plus `url` (or `postId`) to confirm the post exists and return its metadata. Behavior: - We check our stored copy first and return immediately if the post is already known (no platform call). - Otherwise we fetch the account's latest posts live from the platform, then match and return the submitted post. - Requests are debounced per account (~15s): if the account was just synced, the live fetch is skipped. `accountId` is required — a post URL or id alone cannot be resolved to an account, and the account must be connected to Zernio (we use its token to read the platform). Supported for every platform with a listing API (Instagram, Facebook, TikTok, YouTube, X, Threads, Pinterest, Reddit, Bluesky, Google Business, and LinkedIn organization accounts). LinkedIn personal profiles: LinkedIn has no listing API for personal profiles, so a `url` is REQUIRED and imports that single post. Pass any LinkedIn post URL (`linkedin.com/posts/…`, `linkedin.com/feed/update/urn:li:activity:…`) or a `urn:li:share:…` / `urn:li:ugcPost:…` URN. Works for posts published outside Zernio and before the account was connected, any age; the post must be authored by the connected member. Imported posts return full analytics (impressions, reach, reactions, comments, reshares, saves) and keep refreshing on the background analytics cycle, but carry no content/media (LinkedIn does not expose them for personal profiles). `url` accepts any format the platform uses (e.g. `instagram.com/p/…`, `instagram.com/reel/…`, `youtu.be/…`, `youtube.com/shorts/…`, `tiktok.com/@user/video/…`, and `vm.tiktok.com` short links). Pass `postId` (the platform media/video id) as an alternative locator. Note: post-level analytics (reach, impressions) still carry the platform's own delay (e.g. ~24h on Instagram). This endpoint confirms the post exists and returns its metadata plus basic engagement (likes, comments), not delayed insights.