> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs.usegrade.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sync campaign media

> Keep a local campaign media copy current with bounded pages, changed records, and removals.

## GET /sdk/v1/campaigns/:campaignId/media/sync

Use this endpoint when you keep a local copy of campaign media. The first run
downloads the current records in pages. Later runs return records affected by
changes and IDs to remove. Each `data` item is a **complete replacement record**
with the same fields as the [campaign media list](https://api-docs.usegrade.com/campaign-media.md).

Requests require `X-Application-Id`, `X-Api-Key`, and the `campaigns:read` scope.
The existing `/media` endpoint continues to return its complete list.

## One cursor for pages and future polls

1. Start without a cursor and with an empty local collection for this campaign.
2. Upsert every `data` item by `campaignCreatorMediaId`; delete every `removedIds` entry.
3. Save `nextCursor` **in the same local transaction** as those changes.
4. If `hasMore` is `true`, request the next page using `cursor=nextCursor`.
5. Otherwise, keep the cursor and use it the next time you poll.

An empty response is normal. Save its cursor too. Continue while `hasMore` is
true even if `data` is empty: a page can contain only removals.

> [!NOTE]
>
> Cursors have no time-based expiry. You can pause polling, restart your service,
> rotate an API key, or change the page size and continue with the saved cursor.
> There is no routine full reload after a retention window.

### Query parameters

| Parameter | Behavior |
| - | - |
| `cursor` | Opaque value from the previous response. Omit only to start a new copy. |
| `limit` | Maximum IDs processed per page: 1–100, default 100. Removals count toward this limit. |
| `platform` | Optional platform for a new sync, for example `tiktok`. Case-insensitive. |
| `query` | Optional creator display-name search for a new sync, at most 256 characters. |

Filters are saved in the cursor, so you can omit them on subsequent calls. Use a
separate local collection and a new cursor when starting a different filtered
view. A record that leaves a filtered view appears in `removedIds`.

Pages also target 1 MiB of record data. A single larger record is returned intact.
The number of returned records can therefore be lower than `limit`.

### Example request

```bash theme={null}
curl --get "$BASE_URL/sdk/v1/campaigns/$CAMPAIGN_ID/media/sync" \
  -H "X-Application-Id: $APP_ID" \
  -H "X-Api-Key: $API_KEY" \
  --data-urlencode "cursor=$SAVED_CURSOR" \
  --data-urlencode "limit=100"
```

Omit the `cursor` argument on the first request.

### Response

```json theme={null}
{
  "success": true,
  "data": [
    {
      "campaignCreatorMediaId": "CCMEDIA_123",
      "creatorProfileId": "CP_123",
      "creatorName": "Jordan",
      "creatorEmail": "jordan@example.com",
      "platform": "TIKTOK",
      "thumbnail": null,
      "mediaUrl": "https://www.tiktok.com/@jordan/video/7350000000000000000",
      "authorPlatformId": null,
      "authorUsername": "jordan",
      "authorDisplayName": "Jordan",
      "title": null,
      "captionText": "Launch day",
      "hashtags": [],
      "audioPlatformId": null,
      "audioTitle": null,
      "audioArtist": null,
      "audioAlbum": null,
      "audioIsOriginal": null,
      "metadataCollectedAt": null,
      "isSlideshow": false,
      "mediaFile": false,
      "isAccount": false,
      "accountIssue": null,
      "views": 12500,
      "likes": 420,
      "comments": 38,
      "shares": 21,
      "bookmarks": 9,
      "score": null,
      "trackingTier": null,
      "tierChangedAt": null
    }
  ],
  "removedIds": ["CCMEDIA_OLD"],
  "hasMore": false,
  "nextCursor": "opaque-cursor"
}
```

`removedIds` covers deletion, tracking retirement, movement to another campaign,
creator removal, and records that no longer match your filters. Deleting a local
ID you do not have is harmless. Restored records return as complete upserts.

The sync also captures metric changes, creator names and email addresses, agency
display names, metadata, and account totals affected by child posts. It sends the
latest available state, so multiple changes to one record can become one upsert.
It is not an audit history of every intermediate edit.

## Changes during paging and retries

Pages read current records. Changes during a run can appear in a later page or
on the next poll. The cursor preserves changes committed after the run began,
including transactions that were already in progress. A run finishes after its
bounded set of IDs; ongoing writes do not require a frozen database transaction.

Delivery is at least once. A record may repeat during retries, concurrent edits,
or catch-up. Apply complete replacements and removals idempotently. Serialize
requests and page application for each local copy so an older response cannot
overwrite a newer one. Independent integrations may maintain independent cursors.

If a request or local transaction fails, retain your data and retry the last
saved cursor. Never clear your local collection on an API error. Only start
without a cursor when intentionally creating a new local copy.

Campaign-wide `metrics` and `asOfTimestamp` are not included. For totals from your
local copy, avoid counting both an account's aggregate and its child posts.

## Client example

The repository demo exports `mediaSync` from `sdk-demo/backend/src/sdkClient.js`.
It follows pages sequentially and stops on an API or storage error. Supply your
own transaction implementation:

```javascript theme={null}
const { mediaSync } = require('./src/sdkClient');

// Load the cursor for this agency, campaign, and optional filter scope.
// Run only one worker at a time for this local copy.
const cursor = await store.readCursor(syncScope);

await mediaSync.run(campaignId, {
  cursor,
  limit: 100,
  applyPage: async ({ data, removedIds, nextCursor }) => {
    await store.transaction(async (tx) => {
      for (const media of data) {
        await tx.replaceMedia(syncScope, media.campaignCreatorMediaId, media);
      }
      for (const id of removedIds) {
        await tx.deleteMedia(syncScope, id);
      }
      await tx.saveCursor(syncScope, nextCursor);
    });
  }
});
```

The `store` methods above represent your database adapter. The API client and
page loop are provided; your adapter must make the page and cursor atomic.

### Errors

* `400`: malformed parameters, a damaged cursor, a cursor for another campaign
  or agency, or filters different from the cursor's saved filters.
* `401` or `403`: invalid credentials or insufficient scope. Fix authorization
  and retain your saved cursor.
* `404`: the campaign is unavailable to the authenticated agency.
* `429` or `5xx`: follow `Retry-After` when present; otherwise retry with backoff
  using the same cursor.

Cursor age is not an error condition. Cursors are continuation state, not
credentials; every request still checks your current authorization.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.