World API
A REST API for reading and writing world data. Two dialects share one base host:
- v2 (
/api/v2/) — the modern API. Cursor pagination, flat UUID link arrays, native create/upsert, bulk, and a change feed. Use this for new work. - Classic (
/api/worldapi/) — the original v1 dialect, unchanged and supported. Kept for existing clients; see the Classic API section at the bottom.
Base URL (v2): https://www.onlyworlds.com/api/v2/
Format: JSON
Interactive docs: onlyworlds.com/api/v2/docs · OpenAPI: onlyworlds.com/api/v2/openapi.json
Authentication
Requests carry the world key, and the PIN when the world is walled:
API-Key: your-world-api-key
API-Pin: your-world-pin
Key types. Keys are minted in the account portal:
ow_w_…— world write. Read and write one world.ow_r_…— read capability. Read one world; write routes return403.ow_a_…— account token. Acts on your account, not one world: list your worlds (GET /api/v2/account/worlds), create worlds, mint/revoke world keys, manage watched worlds. Sent asAuthorization: Bearer ow_a_…. Mint one under Settings → Account tokens in the portal.- Legacy 10-digit keys (e.g.
0000000001) still work and are valid forever. New 10-digit keys are no longer issued — use the prefixed keys above.
Each key is scoped to one world. The key alone determines the world; you do not pass a world id in the body.
The PIN is the world’s wall. A world with a PIN requires it on every write, and on reads too. A world with no PIN (a public/demo world) reads without a PIN, but writes still require one if the world has one set. So whether a read needs a PIN is a property of the world, not the dialect: walled world → PIN on reads; open world → no PIN on reads.
Credentials are managed at the account portal.
Resources
All 22 element types are addressed by their singular slug. The URL determines the type; the credential determines the world.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/v2/{type} | List elements (paginated, filterable) |
| GET | /api/v2/{type}/{id} | Get one element |
| POST | /api/v2/{type} | Create (server or client-minted id) |
| PUT | /api/v2/{type}/{id} | Upsert by id (create or full replace) |
| PATCH | /api/v2/{type}/{id} | Partial update |
| DELETE | /api/v2/{type}/{id} | Delete |
| POST | /api/v2/bulk | Mixed-type batch create/upsert |
| GET | /api/v2/changes | World change feed (sync/export) |
| GET | /api/v2/world | The world named by the credential (identity + meta) |
| PATCH | /api/v2/world | Update world meta (name, description, time fields) |
(Trailing slashes are tolerated on v2 — /api/v2/character/{id} and /api/v2/character/{id}/ both resolve.)
Example — list characters:
curl -s "https://www.onlyworlds.com/api/v2/character?limit=50" \
-H "API-Key: {key}" \
-H "API-Pin: {pin}"
Example — create a location (no world field — the key names the world):
curl -s -X POST "https://www.onlyworlds.com/api/v2/location" \
-H "API-Key: {key}" \
-H "API-Pin: {pin}" \
-H "Content-Type: application/json" \
-d '{ "name": "Hyperion", "description": "Time Tomb planet" }'
Reads and pagination
List responses are enveloped:
{ "data": [ … ], "has_more": false, "next_cursor": null }
data— the array of elements for this page.has_more—trueif more pages remain.next_cursor— opaque token; pass it back as?cursor=to fetch the next page. Treat it as opaque (do not parse it).
Paginate with ?limit= (default 100, max 1000) and ?cursor=:
# first page
curl -s "https://www.onlyworlds.com/api/v2/character?limit=100" -H "API-Key: {key}"
# next page — pass the returned next_cursor back
curl -s "https://www.onlyworlds.com/api/v2/character?limit=100&cursor={next_cursor}" -H "API-Key: {key}"
A single-element GET (/api/v2/character/{id}) returns the bare element object — not enveloped, no data wrapper.
Filtering uses a closed set of Django-style operators — anything else is a 422 (typos are rejected, not silently ignored):
__icontains, __in, __gte, __lte, __isnull, plus supertype / subtype equality, and ?ordering=.
curl -s "https://www.onlyworlds.com/api/v2/character?name__icontains=admiral" -H "API-Key: {key}"
Expansion and sparse fields (optional): ?expand=friends,location inlines those linked elements (one level deep); ?fields=id,name,description returns only those fields.
Link fields — flat UUID arrays, both directions
In v2, link fields use one bare name in both reads and writes — no suffix, no shape change.
{
"id": "0695…",
"name": "Admiral Splashworth",
"location": "0698…", // single link: a UUID string, or null
"friends": ["0695…", "0698…"] // multi link: an array of UUID strings
}
- Read and write use the same field name (
friends,location) and the same value shape (raw UUIDs). No_ids/_idsuffix exists in v2 — sending one is a422 invalid_request. - Multi-link fields are plain UUID arrays on write; single-link fields are a UUID or
null.
Link operations (add/remove without a read-modify-write):
curl -s -X POST "https://www.onlyworlds.com/api/v2/character/{id}/links/friends" \
-H "API-Key: {key}" -H "API-Pin: {pin}" -H "Content-Type: application/json" \
-d '{ "add": ["uuid-a", "uuid-b"], "remove": ["uuid-c"] }'
Adds dedupe (idempotent); removes tolerate ids that aren’t present. No prior GET required.
Writes
POST /{type}— create. You may supply anid(any RFC-4122 UUID; v7 recommended), or omit it and the server mints one. Reusing an existing id is a409 id_conflict— usePUTto upsert.PUT /{type}/{id}— upsert by id: creates if absent, full-replaces if present.PATCH /{type}/{id}— partial update. Omitted fields are left untouched. Arrays replace (aPATCHtofriendssets the whole list — use the link operations endpoint to add/remove). To clear a field, send its empty shape:""/nullfor text,nullfor a single link,[]for a multi link,nullfor a number.DELETE /{type}/{id}— returns204. Idempotent: deleting an already-absent element is still204. Deleting an element also scrubs its UUID from every other element’s links — no dangling references.- Unknown fields return a
422naming the field. (Fields under the reserved extension namespacesatlas_*,shadow_*,x_*pass through and are stored verbatim.)
Idempotency-Key (on POST and /bulk): send a unique key header and the first successful response is stored for 24h. An identical replay returns that stored response (with an Idempotent-Replay: true header) and does not act twice; the same key with a different body is a 409 idempotency_error. Only successful (2xx) responses are stored — a retry after an error re-executes.
Bulk
POST /api/v2/bulk — one call, mixed types, the parse-pipeline and sync workhorse. Always returns HTTP 200; results are per-item, in request order:
// request
{ "items": [ { "op": "upsert", "type": "character", "element": { "name": "…" } }, … ] }
// response
{ "errors": false,
"items": [ { "status": 201, "id": "…", "created_at": "…", "updated_at": "…" }, … ] }
- Partial success is the default — one bad item does not sink the batch. Send
"atomic": truefor all-or-nothing. - Each success slot echoes the server’s
created_at/updated_at, so a sync client sets its baseline from the bulk response alone. - Links are validated against the world plus surviving items in the same batch, in any order — a batch may reference its own members without pre-sorting.
- Failed items carry the standard error envelope under
error; the top-levelerrorsflag istrueif any item failed.
Changes and export
GET /api/v2/changes?since={cursor} — the world’s ordered change feed, and the full-export path:
{ "cursor": "…", "has_more": false, "head": 4213, "changes": [
{ "op": "upsert", "type": "character", "id": "…", "updated_at": "…", "element": { … } },
{ "op": "delete", "type": "location", "id": "…", "deleted_at": "…" } ] }
- Walk it by passing the returned
cursorback as?since=untilhas_moreisfalse. Treat the cursor as opaque. The continuation parameter issince=— passing an unknown parameter (e.g.cursor=) is a422, not a silent no-op. - Full export = no
since.GET /api/v2/changeswith no cursor returns every live element (at its latest state) plus tombstones — the whole world in one walk. - Deletes are explicit tombstones (
op: "delete"), never inferred from absence. Tombstones are retained indefinitely, so an old cursor still replays every delete above it. headis the world’s current change sequence; if your stored cursor position exceedshead, a restore rewound the world — treat your cursor as invalid and re-baseline.?limit=(default 500, cap 1000) bounds one response.
GET /api/v2/world — returns the world named by the credential: {id, name, description, image_url, time_format_names, time_format_equivalents, time_basic_unit, time_range_min, time_range_max, time_range_current, public_read, created_at, updated_at}. A 200 also validates the key (and PIN, if walled).
PATCH /api/v2/world — update world meta (write key + PIN). Writable: name, description, image_url, time_basic_unit (strings), time_format_names, time_format_equivalents (lists of strings), time_range_min, time_range_max, time_range_current (integers or null). Unknown fields are a 422; public_read and the PIN are managed in the account portal, not here. World meta does not appear in /changes — poll GET /world and compare updated_at.
Errors
Every v2 / bulk / changes error is one envelope:
{ "error": {
"type": "invalid_request",
"code": "invalid_link",
"message": "friends references Character '0123…' which does not exist.",
"param": "friends",
"doc_url": "https://onlyworlds.github.io/api/errors#invalid_link" } }
Branch on code; group on type; param names the offending field; doc_url links the full error reference. See the API error reference for every code, its cause, and how to fix it.
CORS
The API accepts cross-origin requests from a wide range of hosting platforms.
Pre-approved platforms:
- OnlyWorlds domains:
*.onlyworlds.comsubdomains,onlyworlds.github.io - Static hosting: GitHub Pages, GitLab Pages, Cloudflare Pages
- Modern platforms: Vercel, Netlify, Render, Railway, Fly.io
- Traditional hosting: Heroku, AWS Amplify, Firebase Hosting
- Quick deploy: Surge.sh, Glitch
- Online IDEs: Replit, CodeSandbox, StackBlitz, CodePen, JSFiddle
- Local development:
localhostand127.0.0.1on any port
For custom domain CORS access, contact info@onlyworlds.com.
Classic API (v1 dialect) — legacy
The original API remains available and unchanged at /api/worldapi/. It is supported for existing clients; new work should use v2 above. The two dialects differ in several ways — most notably link-field naming.
v2 does NOT use the
_ids/_idsuffixes described in this section. The suffix convention below is v1-only. On v2 (and/bulk), link fields are bare names in both directions (friends,location) — sendingfriends_idsthere is a422.
Base URL: https://www.onlyworlds.com/api/worldapi/
Authentication: same API-Key / API-Pin headers. Legacy 10-digit keys and prefixed keys both work here.
Classic operations
Endpoint names are singular (/character/, /location/). Note the trailing slash is required on single-element reads — GET /character/{id} without it returns a 301 redirect with an empty body that curl/requests won’t follow by default; GET /character/{id}/ returns 200.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /{type}/ | List elements (returns a bare JSON array — no envelope, no pagination) |
| POST | /{type}/ | Create |
| GET | /{type}/{uuid}/ | Get one element |
| PATCH | /{type}/{uuid}/ | Partial update |
| PUT | /{type}/{uuid}/ | Full replace |
| DELETE | /{type}/{uuid}/ | Delete |
Classic link fields — the _ids asymmetry
In the v1 dialect, multi-link fields use different names on read vs write:
| Direction | Field name | Example |
|---|---|---|
| GET (read) | characters | Returns the linked elements (expanded into {id, name, …} stub objects) |
| POST/PATCH (write) | characters_ids | Send a list of UUIDs to link |
Single-link fields use the _id suffix on write (e.g. location_id). This read/write asymmetry is specific to v1 — v2 removed it entirely.
Classic errors
The v1 dialect uses the legacy envelope, not the v2 shape:
- Most errors:
{"detail": "…"}(a string) or{"detail": [ … ]}(a list of field validation errors). - Auth failures add a nested legacy object:
{"detail": "Unauthorized", "error": {"code": "unauthorized", "detail": "Authentication required."}}.
Unknown fields (including world in the body) return a 422 naming the field — the same friendly message as v2, in the legacy {"detail": [...]} shape.