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

# Syncing with Events

> Keep a local copy of a Gumnut library up to date by reading the events feed

The events feed is a log of changes in a library. Read it to keep a local copy
of a library in sync, such as a mobile app's cache or a search index, without
listing every asset on each sync.

Each event says that one entity changed. It doesn't carry the entity's new
state: read that from the entity's own endpoint.

## Prerequisites

* An [API key](/guides/authentication/api-keys) with read access to the library
* The [Python SDK](/guides/sdks/python) `gumnut-sdk` 0.168.0 or later, for the
  examples on this page

## How the feed behaves

`GET /api/events` returns events in pages, each with an opaque `cursor`:

```json theme={null}
{
  "data": [
    {
      "cursor": "evc_fSxNDkxnUxDYkftTcFVLXwz9hRXrwuHgD",
      "entity_type": "asset",
      "entity_id": "asset_2itHvEMz7NqELs2XskatRb",
      "event_type": "asset_created",
      "created_at": "2026-05-15T19:20:09.448804Z",
      "payload": null
    }
  ],
  "has_more": true,
  "next_cursor": "evc_PCpKqQ2Q9Hd4GgnYBcKXHmuddYd6f4S5E",
  "as_of": "asof_pApFFtPKr7e"
}
```

Three properties shape how you sync:

* **Nothing is skipped.** Reading forward from a stored cursor returns every
  event committed after it, each once. A cursor is the only checkpoint you
  need.
* **Events are not in commit order.** Two changes to one entity can arrive in
  either order, so apply an event by reading the entity's current state, not by
  replaying the change.
* **Events can trail their commit.** An event appears once every write that
  started before it has finished, so a change can take a moment to show up.

`created_at` is when the change's transaction started. Use it for display only:
it is neither the feed order nor a checkpoint, and a later event can carry an
earlier time.

## Sync a library

1. Load your stored cursor, or start with none for a first sync.
2. Request a page with `after_cursor` set to that cursor.
3. Apply each event, as described below.
4. After applying the page, store its `next_cursor`.
5. Repeat until `has_more` is `false`. You are caught up; the next sync starts
   again at step 1.

This example syncs assets, albums, and album memberships. It requests only those
entity types, so its cursor never passes an event it doesn't apply.

```python theme={null}
from gumnut import Gumnut

client = Gumnut()

# The entity types this client syncs, each re-read up to 200 records by ID in
# one call. Requesting only these keeps the cursor from passing other events.
READERS = {
    "asset": client.assets.list,
    "album": client.albums.list,
    "album_asset": client.album_assets.list,
}


def apply_page(events, store, library_id):
    """Re-read what the events name and make the local copy match."""
    changed = {entity_type: set() for entity_type in READERS}
    for event in events:
        changed[event.entity_type].add(event.entity_id)
    for entity_type, ids in changed.items():
        if not ids:
            continue
        found = READERS[entity_type](
            library_id=library_id, ids=list(ids), limit=200
        )
        for record in found.data:
            store.put(entity_type, record)
        for missing_id in ids - {record.id for record in found.data}:
            store.delete(entity_type, missing_id)  # deleted or trashed
            if entity_type in ("asset", "album"):
                # The server removes or hides its memberships without events.
                store.delete_memberships(**{f"{entity_type}_id": missing_id})
    for event in events:
        if event.event_type == "asset_restored":
            # Its memberships return without events of their own.
            links = client.album_assets.list(
                library_id=library_id, asset_id=event.entity_id, limit=200
            )
            for link in links.data:
                store.put("album_asset", link)


def sync(store, library_id):
    cursor = store.load_cursor()  # None on the first sync
    while True:
        page = client.events.get(
            library_id=library_id,
            entity_types=list(READERS),
            after_cursor=cursor,
            limit=200,
        )
        apply_page(page.data, store, library_id)
        if page.next_cursor:
            cursor = page.next_cursor
            store.save_cursor(cursor)  # only after applying the page
        if not page.has_more:
            return
```

