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

# Sync real-time shared state end to end

> Build cross-client shared state with Devotel Orbit Sync — durable Documents, Maps, and Lists plus ephemeral Streams, over REST and a WebSocket subscription gateway.

Devotel Orbit Sync is a real-time shared-state primitive: when several clients
(a browser session, a mobile app, a backend worker) need to read and change
the same piece of state and see each other's writes as they happen, Sync
gives you that convergence without your own pub/sub infrastructure. It
mirrors the Twilio Sync object model, so a migration is a path swap.

This guide walks the full stack: when to reach for Sync, the REST surface
object by object, the WebSocket subscription protocol, a complete presence +
counter example, and the TTL, error, and permission semantics you need on
every retry loop.

## 1. What Sync is

Sync exposes four object kinds, all addressed by a `unique_name` you choose
(letters, digits, `.`, `_`, `:`, and `-`, up to 256 characters) and all
scoped to your tenant — one tenant can never read or address another
tenant's objects:

| Kind       | Durability | Shape                                                                                    |
| ---------- | ---------- | ---------------------------------------------------------------------------------------- |
| `document` | Durable    | A single JSON object with a monotonically increasing `revision`                          |
| `map`      | Durable    | An unordered key → JSON-value collection                                                 |
| `list`     | Durable    | An ordered, zero-based-indexed JSON-value collection                                     |
| `stream`   | Ephemeral  | Pub/sub messages — nothing is stored; subscribers connected at publish time receive them |

The durable kinds persist until you delete them (or a `ttl` you set
expires). Every REST write publishes a change event to a per-object channel;
the WebSocket gateway relays those events to subscribed clients, and clients
re-hydrate current state through the REST `GET` endpoints after a
(re)connect. Objects live in a low-latency store chosen for fast, frequent
mutation — Sync is for coordination state, not the system of record. Keep
authoritative data in your own database and use Sync for the shared,
changing view of it.

## 2. Sync vs. Inbox, Notifications, and Webhooks

Orbit has several real-time-ish surfaces. Pick Sync when the shape is
*shared state*, not *notification*:

* **Inbox** — a durable alert feed ("case 123 needs a review") where the
  item itself is the durable artifact. Sync documents expire or get
  overwritten; inbox items accumulate.
