How to: keep a mirror in sync

GET /v1/changes?cursor= is the conformant sync mechanism (SYNC-01); the ?updated_since= filter on the list endpoints is a human convenience with at-least-once caveats only (SYNC-06), never a substitute for this loop.

The loop

Poll every 5 minutes, the recommended cadence, with whatever cursor you last stored:

curl -H "Authorization: Bearer $TOKEN" \
  "https://xcathletes.org/v1/changes?cursor=$CURSOR"
{
  "items": [
    {
      "seq": 4821,
      "op": "upsert",
      "entity": "training_session",
      "entity_id": "aabbccdd11223344556677889900aabb",
      "person_id": "1a2b3c4d5e6f70819203a4b5c6d7e8f9",
      "origin_consumer_id": "01020304050607080910111213141516",
      "external_ref": "ecxc-2026-08-20-riley",
      "occurred_at": "2026-08-20T18:00:03.000Z"
    }
  ],
  "next_cursor": "4821"
}

Store next_cursor after every poll, even when items comes back empty: it is present on the last page too, not only a "there's more" marker, so a poller always resumes from it on the next round.

Applying entries idempotently

Delivery is at-least-once (SYNC-03): the same entry can arrive twice. Apply every entry as setting the current state of entity_id, not as an incremental diff, and applying it twice is then a no-op rather than a bug:

  • upsert: fetch or use the entry's own fields to write your local copy of the row
  • delete: remove your local copy of entity_id
  • retire: the person now forwards to forwards_to (ENT-09); rewrite any local reference to entity_id to point at that id instead, forever, since a retired id keeps resolving through its forwarding link
  • scope_change: a grant changed; when the entry carries revoked_at, that grant ended (SYNC-02) and your visibility into that person narrowed as of that moment, whether or not you've noticed rows disappear from a subsequent read

When the cursor is too old

Tombstones are retained for 180 days (SYNC-05). A cursor older than that gets resync_required instead of a page:

HTTP/1.1 410 Gone

{
  "type": "https://xcathletes.org/docs/errors/resync_required",
  "title": "Resync required",
  "status": 410
}

Discard the stored cursor and start again from cursor=0 (or omit it), the same onboarding path a brand-new mirror takes: list every entity your grants reach, store what you get, then resume polling from the fresh next_cursor that read hands back.