← Cove Labs
Overview
HZMediaP is a control-plane-only protocol. It handles authentication,
search, session negotiation, and playlist management. Media bytes (HLS segments) never
flow through the WebSocket — they are fetched directly from the CDN by the client after
receiving a signed URL via STREAM_READY.
Connect to the server at wss://<host>/ws. The server speaks
HZMediaP 1.0 over a standard WebSocket connection using text frames (UTF-8 JSON).
All messages use the same envelope structure regardless of direction.
Control plane only. HZMediaP manages sessions and delivers signed CDN URLs. Your player fetches HLS directly from the CDN — do not attempt to pipe media through the WebSocket.
Message types at a glance
| Type | Dir | Description |
| HANDSHAKE | C → S | Open connection, identify platform and capabilities |
| HANDSHAKE_ACK | S → C | Session nonce, heartbeat config, server version |
| AUTH | C → S | Authenticate with Google ID token + nonce |
| AUTH_OK | S → C | JWT access token + user profile |
| AUTH_REFRESH | S → C | Server-pushed updated access token |
| SEARCH | C → S | Search or browse the content library |
| SEARCH_RESULT | S → C | Paginated result set with relevance scores |
| STREAM_INIT | C → S | Request to begin streaming a content item |
| CONTENT_META | S → C | Full metadata for the requested content item |
| STREAM_READY | S → C | Signed HLS URL — start playback |
| PROGRESS_REPORT | C → S | Playback position, quality, buffer telemetry |
| PLAYLIST_REQUEST | C → S | CRUD operations on the user's playlists |
| PLAYLIST_RESPONSE | S → C | Playlist data in response to a request |
| HEARTBEAT | C → S | Keepalive ping (must be sent every 30 s) |
| HEARTBEAT_ACK | S → C | Keepalive acknowledgement with server timestamp |
| ERROR | S → C | Error with code, domain, message, and retryable flag |
Envelope format
Every message — in both directions — uses the same JSON envelope. The payload field varies by type.
EnvelopeJSON
{
"hzmp": "1.0", // protocol version — must be "1.0"
"msgid": "<uuid-v4>", // unique message ID
"type": "STREAM_INIT",// message type constant
"ts": 1712000000000,// sender Unix timestamp (ms)
"ref": null, // msgid of the message being replied to, or null
"payload": { /* type-specific */ }
}
| Field | Type | | Description |
| hzmp | string | required | Protocol version. Must be "1.0". The server checks the major version and rejects mismatches with error 1004. |
| msgid | string | required | UUID v4 uniquely identifying this message. Used for correlating responses via ref. |
| type | string | required | One of the message type constants listed in the overview. |
| ts | number | required | Unix timestamp in milliseconds at time of sending. |
| ref | string|null | required | When the server sends a reply, ref is the msgid of the request. Set to null for unsolicited messages. |
| payload | object | required | Message-specific payload object. Never null — use {} for messages with no fields. |
Max message size is 256 KB. Messages exceeding this limit are rejected with error 1003 and the connection is closed.
Connection flow
Every session follows this sequence. No other messages are accepted before AUTH_OK.
client →
HANDSHAKE
identify client name, platform, version, capabilities
server →
HANDSHAKE_ACK
session_nonce, heartbeat_interval_ms, max_message_size
client →
AUTH
Google ID token + nonce from HANDSHAKE_ACK
server →
AUTH_OK
access_token (JWT), user profile, expires_in
client →
STREAM_INIT / SEARCH / PLAYLIST_REQUEST
session is ready
client →
HEARTBEAT
every heartbeat_interval_ms (default 30 s)
server →
HEARTBEAT_ACK
missed 2× intervals → server closes connection
Max 5 concurrent sessions per user. Exceeding this limit returns error 4005. Previous sessions remain open — the client must explicitly disconnect old ones or let them expire via missed heartbeats.
Quick start
A minimal JavaScript client that authenticates, searches for content, then initiates streaming. Swap getGoogleIdToken() with your auth provider's SDK call.
Minimal clientJavaScript
const ws = new WebSocket('wss://stream.covelabs.io/ws');
let sessionNonce, accessToken;
function send(type, payload, ref = null) {
ws.send(JSON.stringify({
hzmp: '1.0',
msgid: crypto.randomUUID(),
type, ts: Date.now(), ref, payload,
}));
}
ws.onopen = () => {
send('HANDSHAKE', {
client_name: 'my-app',
client_version: '1.0.0',
platform: 'web',
capabilities: ['hls'],
});
};
ws.onmessage = async ({ data }) => {
const msg = JSON.parse(data);
switch (msg.type) {
case 'HANDSHAKE_ACK': {
sessionNonce = msg.payload.session_nonce;
const idToken = await getGoogleIdToken();
send('AUTH', {
strategy: 'firebase',
id_token: idToken,
nonce: sessionNonce,
});
break;
}
case 'AUTH_OK': {
accessToken = msg.payload.access_token;
// Session ready — send a search
send('SEARCH', { query: 'late night jazz', mode: 'hybrid' });
// Start heartbeat loop
setInterval(() => send('HEARTBEAT', {}),
msg.payload.heartbeat_interval_ms);
break;
}
case 'SEARCH_RESULT': {
const first = msg.payload.items[0];
if (first) send('STREAM_INIT', {
content_id: first.content_id,
resume: true,
});
break;
}
case 'STREAM_READY': {
// Pass hls_master_url to your player (e.g. hls.js)
player.loadSource(msg.payload.hls_master_url);
break;
}
case 'ERROR': {
console.error(msg.payload.code, msg.payload.message);
if (msg.payload.retryable) /* retry with backoff */;
break;
}
}
};
Authentication
HANDSHAKE
The first message sent by the client on every new connection.
| Field | Type | | Description |
| client_name | string | required | Human-readable client identifier, e.g. "my-ios-app" |
| client_version | string | required | Semver string of your client build |
| platform | string | required | One of "ios", "android", "web", "desktop" |
| os_version | string | optional | OS version string, e.g. "17.4" |
| capabilities | string[] | required | Supported features. Include "hls" for HLS playback. |
| locale | string | optional | BCP 47 locale, e.g. "en-US" |
| timezone | string | optional | IANA timezone, e.g. "America/New_York" |
HANDSHAKE_ACK
| Field | Type | Description |
| server_version | string | Server software version |
| session_nonce | string | Base64-encoded 32-byte random nonce — include in AUTH payload |
| auth_required | boolean | Always true in the current server |
| heartbeat_interval_ms | number | How often to send HEARTBEAT (currently 30000) |
| max_message_size | number | Maximum message size in bytes (currently 262144) |
AUTH
| Field | Type | | Description |
| strategy | string | required | Must be "firebase" |
| id_token | string | required | Google ID token from Firebase Auth SDK |
| nonce | string | required | The session_nonce from HANDSHAKE_ACK |
AUTH_OK
| Field | Type | Description |
| user_id | string | Unique user identifier |
| display_name | string | User's display name from Google profile |
| avatar_url | string|null | Profile photo URL |
| access_token | string | JWT — include as Bearer token in any REST requests |
| expires_in | number | Token validity in seconds |
| refresh_before | number | Unix ms — server pushes AUTH_REFRESH before this time |
Search
Cove's search combines PostgreSQL full-text search with AI-powered vector search (OpenAI text-embedding-3-small), merged using Reciprocal Rank Fusion. The default mode is hybrid.
SEARCH payload
| Field | Type | | Description |
| query | string | required | Search query. Max 512 characters. Empty string with mode "browse" returns all content alphabetically. |
| mode | string | optional | "fulltext" | "vector" | "hybrid" | "browse". Default: "hybrid" |
| filters | object | optional | See filter fields below |
| limit | number | optional | Results per page. Default 20, max 100 |
| offset | number | optional | Pagination offset. Default 0 |
| vector_weight | number | optional | 0.0–1.0, hybrid mode only. Default 0.6 (60% vector, 40% fulltext) |
Filter fields
| Field | Type | Description |
| content_type | string[] | Subset of ["audio","video","live"] |
| tags | string[] | Content must match all provided tags |
| duration_min_s | number | Minimum duration in seconds |
| duration_max_s | number | Maximum duration in seconds |
| year_min | number | Earliest release year |
| year_max | number | Latest release year |
| language | string | BCP 47 language code |
Example — hybrid search with filtersJSON
{
"hzmp": "1.0", "msgid": "…", "type": "SEARCH", "ts": …, "ref": null,
"payload": {
"query": "late night jazz",
"mode": "hybrid",
"vector_weight": 0.7,
"filters": {
"content_type": ["audio"],
"duration_min_s": 120,
"tags": ["instrumental"]
},
"limit": 20,
"offset": 0
}
}
SEARCH_RESULT payload
| Field | Type | Description |
| query | string | The original query string |
| total | number | Total matching results (for pagination) |
| offset | number | Current page offset |
| items | object[] | Array of result items — see fields below |
Result item fields
| Field | Type | Description |
| content_id | string | Use in STREAM_INIT |
| title | string | |
| artist | string|null | |
| duration_s | number|null | Duration in seconds |
| thumbnail_url | string|null | Thumbnail image URL |
| content_type | string | "audio", "video", or "live" |
| tags | string[] | |
| year | number|null | Release year |
| score | number | RRF relevance score (higher = more relevant) |
Streaming
Send STREAM_INIT to begin a stream. The server responds with CONTENT_META then STREAM_READY. Feed hls_master_url to your HLS player — no media bytes come through the WebSocket.
STREAM_INIT payload
| Field | Type | | Description |
| content_id | string | required | ID from a SEARCH_RESULT item |
| preferred_quality | string | optional | e.g. "1080p", "720p", "360p", "320k". Default: "auto" |
| start_position_s | number | optional | Start offset in seconds. Default 0 |
| resume | boolean | optional | If true, server uses last saved position for this user + content pair |
| playlist_id | string|null | optional | Associate this stream session with a playlist context |
STREAM_READY payload
| Field | Type | Description |
| content_id | string | |
| session_id | string | Use in PROGRESS_REPORT |
| hls_master_url | string | Signed HLS master playlist URL. Load this into your player. |
| start_position_s | number | Where to start playback (respects resume) |
| token_expires_at | number | Unix ms — URL expires at this time. Listen for error 6004 and re-init. |
CDN URLs expire after 60 minutes. The server caches signed URLs for 55 minutes. If playback is still active near expiry, you will receive error 6004 TOKEN_REFRESH_NEEDED — send a new STREAM_INIT with the same content_id to get a fresh URL.
Progress reporting
Send PROGRESS_REPORT periodically during playback. The server records position for resume support and updates play history. No response is sent. Recommended interval: every 10–15 seconds.
| Field | Type | | Description |
| session_id | string | required | From STREAM_READY |
| content_id | string | required | |
| position_s | number | required | Current playback position in seconds |
| duration_s | number | required | Total content duration in seconds |
| percent | number | required | 0–100 |
| buffered_ahead_s | number | optional | Seconds of buffer ahead of playhead |
| quality | string | optional | Active HLS rendition, e.g. "720p" |
| bandwidth_bps | number | optional | Estimated available bandwidth |
| stall_count | number | optional | Number of buffer stalls since last report |
| stall_duration_ms | number | optional | Total stall time since last report in ms |
Playlists
All playlist operations go through a single PLAYLIST_REQUEST message, differentiated by the action field. Max 50 playlists per user.
| Action | Required fields | Description |
| list | — | Return all playlists owned by the authenticated user |
| get | playlist_id | Return a playlist's full item list, ordered by position |
| create | name | Create a new playlist. Optional: description, is_public |
| add_item | playlist_id, content_id | Append an item. Returns error 7002 if already present |
| remove_item | playlist_id, content_id | Remove an item. Returns error 7003 if not found |
| reorder | playlist_id, content_id, position | Move an item to the given position (1-indexed) |
| delete | playlist_id | Permanently delete a playlist and all its items |
Example — create then add an itemJSON
// Create
{ "type": "PLAYLIST_REQUEST", "payload": {
"action": "create", "name": "Evening Wind-Down"
} }
// Server responds with new playlist_id in PLAYLIST_RESPONSE
// Add item
{ "type": "PLAYLIST_REQUEST", "payload": {
"action": "add_item",
"playlist_id": "pl_abc123",
"content_id": "ct_xyz789"
} }
Heartbeat
The server closes the connection if no HEARTBEAT is received within two heartbeat intervals (default: 60 s). Send HEARTBEAT at the heartbeat_interval_ms cadence provided in HANDSHAKE_ACK.
HEARTBEATJSON
{ "type": "HEARTBEAT", "payload": { "session_id": null } }
// Server responds:
{ "type": "HEARTBEAT_ACK", "payload": { "server_ts": 1712000030000 } }
Error codes
The server sends an ERROR message whenever an operation fails. Check payload.retryable — if true, retry with exponential backoff. If false, the error requires client action to resolve.
Protocol (1xxx)
| Code | Name | Message | Retryable |
| 1000 | UNKNOWN | Unknown error | yes |
| 1001 | INVALID_ENVELOPE | Malformed JSON or missing required envelope fields | no |
| 1002 | UNKNOWN_MESSAGE_TYPE | Unrecognised type field | no |
| 1003 | MESSAGE_TOO_LARGE | Message exceeds 256 KB | no |
| 1004 | VERSION_MISMATCH | Major version not supported | no |
Auth (4xxx)
| Code | Name | Message | Retryable |
| 4000 | AUTH_REQUIRED | Attempted operation before AUTH_OK | no |
| 4001 | TOKEN_EXPIRED | JWT expired — re-authenticate | yes |
| 4002 | TOKEN_INVALID | JWT signature invalid | no |
| 4003 | OAUTH_REJECTED | Google ID token rejected | no |
| 4004 | ACCOUNT_SUSPENDED | Account suspended | no |
| 4005 | SESSION_LIMIT_EXCEEDED | Max 5 concurrent sessions per user | yes |
Search (5xxx)
| Code | Name | Message | Retryable |
| 5000 | SEARCH_FAILED | Internal search error | yes |
| 5001 | QUERY_TOO_LONG | Query exceeds 512 characters | no |
| 5002 | INVALID_FILTER | Unrecognised filter field or value | no |
Stream (6xxx)
| Code | Name | Message | Retryable |
| 6000 | CONTENT_NOT_FOUND | No content with the given ID | no |
| 6001 | NOT_AUTHORIZED | User does not have access to this content | no |
| 6002 | CONTENT_UNAVAILABLE | Content exists but cannot be streamed | yes |
| 6003 | SESSION_NOT_FOUND | Stream session ID not in Redis | yes |
| 6004 | TOKEN_REFRESH_NEEDED | CDN signed URL expiring — send new STREAM_INIT | yes |
| 6005 | TRANSCODING_IN_PROGRESS | Content not yet ready — poll or wait | yes |
Playlist (7xxx)
| Code | Name | Message | Retryable |
| 7000 | PLAYLIST_NOT_FOUND | No playlist with the given ID | no |
| 7001 | PLAYLIST_LIMIT_EXCEEDED | User has reached the 50-playlist limit | no |
| 7002 | ITEM_ALREADY_IN_PLAYLIST | Duplicate item — already in this playlist | no |
| 7003 | ITEM_NOT_IN_PLAYLIST | Item not found in this playlist | no |
Server (9xxx)
| Code | Name | Message | Retryable |
| 9000 | INTERNAL_ERROR | Unexpected server error | yes |
| 9001 | RATE_LIMITED | Too many messages — slow down. Check retry_after_ms | yes |
| 9002 | SERVICE_UNAVAILABLE | Server temporarily unavailable | yes |
Limits
| Limit | Value | Error on breach |
| Max message size | 256 KB | 1003 |
| Max query length | 512 chars | 5001 |
| Max search results per page | 100 | Clamped silently |
| Max concurrent sessions per user | 5 | 4005 |
| Max playlists per user | 50 | 7001 |
| Rate limit | 50 msg/s | 9001 with retry_after_ms |
| Heartbeat timeout | 2 × interval (60 s) | Connection closed |
| CDN URL TTL | 60 min | 6004 at 55 min |