Skip to Content
WebSocket API

WebSocket API

Real-time bidirectional communication for message delivery, presence updates, and live notifications.

Overview

The Zentalk WebSocket API provides persistent connections for real-time communication. Unlike the REST API which requires polling, WebSocket connections enable instant message delivery and presence updates with minimal latency.

Key Features

FeatureDescription
Persistent ConnectionSingle long-lived connection for all real-time events
BidirectionalBoth client and server can initiate messages
Low LatencySub-100ms message delivery typical
Automatic ReconnectionClient SDK handles reconnection with backoff
MultiplexedSingle connection handles all conversations

When to Use WebSocket vs REST

Use CaseRecommended
Receiving messages in real-timeWebSocket
Sending messagesWebSocket
Fetching message historyREST API
Uploading mediaREST API
Managing keysREST API
Presence and typing indicatorsWebSocket

Connection Lifecycle

Connection URL

wss://relay.zentalk.network/ws/v1
ParameterRequiredDescription
tokenYesJWT authentication token
device_idYesUnique device identifier
protocol_versionNoProtocol version (default: 1)
resume_tokenNoToken to resume previous session

Example connection URL:

wss://relay.zentalk.network/ws/v1?token=<jwt>&device_id=<uuid>&protocol_version=1

Connection States

┌──────────────┐ │ DISCONNECTED │ └──────┬───────┘ │ connect() ┌──────────────┐ │ CONNECTING │───────────────────────────────┐ └──────┬───────┘ │ │ WebSocket open │ timeout/error ▼ ▼ ┌──────────────┐ ┌──────────────┐ │AUTHENTICATING│───────────────────────→│ FAILED │ └──────┬───────┘ auth failed └──────────────┘ │ auth success ▲ ▼ │ ┌──────────────┐ │ │ CONNECTED │───────────────────────────────┘ └──────┬───────┘ connection lost │ close() ┌──────────────┐ │ CLOSING │ └──────┬───────┘ │ closed ┌──────────────┐ │ DISCONNECTED │ └──────────────┘
StateDescriptionDuration
DISCONNECTEDNo active connectionUntil connect() called
CONNECTINGTCP/TLS handshake in progressTypically < 1 second
AUTHENTICATINGChallenge-response authentication< 5 seconds
CONNECTEDFully authenticated and readyIndefinite
CLOSINGGraceful shutdown in progress< 2 seconds
FAILEDConnection attempt failedUntil retry

Authentication Handshake

After WebSocket connection is established, authentication occurs:

Client Server │ │ ├──────── WebSocket Connect ─────────────→│ │ │ │←─────── AUTH_CHALLENGE ─────────────────┤ │ { challenge: <random_32_bytes>, │ │ server_time: <timestamp> } │ │ │ ├──────── AUTH_RESPONSE ─────────────────→│ │ { signature: Sign(IK, challenge), │ identity_key: <public_key>, │ │ device_id: <uuid>, │ │ timestamp: <client_time> } │ │ │ │←─────── AUTH_SUCCESS ───────────────────┤ │ { session_token: <token>, │ │ expires_at: <timestamp>, │ │ server_time: <timestamp> } │ │ │ │ Connection Ready │ └─────────────────────────────────────────┘

Heartbeat Mechanism

The connection uses ping-pong frames for keepalive:

ParameterValue
Ping interval30 seconds
Pong timeout10 seconds
Max missed pings3
Total timeout120 seconds
Client Server │ │ │←─────── PING ───────────────────────────┤ │ { timestamp: <server_time> } │ │ │ ├──────── PONG ─────────────────────────→ │ │ { timestamp: <server_time>, │ │ client_time: <client_time> } │ │ │ │ (repeated every 30 seconds) │ └─────────────────────────────────────────┘

Latency Measurement:

The server calculates round-trip time from ping-pong exchange. Clients can use this for connection quality indicators:

RTTQuality
< 100msExcellent
100-300msGood
300-1000msFair
> 1000msPoor

Authentication Protocol

Challenge-Response Flow

Authentication uses Ed25519 signatures to prove identity key ownership without transmitting private keys.

Step 1: Server Challenge

{ "type": "AUTH_CHALLENGE", "challenge": "<base64_32_random_bytes>", "server_time": 1704067200000, "nonce": "<base64_16_bytes>" }

Step 2: Client Response

signed_data = challenge || server_time || device_id || client_time { "type": "AUTH_RESPONSE", "identity_key": "<base64_ed25519_public_key>", "device_id": "<uuid>", "signature": "<base64_ed25519_signature>", "client_time": 1704067200500 }

