# WebSocket API (real-time events) One channel, one direction: `/ws/sync` is a **server → client push feed** for sync and scan events. It is not a command channel — clients do all reading and writing through the REST API, and the server logs (but does not act on) anything a client sends. The one client obligation is the standard WebSocket pong, below. ## Connecting `WS /ws/sync` — two accepted identities: | Method | How | | --- | --- | | User (JWT) | `?token=` query parameter | | Device (KOReader, Kobo) | `Authorization: Bearer ` header | No token → `401`. On connect the server immediately sends one **`initial_state`** message: the connecting identity's media items for sync, read under that identity's own data visibility. ## Message envelope Every server message is a JSON object: ```json { "type": "progress_update", "timestamp": "2026-11-02T12:00:00Z", "data": { "...": "..." }, "source_device": { "id": "…", "name": "Living-room Kobo", "type": "kobo" } } ``` `source_device` is present on events that originate from a device (progress and annotation updates), so clients can ignore their own echo. ## Message catalog All ten types defined by `internal/sync/websocket.go`: | Type | Emitted | `data` carries | | --- | --- | --- | | `initial_state` | Once, on connect | The connecting user's media items for sync | | `progress_update` | On any device's progress save | `book_id`, `percentage` | | `annotation_update` | On annotation changes (highlights, notes, bookmarks) | `book_id`, `annotation_type`, payload | | `conflict` | When a sync conflict is detected | `book_id`, `notification_type`, `conflict_id` | | `scan_progress` | During scan jobs | Job progress for the admin scan panel | | `scan_complete` | When a scan job finishes | Job summary | | `scan_error` | When a scan job fails | Job error | | `scan_cancelled` | When a scan job is cancelled | Job identity | | `collection_updated` | When books are added to / removed from one of *your* collections | `collection_id`, `action` (`books_added` / `book_removed` / `books_removed`), affected book ids | | `heartbeat` | *Defined, not currently emitted* | — | | `sync_complete` | *Defined, not currently emitted* | — | (`collection_updated` is currently the only message type defined as a raw string in the collections handler rather than in the sync package's constants block.) ## Keepalive The server sends a WebSocket **ping frame every 30 seconds** and expects the standard pong within a **90-second read deadline**. Browsers and every mainstream WebSocket library answer pings automatically — there is nothing to implement. A connection that stops answering is removed. ## Fan-out and backpressure — read this before building a client - **Every event carries an explicit audience.** Progress, annotation, conflict, and collection events are delivered only to the acting user's connections — the ConnectionManager has no accidental fan-out, and the compiler forces every emitter to name its audience (2026-11-02, after cross-user delivery was found and fixed). The one sanctioned instance-wide fan-out is **scan events** (`BroadcastToAll`): they carry job ids and counts, never user data, and the sitewide scan indicator in every browser runs on them. - Every connection has a bounded send queue. **If a client falls behind, messages for that connection are skipped, not queued.** Clients must therefore treat the socket as a *hint* — on reconnect (or any gap), refetch real state over REST. The fresh `initial_state` on reconnect is designed to be that gap-healer.