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}]| Field | Type | Meaning |
|---|---|---|
auth | string | browser: a capability token granting object:read:{type}/{key} |
app_secret | string | server: the App Secret directly (skips token minting) |
after_log_id | integer | resume: 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": ... } }):
reason | Cause |
|---|---|
auth required for durable object channels | neither auth nor app_secret |
invalid app_secret | wrong app_secret |
invalid token | token bad, expired, or bound to another socket |
forbidden: missing object:read:{type}/{key} | token doesn't grant this object |
invalid topic format | topic'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 + path | Body | Reply |
|---|---|---|
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
| Status | Body | Cause |
|---|---|---|
401 | { "errors": [{ "status": "401", "title": "Unauthorized" }] } | bad / missing headers, or the path app_id isn't the authenticated app |
404 | route not matched | bad path or unknown slot on GET /{slot} |
422 | { "error": "..." } | operation failed — increment on non-numeric, append on non-list, malformed transaction op |
Consistency
log_idis 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 lowerlog_id, and will not see one twice. after_log_idreplay is exact — every entry> after_log_id, in order, before the snapshot.
See Durable Objects — Architecture for the replication and ring internals.