Step 3: Server Verification

1. Verify signature using identity_key 2. Check challenge matches issued challenge 3. Validate timestamp within 5 minute window 4. Look up identity_key in user registry 5. Issue session token if valid

JWT Token Format

Session tokens are JWTs with the following structure:

Header:

{ "alg": "EdDSA", "typ": "JWT" }

Claims:

ClaimTypeDescription
substringWallet address (0x…)
iatnumberIssued at (Unix timestamp)
expnumberExpiration (Unix timestamp)
device_idstringDevice UUID
session_idstringUnique session identifier
capabilitiesarrayGranted capabilities

Example Claims:

{ "sub": "0x1234567890abcdef1234567890abcdef12345678", "iat": 1704067200, "exp": 1704153600, "device_id": "550e8400-e29b-41d4-a716-446655440000", "session_id": "sess_abc123def456", "capabilities": ["send_message", "receive_message", "presence"] }

Token Refresh

Tokens expire after 24 hours. Refresh must occur before expiration:

ParameterValue
Token lifetime24 hours
Refresh windowLast 2 hours before expiry
Grace period5 minutes after expiry
Max refresh attempts3 per hour
Client Server │ │ ├──────── TOKEN_REFRESH ─────────────────→│ │ { session_token: <old_token>, │ │ signature: Sign(IK, token) } │ │ │ │←─────── TOKEN_REFRESHED ────────────────┤ │ { session_token: <new_token>, │ │ expires_at: <timestamp> } │ └─────────────────────────────────────────┘

Multi-Device Connection Handling

Each device maintains an independent WebSocket connection. The server manages device multiplexing:

BehaviorDescription
Connection limitMaximum 5 concurrent devices per user
Message fanoutMessages delivered to all connected devices
Presence aggregationUser shows online if any device connected
Device priorityMost recently active device for notifications

Device Registration:

FieldDescription
device_idUUID, generated once per device install
device_nameUser-friendly name (e.g., “iPhone 15”)
device_typemobile, desktop, tablet, web
push_tokenFCM/APNs token for offline notifications

Message Types

Frame Format

All WebSocket messages use JSON text frames with a consistent envelope:

{ "type": "<MESSAGE_TYPE>", "id": "<unique_message_id>", "timestamp": <unix_ms>, "payload": { ... } }
FieldTypeRequiredDescription
typestringYesMessage type identifier
idstringYesUnique message ID (UUID)
timestampnumberYesUnix timestamp in milliseconds
payloadobjectYesType-specific payload

Inbound Message Types (Server to Client)

TypeDescriptionPayload Fields
AUTH_CHALLENGEAuthentication challengechallenge, server_time, nonce
AUTH_SUCCESSAuthentication succeededsession_token, expires_at, server_time
AUTH_FAILEDAuthentication failederror_code, message
MESSAGE_NEWNew encrypted messagefrom, ciphertext, nonce, timestamp
MESSAGE_ACKServer received messagemessage_id, server_timestamp
MESSAGE_DELIVEREDMessage delivered to recipientmessage_id, device_id, timestamp
MESSAGE_READMessage read by recipientmessage_id, timestamp
TYPING_STARTPeer started typingconversation_id, user_id
TYPING_STOPPeer stopped typingconversation_id, user_id
PRESENCE_UPDATEUser presence changeduser_id, status, last_seen
KEY_CHANGEPeer’s identity key changeduser_id, new_key, reason
PINGKeepalive pingtimestamp
ERRORServer errorerror_code, message, details
SYNC_REQUIREDClient should fetch updatesreason, since_timestamp

Outbound Message Types (Client to Server)

TypeDescriptionPayload Fields
AUTH_RESPONSEAuthentication responseidentity_key, signature, device_id, client_time
MESSAGE_SENDSend encrypted messageto, ciphertext, nonce, message_type
MESSAGE_RECEIVEDConfirm message receiptmessage_id
MESSAGE_READMark message as readmessage_id
TYPING_STARTStarted typingconversation_id
TYPING_STOPStopped typingconversation_id
PRESENCE_SETSet own presencestatus
SUBSCRIBESubscribe to updatestopics
UNSUBSCRIBEUnsubscribe from updatestopics
PONGKeepalive responsetimestamp, client_time
TOKEN_REFRESHRequest token refreshsession_token, signature

Binary vs Text Frames

Frame TypeUsage
TextAll control messages, presence, typing, ACKs
BinaryLarge encrypted payloads (> 16KB)

