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

# Syncing

> Keep a copy of registers current with the changes feed: subscribing, the initial load, the polling loop, and recovery.

The [changes feed](https://docs.pinakelai.com/api-reference/changes/list.md) lists every record that changed, in the order changes were published. With a stored cursor, you can keep your own copy of one or more registers current by polling it.

## How the feed works

```json theme={null}
{
  "object": "list",
  "data": [
    {
      "object": "change",
      "id": "chg_8d1f4a0c27b94e6f",
      "type": "trademark.changed",
      "created_at": "2026-12-01T03:12:44.802113Z",
      "trademark": {
        "object": "trademark_summary",
        "id": "tm_AD_097c8a54-c1a8-5a8e-8b77-fb1b9e51ccba",
        "jurisdiction": "AD",
        "office": "AD",
        "origin_office": null,
        "identifiers": { "application_number": "48003", "registration_number": "48003" },
        "mark": { "text": "SkingénIA by GALENIC", "translation": null, "transliteration": null, "feature": "word", "kind": "individual" },
        "status": { "value": "registered", "category": "live", "date": "2020-06-23" },
        "dates": { "application": "2020-06-23", "registration": "2020-06-23", "publication": "2025-07-30", "expiry": "2030-06-23", "renewal": null, "termination": null, "opposition_period_end": null },
        "primary_owner": { "name": "GALENIC COSMETICS LABORATORY", "country": null },
        "owner_count": 1,
        "nice_class_count": 1,
        "primary_nice_class": 9,
        "media": { "has_image": false },
        "updated_at": "2026-12-01T02:58:17.390551Z",
        "links": { "self": "https://api.pinakelai.com/v1/trademarks/tm_AD_097c8a54-c1a8-5a8e-8b77-fb1b9e51ccba" }
      }
    }
  ],
  "has_more": false,
  "next_cursor": "eJxNjEEKwjAQRe9y1l3EVnDhOdQLFLp2IXRbxG0ZphgxIAwTF_n7iQqFt3n_Mf8FTRBCkMU.9cQ2aNfDdA",
  "unavailable_jurisdictions": [],
  "feed_start": "2026-11-30T02:41:09.000000Z",
  "complete_through": "2026-12-01T03:30:00.115406Z"
}
```

* Each item is a `change` with `type: "trademark.changed"` and the record as it is **when you read the page**. If a record changed twice since your last poll, you get it once, in its latest state.
* `created_at` is when the change was published to the feed. Items are ordered by it, and several items can share one timestamp.
* `trademark` is a [summary](https://docs.pinakelai.com/concepts/trademark-record.md#the-summary). Add `include=full` (with `limit` up to 25) to get full records, or fetch the ones you need with [batch retrieve](https://docs.pinakelai.com/api-reference/trademarks/batch.md).
* `next_cursor` is **never null**. It is your position in the feed; store it after every page.
* `has_more: false` means you have everything published before `complete_through`. Poll again later with the same cursor.
* Ignore item types you do not know. `trademark.removed` is reserved for a later version.

## Subscribe

Choose your scope: the registers you want, as `jurisdiction` (up to 20 codes), or no `jurisdiction` for every register. Then call the feed once without `since` or `cursor`. This subscribes from now: you get an empty page and a cursor.

```bash theme={null}
curl -G "https://api.pinakelai.com/v1/changes" \
  -H "Authorization: Bearer $PINAKEL_API_KEY" \
  --data-urlencode "jurisdiction=AD,AL"
```

Send the same `jurisdiction` on every later call. A cursor is bound to its scope; a different scope gets 400 [`cursor-mismatch`](https://docs.pinakelai.com/errors/cursor-mismatch). To change scope, start a new subscription (and load any new register as below).

## Initial load

The feed only carries changes. To start a copy, combine it with search, in this order:

1. **Take a feed cursor first**, by subscribing as above, and store it.
2. **Copy the current state** of your scope with a search (for example `jurisdiction=AD,AL`), fetching full records in batches.
3. **Follow the stored cursor.** Changes made while step 2 ran are replayed; because you upsert by `id`, replaying them is harmless.

Taking the cursor before the copy is what guarantees that nothing is lost between the two.

> [!NOTE]
>
> Feed history starts at `feed_start`, when the feed was launched, not at the beginning of each register. Whole-register exports are not part of v1; for very large registers, talk to us before copying them through the API.

## The polling loop

```python Python theme={null}
import json
import os
from pathlib import Path

import requests

API = "https://api.pinakelai.com/v1"
SCOPE = {"jurisdiction": "AD,AL"}
STATE = Path("pinakel_feed_state.json")

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['PINAKEL_API_KEY']}"


def load_cursor() -> str | None:
    return json.loads(STATE.read_text())["cursor"] if STATE.exists() else None


def save_cursor(cursor: str) -> None:
    tmp = STATE.with_suffix(".tmp")
    tmp.write_text(json.dumps({"cursor": cursor}))
    tmp.replace(STATE)  # atomic: a crash never leaves half a cursor


def fetch_full(ids: list[str]) -> list[dict]:
    """Full records for up to 100 ids."""
    resp = session.post(f"{API}/trademarks/batch", json={"ids": ids}, timeout=60)
    resp.raise_for_status()
    return resp.json()["data"]


def initial_load(upsert) -> None:
    resp = session.get(f"{API}/changes", params=SCOPE, timeout=30)   # 1. cursor first
    resp.raise_for_status()
    save_cursor(resp.json()["next_cursor"])
    params, ids = dict(SCOPE, limit=100), []
    while True:                                                       # 2. copy the scope
        page = session.get(f"{API}/trademarks", params=params, timeout=60)
        page.raise_for_status()
        body = page.json()
        ids += [tm["id"] for tm in body["data"]]
        while len(ids) >= 100 or (ids and not body["has_more"]):
            for record in fetch_full(ids[:100]):
                upsert(record)
            ids = ids[100:]
        if not body["has_more"]:
            return                                                    # 3. now run sync_once()
        params["cursor"] = body["next_cursor"]


def sync_once(upsert) -> None:
    """Drain the feed up to complete_through. Run it every 15 minutes or less often."""
    cursor = load_cursor()
    while True:
        resp = session.get(f"{API}/changes", params=dict(SCOPE, limit=100, cursor=cursor),
                           timeout=30)
        if resp.status_code == 503:
            return  # every register in scope is reloading, or the service is busy: next run
        resp.raise_for_status()
        page = resp.json()
        changed = [c["trademark"]["id"] for c in page["data"] if c["type"] == "trademark.changed"]
        for i in range(0, len(changed), 100):
            for record in fetch_full(changed[i:i + 100]):
                upsert(record)
        save_cursor(page["next_cursor"])  # after every page
        if not page["has_more"]:
            return
```

```javascript JavaScript theme={null}
import { readFile, writeFile, rename } from "node:fs/promises";

const API = "https://api.pinakelai.com/v1";
const SCOPE = { jurisdiction: "AD,AL" };
const STATE = "pinakel_feed_state.json";
const headers = { Authorization: `Bearer ${process.env.PINAKEL_API_KEY}` };

async function loadCursor() {
  try { return JSON.parse(await readFile(STATE, "utf8")).cursor; } catch { return null; }
}

async function saveCursor(cursor) {
  await writeFile(`${STATE}.tmp`, JSON.stringify({ cursor }));
  await rename(`${STATE}.tmp`, STATE); // atomic
}

async function fetchFull(ids) {
  const res = await fetch(`${API}/trademarks/batch`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({ ids }),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return (await res.json()).data;
}

// Drain the feed up to complete_through. Run it every 15 minutes or less often.
export async function syncOnce(upsert) {
  let cursor = await loadCursor();
  while (true) {
    const query = new URLSearchParams({ ...SCOPE, limit: "100" });
    if (cursor) query.set("cursor", cursor);
    const res = await fetch(`${API}/changes?${query}`, { headers });
    if (res.status === 503) return; // reloading or busy: try on the next run
    if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
    const page = await res.json();
    const ids = page.data.filter((c) => c.type === "trademark.changed").map((c) => c.trademark.id);
    for (let i = 0; i < ids.length; i += 100) {
      for (const record of await fetchFull(ids.slice(i, i + 100))) await upsert(record);
    }
    cursor = page.next_cursor;
    await saveCursor(cursor); // after every page
    if (!page.has_more) return;
  }
}
```

Rules the loop follows:

* **Store the cursor after every page**, after the page's records are saved. If you can, store it in the same transaction as the records. A crash then replays at most one page.
* **Upsert by `trademark.id`.** Never append: the same record arrives again whenever it changes, and replays are normal.
* **Poll at most every 15 minutes.** Changes are published in batches after each register update, so polling more often rarely finds anything new. A poll that returns no changes is not billed, but it still counts toward your per-minute rate limit.
* **Keep the scope fixed.** Send the same `jurisdiction` on every call, and never send `since` together with `cursor`.

## Starting from a time

Instead of subscribing from now, you can start from a past time with `since`, an RFC 3339 timestamp with `Z` or an offset:

```bash theme={null}
curl -G "https://api.pinakelai.com/v1/changes" \
  -H "Authorization: Bearer $PINAKEL_API_KEY" \
  --data-urlencode "jurisdiction=AD,AL" \
  --data-urlencode "since=2026-12-01T00:00:00Z"
```

`since` is inclusive, because several changes can share one timestamp. It cannot be earlier than `feed_start`; an earlier value gets 400, and so does any `since` before the feed's first publication (the response says so). Use `since` on the first call only, then follow the cursor.

## Recovering a lost cursor

If your stored cursor is lost, or rejected with 400 [`invalid-cursor`](https://docs.pinakelai.com/errors/invalid-cursor), restart with `since` set to the `created_at` of the last item you processed. Items with that exact timestamp are delivered again; your upserts absorb them.

## Reloads, bursts and removals

* A register that is reloading is skipped and listed in `unavailable_jurisdictions`; its changes follow when the reload ends. If every register in your scope is reloading, the call gets 503 [`jurisdiction-unavailable`](https://docs.pinakelai.com/errors/jurisdiction-unavailable) with `Retry-After` of at least 900 seconds. Your cursor stays valid.
* Reloads and processing changes republish many records at once. Compare the fields you care about before you raise alerts. See [How records change](https://docs.pinakelai.com/data/how-records-change.md#bursts).
* Records removed by an office are not announced in v1. Re-check the records you depend on with batch retrieve from time to time and handle `not_found`.

## Feed or polling by id?

The feed's cost grows with how much your registers change, not with how many records you care about. If you track a few thousand marks spread over busy registers, polling those ids with [batch retrieve](https://docs.pinakelai.com/api-reference/trademarks/batch.md) can be cheaper: 5,000 marks checked daily cost 50 calls a day. [Docketing sync](https://docs.pinakelai.com/use-cases/docketing-sync.md) compares both approaches.


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