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

# Partner Sync

> Mirror a PunchPlay account and write back in bulk, without polling the library endpoints.

The library endpoints — history, ratings, collection, lists — are shaped for
presentation. Re-downloading them on a timer is expensive for you and for
PunchPlay, and it cannot tell you what changed.

Partner sync exists for clients that maintain their own copy of an account: a
durable change feed to read from, and bulk endpoints to write back through.

## When to use it

Use partner sync if you keep a local mirror of a user's library and need to stay
in step with it. If you only read a screenful at a time, the ordinary library
endpoints with `limit` and `cursor` are simpler and enough.

## Reading: snapshot, then follow the feed

The change feed is populated by database triggers, so web activity, imports,
provider webhooks, first-party apps, and your own writes all land in one ordered
feed per account. Resources are filtered to the read scopes on your token.

<Steps>
  <Step title="Open a cursor">
    Call `GET /api/platform/v1/me/sync/changes` with no cursor. Keep the
    returned `nextCursor`. A new client sees `resetRequired: true`.
  </Step>

  <Step title="Backfill each resource">
    For every resource named in `resources`, page through
    `GET /api/platform/v1/me/sync/snapshot?resource=...&after=...&limit=500`
    until it is exhausted.
  </Step>

  <Step title="Follow the feed">
    Call `/me/sync/changes?cursor=RETAINED_CURSOR`. Apply changes in the order
    returned, and persist each `nextCursor` only after that page has committed
    locally. If you persist the cursor first and then fail, you have silently
    skipped changes.
  </Step>

  <Step title="Handle tombstones and resets">
    `operation: "delete"` with `data: null` is a tombstone — remove the row. If
    `resetRequired` ever becomes `true`, discard the mirror and repeat the
    snapshot step.
  </Step>
</Steps>

Cursors stay valid for 90 days. A cursor older than that returns
`resetRequired: true` rather than an error.

Keep the mirror and cursor per PunchPlay account, app, and read-scope set. If a
user later grants another read scope, start a new cursor and snapshot the newly
visible resource; an older cursor does not backfill history that the earlier
token could not read.

### Delivery is at-least-once

Apply every change idempotently, keyed on `resource` and `resourceId`. Receiving
the same change twice is normal and must be harmless.

<Note>
  This is a deliberate trade. A change's id is assigned when it is written, but
  it only becomes visible when its transaction commits — so two changes can
  commit out of order. A cursor therefore also records which transactions were
  still open when it was issued, and the next read re-checks them. The
  alternative is worse: a change committing late would fall behind the cursor
  and never be delivered at all, leaving your mirror silently wrong.
</Note>

## Writing: bulk endpoints

| Data | Endpoint | Scope |
| - | - | - |
| History | `POST /api/platform/v1/sync/history` | `history:write` |
| Ratings, favourites, statuses | `POST /api/platform/v1/sync/ratings` | `ratings:write` |
| Watchlist additions and removals | `POST /api/platform/v1/sync/watchlist` | `lists:write` |

Each request takes up to 100 items within a 512 KiB body. Size batches against
the byte limit as well as the item count — a batch of long-titled entries can
reach 512 KiB before it reaches 100 items.

Responses carry one outcome per submitted index. An `invalid` item does not fail
its valid neighbours, so surface the per-item error text rather than treating
the whole batch as failed.

History items also need a stable `client_item_id`, derived from your source
watch event, for example `nuvio-profile-4:history:1735927200:tmdb:550`. It
prevents duplicates across separately keyed batches.

## Identifying items

Every bulk item carries **at least one** of `tmdb_id`, `imdb_id`, `tvdb_id` or
`mal_id`. If IMDb is your canonical identifier, send it directly — there is no
need to translate to TMDB first. Sending several ids at once is allowed:
`tmdb_id` is the write identity, and the others are cross-checked against
evidence already held, at no extra upstream cost.

`title` and `year` are optional on history items, and `title` on watchlist
items. Anything omitted is hydrated from TMDB, and a write is never lost merely
because its metadata could not be fetched.

<Warning>
  `client_item_id` is **required** on any item identified only by `imdb_id`,
  `tvdb_id` or `mal_id`. Such an item may be deferred, and a deferred item is
  banked under that key so a corrected resend updates the same record instead of
  creating a second one. Rating and watchlist items carrying `tmdb_id` may still
  omit it — history items must always send it, regardless of which ids
  accompany them.