Binary frame format for large messages:

┌────────────────────────────────────────┐ │ Message Type (1 byte) │ ├────────────────────────────────────────┤ │ Message ID (16 bytes, UUID) │ ├────────────────────────────────────────┤ │ Timestamp (8 bytes, big-endian) │ ├────────────────────────────────────────┤ │ Payload Length (4 bytes, big-endian) │ ├────────────────────────────────────────┤ │ Payload (variable) │ └────────────────────────────────────────┘

Real-Time Events

New Message Notification

When a new message arrives for the client:

{ "type": "MESSAGE_NEW", "id": "msg_abc123def456", "timestamp": 1704067200000, "payload": { "from": "0x9876543210fedcba9876543210fedcba98765432", "conversation_id": "conv_xyz789", "ciphertext": "<base64_encrypted_message>", "nonce": "<base64_12_bytes>", "message_type": "text", "x3dh_header": { "identity_key": "<base64_key>", "ephemeral_key": "<base64_key>", "one_time_key_id": 42 } } }
FieldDescription
fromSender’s wallet address
conversation_idConversation identifier
ciphertextE2E encrypted message content
nonceEncryption nonce
message_typetext, image, file, voice, location
x3dh_headerPresent only for session-initiating messages

Typing Indicators

Typing indicators show when a peer is composing a message:

Start Typing (outbound):

{ "type": "TYPING_START", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "conversation_id": "conv_xyz789" } }

Typing Update (inbound):

{ "type": "TYPING_START", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "conversation_id": "conv_xyz789", "user_id": "0x9876543210fedcba9876543210fedcba98765432" } }
BehaviorValue
Typing timeout5 seconds (auto-stop if no update)
Throttle interval3 seconds between updates
Display durationUntil TYPING_STOP or timeout

Presence Updates

Presence indicates user online/offline status:

Status Values:

StatusDescription
onlineUser has active connection
awayConnected but inactive > 5 minutes
offlineNo active connections
invisibleOnline but appearing offline (privacy)

Presence Event:

{ "type": "PRESENCE_UPDATE", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "user_id": "0x9876543210fedcba9876543210fedcba98765432", "status": "online", "last_seen": 1704067200000, "device_count": 2 } }

Presence Privacy:

SettingBehavior
Share presenceContacts see real status
Hide presenceAlways appear offline
Last seenShow/hide last active timestamp
Typing indicatorsCan be disabled per-contact

Read Receipts

Read receipts confirm message viewing:

Send Read Receipt:

{ "type": "MESSAGE_READ", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "message_id": "msg_abc123def456" } }

Receive Read Receipt:

{ "type": "MESSAGE_READ", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "message_id": "msg_abc123def456", "read_by": "0x9876543210fedcba9876543210fedcba98765432", "timestamp": 1704067205000 } }
Privacy OptionBehavior
Send read receiptsON: Sender sees when you read
Send read receiptsOFF: No read receipts sent

Key Change Notifications

When a contact’s identity key changes:

{ "type": "KEY_CHANGE", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "user_id": "0x9876543210fedcba9876543210fedcba98765432", "old_key_fingerprint": "37291 84756 19283...", "new_key_fingerprint": "94857 29183 74829...", "reason": "device_added", "verified": false } }
ReasonDescriptionRisk Level
device_addedNew device registeredLow
key_rotationScheduled key rotationLow
reinstallApp reinstalledMedium
unknownReason not providedHigh

Message Delivery Flow

Send Message Flow

Client A Server Client B │ │ │ ├── MESSAGE_SEND ───────→│ │ │ (encrypted payload) │ │ │ │ │ │←── MESSAGE_ACK ────────┤ (queued for B) │ │ (server_timestamp) │ │ │ │ │ │ ├──── MESSAGE_NEW ──────→│ │ │ │ │ │←── MESSAGE_RECEIVED ───┤ │ │ │ │←── MESSAGE_DELIVERED ──┤ │ │ │ │ │ │←── MESSAGE_READ ───────┤ │ │ │ │←── MESSAGE_READ ───────┤ │ └────────────────────────┴────────────────────────┘

Acknowledgment Stages

StageEventMeaning
1SentClient transmitted to server
2ACKServer received and stored
3DeliveredRecipient device received
4ReadRecipient viewed message

Server Acknowledgment

After receiving a message, server responds immediately:

{ "type": "MESSAGE_ACK", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "message_id": "msg_abc123def456", "server_timestamp": 1704067200100, "status": "queued", "recipient_devices": 2 } }

Delivery Confirmation