* **Notifications** — point-in-time alerts to an operator ("threshold
  crossed"). If the event is the message, that is a notification, not
  shared state.
* **Webhooks** — server-to-server push to your backend. Use Sync when the
  consumers are *client applications* (browser, mobile, desktop) that you
  do not want polling.

## 3. REST walkthrough

Every request carries an `X-API-Key` header (or a session Bearer token).
Reads are open to any authenticated member; writes require an operator role
(`owner`, `admin`, `developer`, or `agent`).

```bash theme={null}
export ORBIT_API_KEY="dv_live_sk_your_key_here"
BASE="https://api.orbit.devotel.io/api/v1/sync"
```

### Documents

Create a document (201 on success; a duplicate `unique_name` returns 409):

```bash theme={null}
curl -X POST "$BASE/documents" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "unique_name": "room_42_config",
    "data": { "theme": "dark", "max_participants": 25 },
    "ttl": 0
  }'
```

Fetch it — the response carries the current `data` and `revision`:

```bash theme={null}
curl "$BASE/documents/room_42_config" -H "X-API-Key: $ORBIT_API_KEY"
```

Update it. Revisions are monotonic, so a client uses them as an optimistic
guard: read, write, and re-read; if the reply revision moved past the one
you held, another writer won the race and you should re-hydrate instead of
retrying with your stale base:

```bash theme={null}
# 1. Read and note the revision (e.g. 3).
REV=$(curl -s "$BASE/documents/room_42_config" -H "X-API-Key: $ORBIT_API_KEY" | jq -r .data.revision)

# 2. Write.
curl -X POST "$BASE/documents/room_42_config" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "data": { "theme": "light" }, "ttl": 0 }'

# 3. Re-read; if revision > $REV, converge on the newer state.
```

Delete returns 204 and emits a `document.removed` event:

```bash theme={null}
curl -X DELETE "$BASE/documents/room_42_config" -H "X-API-Key: $ORBIT_API_KEY"
```

### Maps

A Map is an unordered key → JSON-value collection. Set an item
(create-or-replace), then list the map:

```bash theme={null}
curl -X PUT "$BASE/maps/room_42_presence/items/user_123" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "data": { "status": "online" }, "ttl": 0 }'

curl "$BASE/maps/room_42_presence/items" -H "X-API-Key: $ORBIT_API_KEY"
```

Each item carries its own `revision`. `DELETE` a single item or the whole
Map (204 either way, with `map.item.removed` / `map.removed` events).

### Lists

A List is append-indexed. Appending returns the new item's `index`
(zero-based):

```bash theme={null}
curl -X POST "$BASE/lists/room_42_audit/items" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "data": { "actor": "user_123", "action": "joined" } }'
```

Update an item at a given index (`PUT …/items/:index`), remove it
(`DELETE …/items/:index`), or delete the whole List (`DELETE …/:name`). All
emit the corresponding `list.item.*` / `list.removed` events.

### Streams

A Stream is ephemeral: publish a message and every client subscribed to the
stream at that moment receives it. Nothing is stored; a client that
connects after the publish does not see it. Useful for live counters and
presence aggregates you do not want to persist:

```bash theme={null}
curl -X POST "$BASE/streams/room_42_viewers/messages" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "data": { "viewers": 27 } }'
```

## 4. WebSocket gateway

Point your client at the gateway and authenticate, then manage per-object
subscriptions over the socket:

```
wss://api.orbit.devotel.io/api/v1/ws/sync
```

**Server-side (Node):** send the key in a header. Browsers cannot set
WebSocket headers, so pass them as subprotocols (see below).

```javascript theme={null}
import WebSocket from "ws"; // npm install ws

const socket = new WebSocket(
  "wss://api.orbit.devotel.io/api/v1/ws/sync",
  { headers: { "X-API-Key": process.env.ORBIT_API_KEY } },
);
```

**Browser:** offer a marker protocol first plus the credential as an auth
subprotocol — `Bearer.<jwt>` for a session token or `ApiKey.<key>` for an
API key. The server scans the offered list for the auth token. Never embed
a live API key in code shipped to end users; mint scoped session tokens on
your backend.

```javascript theme={null}
const socket = new WebSocket(
  "wss://api.orbit.devotel.io/api/v1/ws/sync",
  ["orbit-sync-v1", `ApiKey.${apiKey}`], // or `Bearer.${sessionJwt}`
);
```

After connect, the server sends `{ "type": "hello", "tenantId", ... }`.
Subscribe and unsubscribe with JSON frames:

```javascript theme={null}
socket.send(JSON.stringify({
  type: "subscribe",
  kind: "map",           // document | map | list | stream
  name: "room_42_presence",
}));
// → { "type": "subscribed", "kind": "map", "name": "room_42_presence" }
```

Change events arrive as full envelopes:

```json theme={null}
{
  "type": "map.item.set",
  "kind": "map",
  "unique_name": "room_42_presence",
  "key": "user_123",
  "revision": 7,
  "data": { "status": "online" },
  "ts": 1756147200000
}
```

The `type` is one of `document.updated`, `document.removed`,
`map.item.set`, `map.item.removed`, `map.removed`, `list.item.added`,
`list.item.updated`, `list.item.removed`, `list.removed`, or
`stream.message`. `revision` is absent for streams; `key` appears on map
events; `index` on list events; `data` is absent on removal events. A
client `ping` frame gets a `pong`; malformed frames get an `error` frame.

**Liveness and limits.** The server pings every 30 seconds and closes a
socket after 90 seconds without activity (about three missed pongs).
Replicate the client-side close logic: on close, re-hydrate state with a
REST `GET` and re-subscribe. Two cluster-wide caps apply per tenant: at
most 50 simultaneous sockets, and at most 200 subscribed objects per
socket. If you exceed either, the server returns an `error` frame
(`too_many_connections` / `too_many_subscriptions`) and closes the socket.

## 5. End-to-end example: chat-room presence + a live viewer counter

A common pairing: a durable Map for the per-user presence roster (survives
reconnects) and an ephemeral Stream for the "current viewer count" badge
(no persistence means no cleanup). The client below runs in Node or a
browser — keep the key out of shipped front-end code; in the browser
variant use the subprotocol auth from Section 4.

```javascript theme={null}
const API_KEY = process.env.ORBIT_API_KEY;
const BASE = "https://api.orbit.devotel.io/api/v1/sync";
const WS_URL = "wss://api.orbit.devotel.io/api/v1/ws/sync";
const room = "room_42";

// REST helpers -----------------------------------------------------
async function call(method, path, body) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      "X-API-Key": API_KEY,
      "Content-Type": "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`${method} ${path} → ${res.status}`);
  return res.status === 204 ? {} : (await res.json()).data;
}

const setPresence = (userId, status) =>
  call("PUT", `/maps/${room}_presence/items/${userId}`, {
    data: { status, last_seen: new Date().toISOString() },
  });

const publishViewers = (count) =>
  call("POST", `/streams/${room}_viewers/messages`, {
    data: { viewers: count },
  });

// Presence rendering ------------------------------------------------
const presence = new Map(); // userId -> { status, last_seen }
function render(presence, viewers) {
  console.log(`viewers: ${viewers ?? "?"} | online: ${[...presence.entries()]
    .filter(([, v]) => v.status === "online")
    .map(([id]) => id)
    .join(", ")}`);
}
let viewers;

// Socket -------------------------------------------------------------
function connect(userId) {
  const socket = new WebSocket(WS_URL, {
    headers: { "X-API-Key": API_KEY }, // browser: use subprotocols instead
  });

  socket.on("open", () => {
    for (const kind of ["map", "stream"]) {
      const name = kind === "map" ? `${room}_presence` : `${room}_viewers`;
      socket.send(JSON.stringify({ type: "subscribe", kind, name }));
    }
  });

  socket.on("message", (raw) => {
    const msg = JSON.parse(raw.toString());
    if (msg.type === "hello") return;
    if (msg.type === "map.item.set") {
      presence.set(msg.key, msg.data);
    } else if (msg.type === "map.item.removed") {
      presence.delete(msg.key);
    } else if (msg.type === "map.removed") {
      presence.clear();
    } else if (msg.type === "stream.message") {
      viewers = msg.data.viewers;
    }
    render(presence, viewers);
  });

  socket.on("close", () => {
    // Re-hydrate, then reconnect with backoff.
    setTimeout(async () => {
      const items = await call("GET", `/maps/${room}_presence/items`);
      presence.clear();
      for (const item of items) presence.set(item.key, item.data);
      connect(userId);
    }, 500);
  });

  // Announce yourself with a TTL so a dead client drops off the roster.
  setPresence(userId, "online");
  return socket;
}

const client = connect("user_123");
```

In a real deployment the backend (not the browser) usually publishes the
viewer aggregate into the Stream — for an always-on badge your client just
renders the stream message it receives.

## 6. TTL and retention semantics

* `ttl` (seconds, integer, max one year) means the object's data expires
  after that many seconds. `ttl: 0` (the default) means *no expiry* — the
  object persists until you `DELETE` it.
* On a Document and a Map you supply `ttl` per object/write; resetting it
  sets a fresh expiry window. Once the window elapses the object vanishes:
  reads return `SYNC_OBJECT_NOT_FOUND`, and subscriptions on it stop
  receiving updates.
* Use a short `ttl` on anything whose absence should clean up automatically
  (presence entries are the canonical case). Use `ttl: 0` for state you
  manage explicitly, and delete it when the workflow ends.
* A Stream has no retention at all — non-subscribers at publish time never
  see the message.

## 7. Error taxonomy

Sync surfaces the same error envelope as the rest of the API
(`error.code` / `error.message` / `error.status` / optional `details`). The
relevant codes:

| Code                     | HTTP | Cause                                                                           | Remedy                                                                      |
| ------------------------ | ---- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `SYNC_OBJECT_NOT_FOUND`  | 404  | The object (or item key/index) does not exist, or its TTL expired.              | Treat missing state as your base case; create the object if that is legal.  |
| `SYNC_REVISION_MISMATCH` | 409  | A write carried a stale base revision (or a duplicate `unique_name` on create). | Re-read the object, converge, and rewrite with the current revision.        |
| `RATE_LIMITED`           | 429  | Exceeded the 120-requests-per-minute limit for this Sync operation family.      | Back off using `error.retry_after` / the `Retry-After` header and re-issue. |
| `400` validation         | 400  | Missing `unique_name`, oversized `data` (64 KiB max), or a bad `ttl`.           | Fix the payload; validation errors are never retriable.                     |

The per-family limit is 120 requests/minute per operation family
(reads vs. writes) — the details of that limiter live in the
[rate-limits guide](/guides/rate-limits).

## 8. Permissions

Tenant scoping is absolute: a client only ever addresses objects inside its
own tenant, and the WebSocket gateway binds subscriptions to the
authenticated tenant's scope.

* **Reads** (`GET` on any object family): any authenticated member of the
  organization.
* **Writes** (create / update / delete / publish): the `owner`, `admin`,
  `developer`, or `agent` role. The dashboard's `member` role can read but
  not write; mint scoped server-side keys for the writers your app embeds.

Compliance controls (quiet-hours, consent, DNC) are tenant-configured in
Devotel Orbit and default to open — Sync does not impose platform-level
gates on what you share; shape your own usage policy on the object.

## See also

* [Sync API reference](/api-reference/sync) — endpoint-by-endpoint shapes
* [Endpoint reference (auto-generated)](/api-reference/endpoints/sync)
* [Error handling by example](/guides/error-handling-examples) — the
  envelope and retry logic
* [Rate limits](/guides/rate-limits) — headers and backoff