</Warning>

## Deferred items

An item whose identifiers cannot be resolved yet comes back as `deferred`. It
has been accepted and durably stored, and PunchPlay retries it on its own
schedule. `BulkMutationResponse` counts these in a `deferred` total.

| `status` | `reason` | What to do |
| - | - | - |
| `deferred` | `unmatched` | Nothing upstream knows this id yet. **Do not resend** |
| `deferred` | `resolver_unavailable` | An upstream source was unreachable. **Do not resend** |
| `invalid` | — | Malformed, no identifier, or contradictory ids. Fix and resend |

Resending a deferred item only re-banks the same record. Treat it as accepted
and move on; if a later sync of the same item succeeds outright, the banked copy
is cleared automatically, so nothing is applied twice.

Applied items carry `resolved_tmdb_id`. Cache it and send it back as `tmdb_id`
on later syncs — that removes resolution from the request path entirely and is
the single biggest saving available to an IMDb-first client.

<Note>
  This no-loss guarantee covers the bulk endpoints. Path-identified writes have
  no banking and return a retryable `503` when resolution is unavailable.
</Note>

The public partner contract does not currently expose a per-item deferred-status
or requeue endpoint. Keep the source `client_item_id` and the accepted response
locally, and use the change feed or a later mirror read to observe when the
result has materialized. A dashboard for deferred backlog state, retry timing,
and terminal outcomes remains a product decision for a future contract.

## Partner ids in paths

The same identifiers work anywhere a path takes a title `{id}` — no translation
to TMDB first:

```
GET   /api/platform/v1/title/movie/tt0137523
POST  /api/platform/v1/title/show/tvdb:81189/season/1/watch
PATCH /api/platform/v1/title/show/mal:16498/interact
```

| Form | Example | Cost |
| - | - | - |
| TMDB id | `550` | No lookup |
| IMDb id | `tt0137523` | Resolved server-side |
| TVDB id | `tvdb:81189` | Resolved server-side |
| MyAnimeList id | `mal:16498` | Resolved from local anime links |

Three failure modes, and they want different handling:

| Status | Meaning | What to do |
| - | - | - |
| `400 invalid_title_id` | Malformed, or ids that contradict each other | Fix and resend |
| `404 title_not_found` | Well formed, but no title matches | Do not retry unchanged |
| `503 title_id_unavailable` | Could not resolve right now | **Retry**, honouring `Retry-After` |

<Warning>
  Path routes do **not** bank. A single path write has no `client_item_id` to
  key a stored record on, so when resolution fails nothing is kept and the retry
  is yours to own — which is why an outage here is a `503` rather than the
  `deferred` bulk sync returns. For anything batch-shaped, or anything you
  cannot afford to lose, use bulk sync.
</Warning>

## Idempotency

Every bulk write requires an `Idempotency-Key` header. Keys are retained for 24
hours, and replaying a key with the same body returns the original result rather
than applying it twice.

Three responses need different handling, and the difference matters:

| Response | Meaning | What to do |
| - | - | - |
| `409 request_in_progress` | The original request is still running | Wait, retry the **same** key |
| `409 idempotency_conflict` | The key was used with a different body | Fix the body, or use a new key |
| `409 idempotency_indeterminate` | The original died before recording its result | Reconcile, then use a **new** key |

<Warning>
  `idempotency_indeterminate` means the write **may or may not** have applied.
  PunchPlay will not repeat it blindly, because doing so could double-log a
  user's watch history. Read `/me/sync/changes` to establish what actually
  landed, then retry the remainder under a new key. Reusing the old key only
  returns the same error.
</Warning>

## Rate limits

Bulk endpoints allow 30 requests per minute per operation, app, and user, and
the change and snapshot reads allow 120 per minute per app and user. The
per-token and per-app buckets in
[Errors & Rate Limits](/errors-rate-limits) apply on top of those.

Throttle on `X-RateLimit-Remaining` and honour `Retry-After` on `429`.

For support, retain the request ID, UTC timestamp, endpoint, status, and error
body. Ask questions in the [PunchPlay Discord](https://discord.gg/pPwct597Ut)
without sharing credentials.


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