---
title: "Analytics changed since a cursor"
method: GET
path: "/v1/analytics/delta"
tags: ["Analytics"]
---

# Analytics changed since a cursor

`GET /v1/analytics/delta`

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.

## Query parameters

- `cursor` string
- `limit` integer
- `platform` string
- `profileId` string

## Response `200`

One page of changed analytics

- AnalyticsDeltaResponse
  - `data` AnalyticsDeltaEntry[], required — Changed snapshots, oldest first, in the order the feed received them. Empty on the bootstrap call (no `cursor` supplied) and whenever nothing has changed since your cursor.
    - `postId` string, required — External post ID. The same identifier as `posts[]._id` in GET /v1/analytics.
    - `accountId` string, required — Social account this post was published through
    - `profileId` string, required — Profile the account belongs to
    - `platform` string, required
    - `platformPostId` string, required — Platform-side post ID (for example the YouTube video ID)
    - `publishedAt` string, date-time, required — When the post was published, ISO-8601 UTC
    - `syncedAt` string, date-time, required — When the sync cycle that produced this snapshot STARTED, ISO-8601 UTC. This is NOT the order entries arrive in and it is not a resume point: a slow cycle writes its rows after a faster cycle that started later, so `syncedAt` can go backwards between consecutive entries. Use `nextCursor` to resume.
    - `isDeleted` boolean, required — True when the post was detected as deleted on the platform at this sync
    - `metrics` object, required — Metrics a platform does not report are 0, not absent.
      - `impressions` integer, required
      - `reach` integer, required
      - `likes` integer, required
      - `comments` integer, required
      - `shares` integer, required
      - `saves` integer, required
      - `sends` integer, required
      - `clicks` integer, required
      - `views` integer, required
      - `follows` integer, required — Follows attributed to this post (Instagram)
      - `igReelsAvgWatchTime` integer, required — Instagram Reels average watch time, in milliseconds
      - `igReelsVideoViewTotalTime` integer, required — Instagram Reels total watch time, in milliseconds
      - `reposts` integer, required
      - `reelsSkipRate` number, required — Instagram Reels skip rate, 0 to 1
  - `nextCursor` string, required — Cursor to send on the next call. ALWAYS present, including on an empty page, so you always have something to advance with, and it never moves backwards. Opaque: pass it back verbatim, and do not parse, construct or compare cursors.
  - `hasMore` boolean, required — True when more changes are already waiting past `nextCursor`, so call again immediately. False means you are caught up: keep `nextCursor` and poll again later. This feed never ends, so `hasMore: false` does NOT mean `nextCursor` is null.

## Other responses

- `400` — Invalid request
- `401` — Unauthorized
- `402` — Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
- `403` — The caller cannot read the requested profile, the API key is scoped to other profiles, or the profile sits outside the plan's profile limit (`code` `PROFILE_OVER_LIMIT`).
- `404` — Resource not found
- `500` — Internal server error
- `503` — The delta feed is temporarily unavailable, for example in the minutes between an application release and the analytics store catching up with it. Nothing is wrong with the request: retry it unchanged, honouring `Retry-After`. The feed answers this rather than an empty page, because an empty page is indistinguishable from "nothing changed" and would advance you past a window you never received.

## Changes

- **2026-09-02** `3f632ccead88` — 1 info
  - endpoint added

[Change history](https://skmtc.dev/zernio/apis/zernio-api/changes/v1/analytics/delta/get.md)

---

[API](https://skmtc.dev/zernio/apis/zernio-api.md) · [All operations](https://skmtc.dev/zernio/apis/zernio-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/zernio/zernio-api/revisions/08611567febf/schema)
