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 rowdelete: remove your local copy ofentity_idretire: the person now forwards toforwards_to(ENT-09); rewrite any local reference toentity_idto point at that id instead, forever, since a retired id keeps resolving through its forwarding linkscope_change: a grant changed; when the entry carriesrevoked_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.