Pingerchips LogoPingerchips
Wire Protocol

PubSub wire protocol

The app:* channel — messages, delta compression, connection recovery, and client publish.

Topic: app:{appKey}:room:{channel}

{channel} is any string ≤ max_channel_name_length (default 200). One channel module (RoomChannel) serves every app:* topic; behaviour is chosen by the channel-name prefix:

PrefixModeSerial + replayPresenceClient publish
ephemeral-ephemeralnoyes (Phoenix.Tracker)fire-and-forget broadcast_from!
private- / presence-standardyespresence- onlyvia flow engine
anything elsestandardyesnovia flow engine

The private- / presence- / ephemeral- prefixes are how the current server routes behaviour. They are stable, but treat them as an implementation detail — a future version moves durability and presence to explicit channel config and capabilities rather than the topic string.


Join

["1", "1", "app:pk_live_x:room:orders", "phx_join", {payload}]

Join payload

FieldTypeWhen
authstringrequired for private-* / presence-*, or any channel when the app has enable_user_authentication = 1. A capability token granting channel:subscribe:{channel}.
after_serialintegerresume: replay every message after this serial before live delivery
deltafalse | "fossil" | "xdelta3" | trueenable delta compression for this subscription
filterobjectserver-side ExSift filter — only matching messages are delivered
presenceobjectpresence-* only — this member's presence data, merged into the tracker meta

Join reply

{ "status": "ok", "response": { "socket_id": "abc-123", "serial": 4210 } }
FieldMeaning
socket_idthis connection's id — pass to your auth endpoint when minting tokens
serialthe channel's current serial at join time; live messages start at serial + 1

Errors: { "status": "error", "response": { "reason": "..." } }reason is one of "invalid token", "forbidden: missing channel:subscribe:{channel}", "auth required for private/presence channels", "app_key mismatch", "invalid topic format".


Server → client

message

The delivery of a published event. Every standard-channel message is wrapped in an envelope:

["1", null, "app:pk_live_x:room:orders", "message", {
  "id": "01JF...",           // ULID, time-sortable, globally unique
  "serial": 4211,            // monotonic per channel
  "clientId": "user-42",     // present if the publisher set one
  "event": "order.created",  // your event name
  "data": { "orderId": "A-1" }
}]

event and data are your payload; id / serial / clientId are added by the server. On an ephemeral- channel there is no envelope — the raw broadcast_from! payload arrives as-is under whatever event name the publisher used.

message:delta

Sent instead of message when the subscription enabled delta compression and the server had a prior message to diff against.

{
  "id": "01JF...",
  "serial": 4212,
  "delta": "<base64 patch bytes>",
  "algorithm": "fossil",
  "base_serial": 4211
}

The client applies delta to the message it holds at base_serial. If it can't (missing base), it should re-subscribe with after_serial to resync.

pingerchips:delta_compression_enabled

{ "channel": "orders", "algorithm": "fossil" }

Sent right after join when the channel has delta compression configured server-side, so a client that didn't ask for it still knows to expect message:delta.

pingerchips:recovery_failed

The server could not honour an after_serial resume.

{ "channel": "orders", "reason": "position_expired", "afterSerial": 1200 }

reasonposition_expired (the replay buffer has evicted that serial) | no_buffer (the channel isn't buffered). Re-fetch state from your own API, then re-subscribe without after_serial.


Client → server

{eventName} — publish

Any event name that isn't a reserved pingerchips:* / presence:* / lock:* name is a client publish.

["1", "5", "app:pk_live_x:room:orders", "order.updated", {
  "orderId": "A-1", "status": "shipped",
  "msg_serial": "b9c1e0d2-..."   // optional — idempotency key for retries
}]

Requires Enable Client Messages on the app. Rate-limited to max_client_events_per_sec. On a standard channel the publish runs through the channel's flow pipeline (transform / filter / fan-out); on an ephemeral- channel it's a straight broadcast_from! to every other member.

msg_serial (a UUID you generate) makes a publish idempotent — a retry with the same msg_serial from the same socket is acknowledged without re-broadcasting.

Reply: { "status": "ok" }, or { "status": "error", "response": { "reason": ... } }reason"Client messages are disabled", "Channel mode does not permit publishing", "Rate limit exceeded", "Validation failed: {detail}".

pingerchips:delta_sync_error

{ "channel": "orders" }

Tells the server the client's delta state for that channel is corrupt; the server resets it and sends the next message in full.


Presence (presence-* channels)

On join, the server pushes the current member set once:

["1", null, "app:pk_live_x:room:presence-lobby", "presence:state", {
  "members": [
    { "id": "sock-1", "client_id": "user-42", "joined_at": 1738500000000, "presence": true }
  ]
}]

The current server sends presence:state on join but does not yet broadcast live presence:join / presence:leave diffs. A member list is accurate as of join time only. This is being reworked — see the presence redesign.


Delta compression

Set delta in the join payload, or configure it on the channel's flow so every subscriber gets it. Two algorithms:

algorithmPatch formatUse for
fossilfossil-delta bytes, base64JSON documents that change incrementally
xdelta3VCDIFF, base64large binary-ish payloads (not bundled in the JS SDK yet)

The server keeps the last full message per (channel, subscription) and sends message:delta when a diff is smaller than the full payload, message otherwise. See Channel filtering & delta.


Connection recovery

On reconnect, re-join every channel with after_serial set to the last serial you saw (client.getLastSerial(channel)). The server replays buffered messages > after_serial, then resumes live delivery. If the buffer has rolled past that point you get pingerchips:recovery_failed instead — treat it as "refetch and resubscribe".

Replay buffer depth is max_replay_messages (default 200) for a non-durable channel, or the full WAL for a durable one.


Envelope

Every standard-channel outbound message:

{
  id:        string    // ULID
  serial:    number    // monotonic per channel, starts at 1
  clientId?: string    // the publisher's client_id, if any
  // ...your event fields merged in at the top level:
  event:     string
  data:      unknown
}

serial is the cursor for after_serial resume. id is a stable dedupe key.

On this page