WebSocket Protocol
Herald clients communicate over WebSocket using JSON frames. Every frame follows the same envelope. Authentication happens during the upgrade via query parameters.
Frame envelope
{
"type": "event.publish",
"ref": "unique-client-ref",
"payload": { ... }
}type— message type (required)ref— client-generated reference for request/response correlation (optional)payload— message-specific data
Authentication
Connect with HMAC-SHA256 signed tokens as query parameters:
wss://herald.skeptik.io/ws?key=API_KEY&token=SIGNED_TOKEN&user_id=USER_ID&streams=STREAM_1,STREAM_2Your backend generates the token by signing a payload containing the user ID, stream IDs, and expiry timestamp with your API secret. On success, Herald responds with auth_ok. On failure, auth_error and the connection closes.
Client → Server messages
| Type | Description |
|---|---|
subscribe | Subscribe to a stream |
unsubscribe | Unsubscribe from a stream |
event.publish | Publish an event to a stream |
event.trigger | Trigger an ephemeral event (not persisted, sender excluded) |
events.fetch | Fetch historical events by sequence range |
cursor.update | Update read cursor position |
presence.set | Set manual presence override (away, dnd, offline) |
typing.start | Broadcast typing indicator |
typing.stop | Clear typing indicator |
reaction.add | Add a reaction to an event |
reaction.remove | Remove a reaction from an event |
ping | Application-level keepalive |
Server → Client messages
| Type | Description |
|---|---|
auth_ok | Authentication succeeded |
auth_error | Authentication failed (connection closes) |
subscribed | Subscription confirmed with stream metadata |
event.new | New event in a subscribed stream |
event.ack | Server acknowledged a published event |
events.batch | Response to events.fetch |
presence.changed | A user's presence changed |
cursor.moved | A user's read cursor updated |
member.joined | A user joined a stream |
member.left | A user left a stream |
typing | Typing indicator |
reaction.changed | Reaction added or removed |
stream.subscriber_count | Updated subscriber count |
watchlist.online | A watched user came online |
watchlist.offline | A watched user went offline |
system.token_expiring | Token expires in 60 seconds |
error | Error with code and message |
Publishing
// Client sends:
{
"type": "event.publish",
"ref": "msg-1",
"payload": {
"stream_id": "STREAM_ID",
"type": "message",
"body": {"text": "Hello"},
"meta": {}
}
}
// Server responds:
{
"type": "event.ack",
"ref": "msg-1",
"payload": {
"event_id": "evt_abc123",
"seq": 42
}
}Ephemeral events
Ephemeral events fan out to subscribers but are not stored. The sender is excluded from delivery. Use these for lightweight signals like cursor position broadcasts or status indicators.
{
"type": "event.trigger",
"payload": {
"stream_id": "STREAM_ID",
"type": "cursor_move",
"body": {"x": 120, "y": 340}
}
}Reconnect protocol
- Connection drops
- Client starts exponential backoff (jittered)
- Client reconnects with its highest received sequence per stream
- Herald replays missed events in order
- Client deduplicates by event ID
The SDKs handle reconnect and deduplication automatically.
At-least-once delivery
Opt-in protocol for guaranteed delivery. The client acknowledges each event with event.ack. On reconnect, unacknowledged events are re-delivered.
// Server delivers:
{"type": "event.new", "payload": {"event_id": "evt_abc", "seq": 42, ...}}
// Client acknowledges:
{"type": "event.ack", "payload": {"event_id": "evt_abc", "stream_id": "STREAM_ID"}}Error codes
| Code | Meaning |
|---|---|
auth_failed | Invalid or expired token |
not_found | Stream or resource does not exist |
forbidden | Not authorized for this action |
invalid_payload | Malformed frame payload |
rate_limited | Too many requests |
stream_full | Stream member limit reached |
blocked | User is blocked in this stream |
internal | Server error |