Pingerchips LogoPingerchips
Wire Protocol

Durable Objects wire protocol

The durable:* channel and the /api/v1/objects REST API — exact requests, events, and errors.

A Durable Object is a replicated key→value blob (type + key) with an append-only log. Writes go through the REST API or the server SDK; browsers subscribe read-only over a channel and get every change relayed live.


Channel

Topic: durable:{appKey}:{type}:{key}

{type} and {key} are arbitrary non-empty strings. The channel is read-only — any client push is rejected.

Join

["1", "1", "durable:pk_live_x:order:order-42", "phx_join", {payload}]
FieldTypeMeaning
authstringbrowser: a capability token granting object:read:{type}/{key}
app_secretstringserver: the App Secret directly (skips token minting)
after_log_idintegerresume: replay log entries after this id as change events before the snapshot

Exactly one of auth / app_secret is required.

Join reply

{ "status": "ok", "response": { "socket_id": "abc-123" } }

Errors ({ "status": "error", "response": { "reason": ... } }):

reasonCause
auth required for durable object channelsneither auth nor app_secret
invalid app_secretwrong app_secret
invalid tokentoken bad, expired, or bound to another socket
forbidden: missing object:read:{type}/{key}token doesn't grant this object
invalid topic formattopic's {appKey} segment isn't the connected app

Server → client

Immediately after join the server sends a snapshot, preceded by any after_log_id replay:

change (replay — one per missed log entry, before the snapshot)

{ "key": "status", "value": "shipped", "log_id": 41 }

Each replayed entry comes from GET /log — same { key, value, log_id } shape.

snapshot

["1", null, "durable:pk_live_x:order:order-42", "snapshot", {
  "state": { "status": "shipped", "assigned_to": "agent-7", "retry_count": 2 },
  "log_id": 42
}]

state is the full materialised blob; log_id is the monotonic per-object cursor. Live events follow at log_id + 1.

change (live — single-slot write)

Emitted after set, increment, append, delete.

{ "key": "status", "value": "delivered", "previous": "shipped", "log_id": 43 }

value: null means the slot was deleted.

batch (live — atomic multi-slot write)

Emitted after set_all / transaction.

{
  "changes": [
    { "key": "status", "value": "delivered", "previous": "shipped" },
    { "key": "retry_count", "value": 0, "previous": 2 }
  ],
  "log_id": 44
}

All changes in a batch committed in one log entry — they arrive together or not at all.

change / batch are relayed verbatim from the object's internal broadcast topic. Chat threads (which are Durable Objects of type: "thread") add run:start / run:end / run:suspend / run:resume on the same topic — see Chat.


REST API

Base path: /api/v1/objects/{app_id}/{type}/{key}

{app_id} may be the App Key or the App ID. Auth: X-App-Key + X-App-Secret headers on every request — no signing, no timestamp (Authentication).

Method + pathBodyReply
GET /{ "state": {...}, "log_id": N }
GET /{slot}{ "value": ... } or 404
PUT /{slot}{ "value": ... }{ "log_id": N }
PATCH /{ "slot1": v1, "slot2": v2 }{ "log_id": N }
POST /{slot}/increment{ "by": N } (default 1){ "log_id": N, "value": N }422 if the slot isn't numeric
POST /{slot}/append{ "value": ... }{ "log_id": N }422 if the slot isn't a list
DELETE /{slot}{ "log_id": N }
POST /transaction[ { "op": "set"|"delete"|"increment"|"append", "key": "...", "value"?, "by"? }, ... ]{ "log_id": N }
GET /log?after={logId}{ "items": [ { "key", "value", "log_id" }, ... ] }
DELETE /204 (object purged)

Every write broadcasts the corresponding change / batch to channel subscribers.

Errors

StatusBodyCause
401{ "errors": [{ "status": "401", "title": "Unauthorized" }] }bad / missing headers, or the path app_id isn't the authenticated app
404route not matchedbad path or unknown slot on GET /{slot}
422{ "error": "..." }operation failed — increment on non-numeric, append on non-list, malformed transaction op

Consistency

  • log_id is monotonic per object and total-ordered across all subscribers.
  • A write is durable (RocksDB + WAL, quorum-replicated) before its HTTP reply returns.
  • The snapshot on join reflects state at its log_id; you will not miss a change that has a lower log_id, and will not see one twice.
  • after_log_id replay is exact — every entry > after_log_id, in order, before the snapshot.

See Durable Objects — Architecture for the replication and ring internals.

On this page