When recipient receives the message:

{ "type": "MESSAGE_DELIVERED", "id": "<uuid>", "timestamp": 1704067200500, "payload": { "message_id": "msg_abc123def456", "device_id": "550e8400-e29b-41d4-a716-446655440000", "timestamp": 1704067200500 } }

Offline Message Queuing

When recipient is offline, messages are queued:

ParameterValue
Max queue size1000 messages per device
Max retention30 days
PriorityNewer messages prioritized
Overflow behaviorOldest messages dropped

Queue Processing on Connect:

1. Client connects and authenticates 2. Server sends SYNC_REQUIRED with pending count 3. Client requests queued messages via REST API 4. Messages delivered in chronological order 5. Push notifications cleared after delivery

Queue Status:

{ "type": "SYNC_REQUIRED", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "reason": "offline_messages", "pending_count": 47, "oldest_timestamp": 1704000000000, "newest_timestamp": 1704067199000 } }

Error Handling

WebSocket Close Codes

CodeNameDescriptionReconnect?
1000NormalClean close by client or serverOptional
1001Going AwayServer shutting downYes
1002Protocol ErrorInvalid frame receivedYes
1003UnsupportedReceived unsupported data typeYes
1006AbnormalConnection lost unexpectedlyYes
1008Policy ViolationMessage violated policyNo
1009Too LargeMessage exceeded size limitNo
1011Server ErrorUnexpected server errorYes
4000Auth FailedAuthentication failedNo
4001Token ExpiredSession token expiredRefresh token
4002Rate LimitedToo many requestsAfter cooldown
4003BannedAccount suspendedNo
4004Device LimitToo many devicesRemove device

Error Message Format

{ "type": "ERROR", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "error_code": "RATE_LIMITED", "message": "Too many messages sent", "details": { "limit": 100, "window": 60, "retry_after": 45 }, "request_id": "req_xyz789" } }

Error Codes

Error CodeHTTP EquivDescription
INVALID_MESSAGE400Malformed message format
UNAUTHORIZED401Invalid or missing authentication
FORBIDDEN403Action not permitted
NOT_FOUND404Resource not found
RATE_LIMITED429Rate limit exceeded
INTERNAL_ERROR500Server error
SERVICE_UNAVAILABLE503Server overloaded

Reconnection Strategy

Implement exponential backoff for reconnection:

attempt = 0 max_attempts = 10 base_delay = 1000ms while not connected and attempt < max_attempts: delay = min(30000, base_delay * (2 ^ attempt)) jitter = random(0, delay * 0.1) wait(delay + jitter) try: connect() attempt = 0 catch: attempt = attempt + 1
AttemptDelayWith Jitter (max)
11s1.1s
22s2.2s
34s4.4s
48s8.8s
516s17.6s
6+30s33s

Message Retry on Disconnect

When connection drops during message send:

1. MESSAGE_SEND transmitted 2. Connection lost before MESSAGE_ACK 3. Client stores message in pending queue 4. Reconnection successful 5. Resend all pending messages with original IDs 6. Server deduplicates by message ID 7. Client receives MESSAGE_ACK for each

Pending Message Storage:

FieldDescription
message_idOriginal message UUID
payloadComplete message payload
first_attemptTimestamp of first send attempt
retry_countNumber of retry attempts
max_retriesMaximum attempts (default: 5)

Server-Side Error Handling

When server encounters an error processing a request:

{ "type": "ERROR", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "error_code": "RECIPIENT_NOT_FOUND", "message": "Recipient address not registered", "request_id": "req_abc123", "original_type": "MESSAGE_SEND" } }

Handling Specific Errors:

ErrorClient Action
RECIPIENT_NOT_FOUNDShow error to user, don’t retry
RATE_LIMITEDWait retry_after seconds
INVALID_MESSAGELog error, don’t retry
INTERNAL_ERRORRetry with backoff
KEY_MISMATCHFetch new key bundle, retry

Rate Limiting

Connection Limits

LimitValueScope
Max connections per user5All devices combined
Max connections per IP10Shared networks
Connection rate10/minutePer device
Auth attempts5/minutePer IP

Message Rate Limits

OperationLimitWindowBurst
Send message6060 seconds10
Typing indicator13 seconds1
Presence update160 seconds1
Read receipt10060 seconds20
Subscribe1060 seconds5

Typing Indicator Throttling

To prevent abuse, typing indicators are throttled:

