Changes and Export
GET /api/v2/changes is the world’s change feed: every create, update and delete, in order. Walked from the start it is a full export; walked from a stored cursor it returns what changed since.
The Feed
Section titled “The Feed”curl -s "https://www.onlyworlds.com/api/v2/changes?limit=1" -H "API-Key: 0000000000"{ "cursor": "1:068e2e60-fc37-789e-8000-a64560195ff8", "has_more": true, "head": 124, "changes": [ { "op": "upsert", "type": "ability", "id": "068e2e60-fc37-789e-8000-a64560195ff8", "change_seq": 1, "updated_at": "2025-10-05T21:41:35.764549+00:00", "element": { "type": "ability", "name": "Piano Playing", "…": "…" } } ] }That is Hyperion’s first page. The next call sends ?since=1:068e2e60-fc37-789e-8000-a64560195ff8. A delete in the feed reads:
{ "op": "delete", "type": "location", "id": "…", "deleted_at": "…" }| Key | Meaning |
|---|---|
changes |
The ops on this page, sorted by the world’s change sequence |
cursor |
Pass it back as ?since= for the next page. Its shape varies (a page’s cursor such as "1:068e…", or a number from ?head=true), so store it as it came |
has_more |
true if more pages remain |
head |
The world’s current change sequence |
An upsert carries the whole element at its latest state, in the same body a read returns. A delete is a tombstone with the id and deleted_at.
Walking the Feed
Section titled “Walking the Feed”Pass the returned cursor back as ?since= until has_more is false. Store the last cursor; next time, start from it.
| Parameter | Meaning |
|---|---|
since |
The cursor to continue from. Omit it to start from the beginning |
limit |
Ops per page: default 500, at most 1000. A value out of range or not an integer is a 422 |
head |
?head=true returns only the current tip (head, and a cursor to follow from), with no elements |
- Treat the cursor as opaque. Pass back exactly what you received.
- The continuation parameter is
since. Any other parameter, such ascursor=, is a422, never silently ignored. - Apply ops in the order given. Upserts and deletes are interleaved by sequence; applying them in that order makes the local copy converge.
- The cursor never expires. Tombstones are kept indefinitely, so an old cursor still replays every delete after it.
- An empty
changeslist means nothing changed since that cursor; the cursor comes back unchanged. ?head=trueis the cheap way to start following a world from now, or to check whether you are behind.
import requests
BASE = "https://www.onlyworlds.com/api/v2"HEADERS = {"API-Key": "ow_r_…"} # any key on the world; reads need no PIN
def pull(since=None): """Walk the feed from `since` (None = full export). Returns the ops and the next cursor.""" ops = [] while True: params = {"limit": 1000} if since is not None: params["since"] = since page = requests.get(f"{BASE}/changes", headers=HEADERS, params=params).json() ops.extend(page["changes"]) since = page["cursor"] if not page["has_more"]: return ops, sinceFull Export
Section titled “Full Export”Walking GET /api/v2/changes from no since until has_more is false gives every live element at its latest state, plus a tombstone for each delete: the whole world. Most worlds take several pages. There is no separate export route. Deletes are always explicit delete ops, never inferred from an element’s absence.
World fields (name, the time fields and the rest of GET /api/v2/world) are not in the feed. Poll GET /api/v2/world and compare updated_at; element writes never change the world’s updated_at.
Rewinds and head
Section titled “Rewinds and head”In normal operation a world’s change sequence only moves forward. The one exception is a restore from backup, which rewinds it. If your stored position is beyond the head in a response, a restore happened: treat your cursor as invalid and pull again from the start.
Guests’ Cursors
Section titled “Guests’ Cursors”For every key except a guest’s, everything above holds. A guest key walks only what it can see:
- Its cursor has three parts. It is still opaque: pass back exactly what you got.
- It never receives
deleteops. - When the guest’s view shrinks or changes wholesale (an element leaves it, an existing element enters it, the roster changes, or the guest’s own role changes), the next call answers
409resync_required. Pull again fromsince=0and replace the local copy: merging would keep elements the guest can no longer see. - A guest may always start from
since=0or with nosince. ?head=truegives a guest a cursor it passes straight back assince.headin a page is still the world’s change sequence.
A guest that becomes a contributor (or the reverse) holds a cursor of the wrong shape, and also gets 409 resync_required.
The Export File
Section titled “The Export File”The account portal’s Export world button downloads the whole world as one JSON file, separate from the API feed. It is self-describing:
{ "format": "onlyworlds-world-export", "schema_version": "…", "exported_at": "…", "world": { "id": "…", "name": "Hyperion", "created_at": "…", "updated_at": "…" }, "elements": { "character": [ { "id": "…", "type": "character", "name": "The Consul" } ], "institution": [ { "id": "…", "type": "institution", "name": "The Hegemony" } ] }}formatis always the literal"onlyworlds-world-export"; a reader should reject a file without it.elementsis keyed by lowercase category slug, each an array ordered bycreated_at. Only categories with at least one element appear.- Element bodies are exactly what the API returns, extension fields included.
world.idis the world’s identity: an importer should keep it rather than mint a new one.schema_versionversions this envelope, not the schema or the world. Readers ignore keys they do not know.