`store` stands for your local storage, keyed by entity type and ID. Save the
cursor in the same place as the data, ideally in the same transaction, so the
two can't drift apart.

Make applying an event idempotent. If your client stops after applying a page
but before storing its cursor, the next sync replays that page.

While `has_more` is `true`, `next_cursor` also bounds the read to events that
were ready when it began, so a busy library can't keep a sync running forever.
Changes made during the sync arrive in the next one. Pass the same `library_id`,
`entity_types`, and `created_at_gte` on every page.

## Apply events

Treat every event as "this changed": read the entity's current state and upsert
it, or remove your copy when the read no longer returns it. Because you always
apply current state, events that arrive out of order still leave you correct.

Re-reads count against your [rate limit](/guides/apis/rate-limiting). The list
endpoints accept up to 200 `ids`, so read a page's entities of each type in one
call rather than one call per event.

* **Deletions and trash.** Event types ending in `_deleted` or `_removed` mean
  the entity is gone, and `asset_trashed` moves an asset to the trash. A
  default read omits both, so the same rule removes your copy. `asset_restored`
  brings an asset back. Trashing or restoring an asset also hides or reveals its
  faces and album memberships, and changes its people's and stack's counts,
  without events of their own: re-read those by `asset_id` too.
* **Album membership.** Each add creates a new membership with its own ID, so
  re-read memberships by ID like any other entity. Deleting an asset or album,
  or trashing an asset, removes or hides its memberships without separate
  events, so drop yours along with it, as the example does. When you remove an
  asset from an album, `album_asset_removed` also names the `album_id` and
  `asset_id` in its `payload`.
* **People and faces.** A person's face and asset counts change through its
  faces, so they arrive as `face_*` events, not `person_updated`. A
  `face_updated` payload names the face's `person_id` and
  `previous_person_id`, and a `face_deleted` payload carries
  `previous_person_id`. Refetch each person that isn't `null`, and expect a
  `404` for one deleted in the same change.

To sync people, faces, or stacks too, add their list endpoints to `READERS`
and apply the rules above. A `metadata` event's `entity_id` is an asset ID.

The `GET /api/events` page in the **API Reference** tab lists the event types.
Request only the entity types you apply. Within them, re-read state for any
event type your client doesn't recognize rather than failing.

## Sync several entity types together

Some clients read one entity type at a time, such as albums before the photos in
them. Each read stops at its own point, so a later read could include a change
an earlier one stopped short of.

Every response includes `as_of`, the point that read stopped at. Pass the first
response's `as_of` to every other read in the same sync so they all stop at the
same point:

```python theme={null}
def sync_by_type(store, library_id, entity_types):
    as_of = None
    for entity_type in entity_types:
        cursor = store.load_cursor(entity_type)
        while True:
            page = client.events.get(
                library_id=library_id,
                entity_types=[entity_type],
                after_cursor=cursor,
                as_of=as_of,
                limit=200,
            )
            as_of = as_of or page.as_of
            apply_page(page.data, store, library_id)
            if page.next_cursor:
                cursor = page.next_cursor
                store.save_cursor(entity_type, cursor)
            if not page.has_more:
                break
```

Keep a separate cursor per type, but never store `as_of`: it belongs to one
sync. A shared `as_of` stops every read at one point, but an entity can still
arrive before an entity it refers to. Let your client tolerate a missing
reference until a later event fills it in.

## Handle errors

`GET /api/events` returns `400` for:

* an unknown entity type
* a malformed `after_cursor` or `as_of`
* an `after_cursor` or `as_of` ahead of the server, as after a restore that
  went back in time

Retrying the same request returns the same error. After a cursor error, discard
the stored cursor and resync from none.

`created_at_lt` is deprecated and ignored; a time bound could skip events. Bound
a read with `next_cursor` and `as_of` instead.

## Related guides

* [Pagination & Filtering](/guides/apis/pagination-and-filtering)
* [People and Faces](/guides/apis/faces-and-people)
* [Rate Limiting](/guides/apis/rate-limiting)
* [Python SDK](/guides/sdks/python)


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