Client Server │ │ ├── TYPING_START ────────────────────────→│ Accepted │ (t = 0s) │ │ │ ├── TYPING_START ────────────────────────→│ Ignored (too soon) │ (t = 1s) │ │ │ ├── TYPING_START ────────────────────────→│ Accepted │ (t = 3s) │ │ │ │ (user stops typing) │ │ │ ├── TYPING_STOP ─────────────────────────→│ Accepted └─────────────────────────────────────────┘

Backpressure Handling

When rate limited, the server sends:

{ "type": "ERROR", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "error_code": "RATE_LIMITED", "message": "Message rate limit exceeded", "details": { "limit": 60, "window_seconds": 60, "current_count": 60, "retry_after": 15, "reset_at": 1704067215000 } } }

Client Backpressure Strategy:

if rate_limited: pause_sending() queue_outgoing_messages() wait(retry_after) resume_sending() drain_queue_with_pacing()
Queue BehaviorDescription
Max queue size100 messages
Drain rate1 message per 100ms
PriorityNewer messages first
OverflowReject with error to user

Security Considerations

TLS Requirements

RequirementValue
Minimum TLS versionTLS 1.3
Certificate validationRequired
Certificate pinningRecommended
HSTSEnforced

Supported Cipher Suites:

PriorityCipher Suite
1TLS_AES_256_GCM_SHA384
2TLS_CHACHA20_POLY1305_SHA256
3TLS_AES_128_GCM_SHA256

Origin Validation

Server validates WebSocket upgrade requests:

HeaderValidation
OriginMust match allowed origins list
HostMust be relay.zentalk.network
Sec-WebSocket-KeyMust be valid base64
Sec-WebSocket-ProtocolMust be “zentalk.v1”

Allowed Origins:

https://app.zentalk.network https://zentalk.network capacitor://localhost (mobile apps)

Token Expiration Handling

Tokens are validated on every message:

1. Extract token from session 2. Verify signature 3. Check exp claim against server time 4. If expired: a. Check grace period (5 minutes) b. If within grace: allow, send TOKEN_EXPIRING warning c. If past grace: close connection with 4001 5. If valid: process message

Token Expiring Warning:

{ "type": "TOKEN_EXPIRING", "id": "<uuid>", "timestamp": 1704067200000, "payload": { "expires_at": 1704070800000, "seconds_remaining": 1800, "action": "refresh_recommended" } }

Replay Attack Prevention

Each message includes protections against replay:

ProtectionImplementation
NonceUnique per message, never reused
TimestampMust be within 5 minute window
Sequence numberMonotonically increasing per session
Message IDServer rejects duplicate IDs

Server-Side Deduplication:

1. Receive message with ID 2. Check bloom filter for ID 3. If possibly present: a. Check database for exact match b. If found: reject as duplicate 4. If not present: a. Add to bloom filter b. Store in database with TTL (24 hours) c. Process message

Message Integrity

All messages are integrity protected:

message_mac = HMAC-SHA256(session_key, type || id || timestamp || payload) Client includes message_mac in envelope Server verifies before processing Tampering detected = connection closed

Implementation Recommendations

Client Implementation

WebSocket Client Pseudocode: class ZentalkWebSocket: state = DISCONNECTED pending_messages = [] reconnect_attempts = 0 function connect(): state = CONNECTING ws = new WebSocket(connection_url) ws.onopen = handle_open ws.onmessage = handle_message ws.onclose = handle_close ws.onerror = handle_error function handle_open(): state = AUTHENTICATING // Server will send AUTH_CHALLENGE function handle_message(msg): data = parse_json(msg) switch data.type: case "AUTH_CHALLENGE": send_auth_response(data.payload) case "AUTH_SUCCESS": state = CONNECTED reconnect_attempts = 0 drain_pending_messages() case "MESSAGE_NEW": emit("message", data.payload) case "PING": send_pong(data.payload) // ... handle other types function handle_close(code): state = DISCONNECTED if should_reconnect(code): schedule_reconnect() function send(type, payload): if state != CONNECTED: pending_messages.push({type, payload}) return ws.send(json({ type: type, id: generate_uuid(), timestamp: now(), payload: payload }))

Server Implementation Considerations

ConcernRecommendation
Connection limitUse connection pooling
Message orderingSequence numbers per session
Horizontal scalingSticky sessions or pub/sub
Presence aggregationDistributed cache (Redis)
Message queuePersistent store (PostgreSQL)

Mobile Considerations

ChallengeSolution
Background restrictionsUse push notifications
Battery optimizationReduce ping frequency when backgrounded
Network transitionsImplement connection recovery
Memory constraintsLimit pending message queue

Last updated on