WebSocket Protocol¶
This document defines the Dichit WebSocket protocol: the framing, the message envelope, the request/response model, and naming conventions. It is the contract both clients and servers implement.
Reference: README.md · connection.md · authentication.md · rooms.md · errors.md
WebSocket protocol¶
- Transport: WebSocket (RFC 6455).
- All messages are JSON-encoded text frames. Binary frames are rejected.
- Endpoint:
wss://api.example.com/ws(see connection.md). - A connection requires a valid access token at handshake (see authentication.md).
- The server maintains the connection with protocol-level ping/pong heartbeats.
Connection semantics follow RFC 6455. A single connection is a full-duplex channel: the client sends request messages, the server replies with acknowledgements or errors, and both directions may carry events.
Connection lifecycle¶
The protocol assumes a stateful lifecycle per connection:
- Handshake — the client opens a WebSocket and authenticates with a JWT.
- Ready — the connection is established and can send/receive messages.
- Subscription — the client joins rooms and/or subscribes to auctions.
- Active — the client sends commands and receives pushed updates.
- Closed — the connection ends (client or server initiated), heartbeats stop, and all room memberships are released.
See lifecycle.md for the detailed state machine and sequence diagrams.
Request/Response model¶
The protocol follows async CQRS (Command Query Responsibility Segregation):
- Commands (write operations): The client sends a command and receives an immediate
command.acknowledgedresponse. Business results are delivered asynchronously via domain events. - Queries (read operations): The client sends a query and receives a synchronous response with the requested data.
- Server push: State changes are pushed asynchronously as domain events. They are not tied one-to-one with client commands.
Command Lifecycle¶
Client Server Use Case Event Bus
│ │ │ │
│ 1. auction.bid.place │ │ │
├────────────────────────>│ │ │
│ │ 2. Validate envelope │ │
│ │ Authenticate │ │
│ │ Authorize │ │
│ ├─────────────────────────>│ │
│ 3. command.acknowledged │ │ 3. Execute business │
│<────────────────────────┤ │ logic │
│ │ │ │
│ │ │ 4. Publish events │
│ │ ├───────────────────────>│
│ │ │ │
│ 5. auction.bid.created │<─────────────────────────┼────────────────────────┤
│<────────────────────────┤ │ │
Key Principles:
- Acknowledgements contain no business data (no bid IDs, amounts, state versions)
- Acknowledgements normally confirm the command entered the processing pipeline; an idempotent retry may additionally report the command's already-persisted terminal status
- Initial business outcomes are delivered via domain events; terminal retries report the persisted status in the acknowledgement and require snapshot/feed reconciliation
- Commands are idempotent (use the envelope
idfor duplicate detection)
Command Acknowledgements¶
A command that passes initial validation returns command.acknowledged:
{
"id": "ack_01HQ5BXWYP1Y5RX0W5X0W5X0W5X",
"type": "command.acknowledged",
"version": "1.0",
"source": "dichit-backend",
"timestamp": "2026-08-05T10:00:00.000Z",
"sequence": 45218,
"correlationId": "req_01HQ5BXWYP1Y5RX0W5X0W5X0W5X",
"causationId": "req_01HQ5BXWYP1Y5RX0W5X0W5X0W5X",
"data": {
"command": "auction.bid.place",
"status": "accepted"
},
"meta": {
"traceId": "trc_01HQ5BXWYP1Y5RX0W5X0W5X0W5X"
}
}
| Field | Type | Description |
|---|---|---|
id | string | Server-generated acknowledgement ID. |
type | string | Always "command.acknowledged". |
version | string | Protocol version. Always "1.0". |
source | string | Emitting service (e.g. dichit-backend). |
timestamp | string | Server ISO-8601 time the ack was emitted. |
sequence | number | Monotonic per-connection sequence (see Sequencing). |
correlationId | string | Echoes the request's id for correlation. |
causationId | string | The command ID that caused this acknowledgement. |
data.command | string | The command type that was accepted. |
data.replayedTerminalStatus | string | Optional completed or failed status for an idempotent retry. |
data.errorCode | string | Present when the replayed terminal status is failed. |
data.message | string | Sanitized public failure message for a failed terminal replay. |
data.status | string | Always "accepted" for acknowledgements. |
meta.traceId | string | Correlation id used in the connection's server logs. |
Important: The acknowledgement does not contain business results (bid IDs, amounts, state versions, etc.). Business outcomes arrive via domain events (e.g., auction.bid.created).
Command Rejections¶
A command that fails validation or authorization returns command.rejected:
{
"id": "rej_01HQ5KYUYP1Y5RX0W5X0W5X0W5X",
"type": "command.rejected",
"version": "1.0",
"source": "dichit-backend",
"timestamp": "2026-08-05T10:00:00.000Z",
"sequence": 45219,
"correlationId": "req_01HQ5KYUYP1Y5RX0W5X0W5X0W5X",
"causationId": "req_01HQ5KYUYP1Y5RX0W5X0W5X0W5X",
"data": {
"command": "auction.bid.place",
"errorCode": "FORBIDDEN",
"message": "You are not authorized to place bids in this auction."
},
"meta": {
"traceId": "trc_01HQ5KYUYP1Y5RX0W5X0W5X0W5X"
}
}
When used: Invalid message format, authentication failure, authorization failure, schema validation failure.
Command Failures¶
A command that passes validation but fails business rules returns command.failed:
{
"id": "fail_01HQ5MZUYP1Y5RX0W5X0W5X0W5X",
"type": "command.failed",
"version": "1.0",
"source": "dichit-backend",
"timestamp": "2026-08-05T10:00:00.100Z",
"sequence": 45220,
"correlationId": "req_01HQ5KYUYP1Y5RX0W5X0W5X0W5X",
"causationId": "req_01HQ5KYUYP1Y5RX0W5X0W5X0W5X",
"data": {
"command": "auction.bid.place",
"errorCode": "BID_TOO_LOW",
"message": "Bid amount must be at least $25,100.",
"details": {
"minimumBid": 25100,
"attemptedBid": 25000
}
},
"meta": {
"traceId": "trc_01HQ5MZUYP1Y5RX0W5X0W5X0W5X"
}
}
When used: Bid too low, auction closed, participant suspended, insufficient funds, duplicate bid, etc.
Legacy Synchronous Responses (Deprecated)¶
Query operations (e.g., auction.subscribe, auction.unsubscribe) still return the legacy ack format with business data:
{
"id": "req_01HQ5BXWYP1Y5RX0W5X0W5X0W5X",
"type": "ack",
"version": "1.0",
"source": "dichit-backend",
"timestamp": "2026-08-05T10:00:00.000Z",
"sequence": 45218,
"data": {
"eventType": "auction.subscribe",
"auctionId": "auc_01HQ5BXWYP1Y5RX0W5X0W5X0W5X",
"cycleId": "cyc_01HQ5BXWYP1Y5RX0W5X0W5X0W5X",
"room": "auction:cyc_01HQ5BXWYP1Y5RX0W5X0W5X0W5X"
},
"meta": {
"traceId": "trc_01HQ5BXWYP1Y5RX0W5X0W5X0W5X"
}
}
This format is deprecated and will be migrated to the async CQRS pattern in a future release.
Errors¶
A failed handler returns an error envelope:
{
"id": "req_01HQ5KYUYP1Y5RX0W5X0W5X0W5X",
"type": "error",
"version": "1.0",
"source": "dichit-backend",
"timestamp": "2026-08-05T10:00:00.000Z",
"sequence": 45219,
"data": {
"code": "FORBIDDEN",
"message": "You are not authorized to perform this WebSocket action."
},
"meta": {
"traceId": "trc_01HQ5KYUYP1Y5RX0W5X0W5X0W5X"
}
}
| Field | Type | Description |
|---|---|---|
id | string | Echoes the failed request's id when the envelope could be parsed. |
type | string | Always "error". |
version | string | Protocol version. Always "1.0". |
source | string | Emitting service (e.g. dichit-backend). |
timestamp | string | Server ISO-8601 time the error was emitted. |
sequence | number | Monotonic per-connection sequence (see Sequencing). |
data.code | string | Stable machine-readable error code (see errors.md). |
data.message | string | Safe, human-readable description. Never contains stack traces. |
meta.traceId | string | Correlation id used in the connection's server logs. |
If a raw frame cannot be parsed as the envelope (e.g. malformed JSON), the server still attempts to extract an id; if none is found, the error envelope carries a server-generated id.
The common message envelope¶
Every client→server message is a JSON object with this envelope:
{
"id": "req_01HQ5BWN5Y1Y5RX0W5X0W5X0W5X",
"type": "auction.bid.place",
"version": "1.0",
"source": "web-client",
"timestamp": "2026-08-05T10:00:00.000Z",
"data": {
"auctionId": "auc_01HQ5BWN5Y1Y5RX0W5X0W5X0W5X",
"amountMinor": "2500000"
},
"meta": {
"traceId": "trc_01HQ5BWN5Y1Y5RX0W5X0W5X0W5X"
}
}
| Field | Required | Type | Description |
|---|---|---|---|
type | Yes | string | Dot-separated event name. 1–128 chars after trimming. Identifies the registered route. |
id | No | string | Client-generated correlation id, echoed in acknowledgements and errors. 1–128 chars. Strongly recommended for debugging and ordering. |
version | No | string | Client protocol version. Informational only. |
source | No | string | Client identifier for debugging (e.g. web-client, mobile-client). Informational only. |
timestamp | No | string | Client ISO-8601 timestamp. Informational only; the server does not rely on it for ordering. |
data | No | any | Event payload, validated by the per-event Zod schema. |
meta | No | object | Reserved extension object. |
meta.traceId | No | string | Client trace id, echoed in the server envelope meta for correlation. 1–128 chars. |
Envelope validation rules:
typemust be a non-empty trimmed string of at most 128 characters.id, when present, must be a trimmed string of 1–128 characters.datais validated against the route's schema; an invaliddatayieldsBAD_REQUEST.- Unknown/extra top-level keys are ignored by the base envelope parser.
- The server never accepts a
sequencefrom the client; the per-connectionsequenceis assigned by the server and included only on server→client frames.
The server event envelope¶
Every server→client message (server push, acknowledgement, or error) uses the same envelope with the addition of the server-assigned sequence:
{
"id": "evt_01HQ5BY3Y7Z2V6Y0W5X0W5X0W5X",
"type": "auction.bid.created",
"version": "1.0",
"source": "auction-service",
"timestamp": "2026-08-05T10:00:00.000Z",
"sequence": 45220,
"data": {
"auctionId": "auc_01HQ5BWNJ5Y1P5RX0W5X0W5X0W5X",
"cycleId": "cyc_01HQ5BWNJ5Y1P5RX0W5X0W5X0W5X",
"stateVersion": 7,
"serverSequence": 12,
"payload": {
"id": "bid_01HQ5BY3Y7Z2V6Y0W5X0W5X0W5X",
"auctionId": "auc_01HQ5BWNJ5Y1P5RX0W5X0W5X0W5X",
"cycleId": "cyc_01HQ5BWNJ5Y1P5RX0W5X0W5X0W5X",
"subscriberProgramId": "enr_01HQ5BY3Y7Z2V6Y0W5X0W5X0W5X",
"subscriberId": "sub_01HQ5BWXD1P5RX0W5X0W5X0W5X",
"bidderName": "Annie Mohan",
"bidderAvatarUrl": null,
"bidderCode": "*********3210",
"bidAmount": "25000.00",
"bidRank": 1,
"isWinningBid": true,
"status": "ACCEPTED",
"createdAt": "2026-08-05T10:00:00.000Z"
}
},
"meta": {
"traceId": "trc_01HQ5BY3Y7Z2V6Y0W5X0W5X0W5X"
}
}
| Field | Type | Description |
|---|---|---|
id | string | Server-generated unique event id (echoes the client id on acks and errors). |
type | string | Event name. "ack" and "error" are reserved meta types. |
version | string | Protocol version. Always "1.0". |
source | string | Emitting service (e.g. auction-service for auction domain events, dichit-backend for the core envelope). |
timestamp | string | ISO-8601 time the event occurred / was emitted. |
sequence | number | Server-assigned monotonic per-connection sequence. |
data | object | Event payload. Acks carry data.eventType plus the handler result; errors carry data.code and data.message. |
meta | object | Reserved extension object. |
meta.traceId | string | Correlation id for the connection's request log. |
For auction domain events, data is structured as { auctionId, cycleId, stateVersion, serverSequence?, payload }. Domain-specific fields such as occurredAt are flattened onto the envelope's top-level timestamp.
Sequencing¶
The server assigns every outbound frame a per-connection monotonic sequence, tracked in Redis and stamped at delivery time. Each connection has its own counter:
- Sequences are strictly increasing per connection and never wrap for practical durations.
- Clients use the envelope
sequenceto detect dropped frames on a single connection: a gap means messages were lost and the client should recover via snapshot + replay. - The envelope
sequenceis not a global event log position and is not shared across connections or devices. - Auction state carries a second, domain-level cursor:
data.serverSequenceis the per-auction position from the persisted activity log. Clients pass the last observeddata.serverSequenceaslastSequencewhen (re)subscribing to fill gaps that happened while disconnected.
Client events (Client → Server)¶
A client event is a command the client sends to the server. It is validated, authorized, rate-limited, and executed. The event pages specify the request schema, response, and required permissions.
| Domain | Event | Permission |
|---|---|---|
| System | system.ping | none |
| Rooms | room.join | room:join |
| Rooms | room.leave | room:leave |
| Auction | auction.subscribe | auction:subscribe |
| Auction | auction.unsubscribe | auction:subscribe |
| Auction | auction.bid.place | auction:bid:place |
| Auction | auction.bid.cancel (planned) | auction:bid:place |
| Auction | auction.status.update | auction:staff |
| Auction | auction.pause | auction:staff |
| Auction | auction.resume | auction:staff |
| Auction | auction.end | auction:staff |
| Auction | auction.bid.mark | auction:staff |
| Auction | auction.winner.declare | auction:staff |
| Auction | auction.winner.record_lot | auction:staff |
| Notification | notification.read (planned) | notification:read |
| Chat | chat.send (planned) | chat:send |
| Chat | chat.typing (planned) | chat:typing |
| Presence | — | — |
Server events (Server → Client)¶
A server event is pushed by the server without a one-to-one client request. It is never acknowledged. Clients must handle them idempotently and be able to recover via snapshot + replay.
| Domain | Event | Note |
|---|---|---|
| System | system.pong | Response to system.ping. |
| Auction | auction.connected | sent after auction.subscribe. |
| Auction | auction.state.snapshot | full state after subscribe. |
| Auction | auction.audience.snapshot | online subscribers after subscribe. |
| Auction | auction.state.changed | Durable lifecycle transition. data.payload.transition identifies the cause and data.payload.state contains complete post-transition lifecycle state. |
| Auction | auction.bid.created, auction.bid.updated, auction.bid.deleted, auction.prebid.created, auction.prebid.deleted, auction.bidder.disqualified | Bid and prebid lifecycle events; auction.prebid.created is durable and staff-only. |
| Auction | auction.winner.changed | Durable staff-only winner selection/disqualification detail. |
| Auction | auction.announcement.created, auction.announcement.updated, auction.announcement.deleted | auction-scoped announcements. |
| Auction | auction.settings.updated | auction schedule/settings changed via program-cycle update. |
| Audience | auction.audience.changed | Aggregate audience counters plus the affected participant in both public and staff-scoped copies. |
Event naming convention¶
- Event types use lowercase dot-separated names:
domain.action, e.g.auction.bid.place,notification.created,auction.audience.changed. - The first segment is the domain (
system,room,auction,notification,chat,winner). Auction sub-concepts use a fixed second segment:auction.bid.*,auction.prebid.*,auction.announcement.*,auction.presence.*,auction.state.*. - The last segment is the action (
join,leave,subscribe,place,update,created,ended). - Domain and action are
lowerCamelCasenouns/verbs; multi-word actions do not use separators (e.g.record_lot,call_startedare legacy underscore compounds retained for compatibility). - Server pushes and client commands share the same naming grammar so they compose in one namespace.
- Client commands tend to use verb actions (
place,update); server pushes tend to use past-tense or state actions (created,ended,joined).
Naming rules for implementers
- Never invent inconsistent casing:
auction.bid.place, notauction.bidPlaceorAuctionBidPlace. - Never reuse a name within a domain for two different shapes.
- Preserve existing names; additive changes only. See versioning.md.
Message routing¶
Each message is routed through a fixed middleware pipeline in this order:
- Parse the raw text frame as JSON.
- Validate the base envelope (
type, optionalid,version,source,timestamp,data,meta). - Resolve the registered route by
type. authenticate— the context must have a user.rate-limit— per-user and per-IP sliding bucket for the event type.validate— parsedataagainst the route's Zod schema.authorize— the connection's permission set must contain all required route permissions.- execute the handler.
- send
ackorerror.
Authorization is message-level: two clients on the same room can observe different routes. See authorization flow and authentication.md.
Related documents¶
- README.md — overview and index.
- connection.md — transport and heartbeat details.
- events/auction.md — auction request and server events.
- errors.md — the
errorenvelope codes.