TypeScript client
The Zentalk TypeScript client library lives in the web application repository under src/api/ and is the reference implementation of a Zentalk client. It is not published to npm; use it as the authoritative example of how to authenticate, send encrypted messages, and consume real-time events until @zentalk/sdk-js ships.
Architecture
Section titled “Architecture”The library exposes two usage styles:
| Style | Import | When to use |
|---|---|---|
| Singleton client | import { zentalkAPI } from '@/api' |
Application code — manages session state, auth headers, and the WebSocket for you |
| Module functions | import { sendMessage } from '@/api/messages' |
Lower-level control — each function accepts a getAuthHeaders callback |
The ZentalkAPI class (src/api/client.ts) wraps the per-feature modules (account.ts, messages.ts, groups.ts, channels.ts, and others) plus a WebSocketManager instance. A singleton is exported from src/api/index.ts:
export const zentalkAPI = new ZentalkAPI();The base URL is taken from NEXT_PUBLIC_API_URL (default http://localhost:3001). See Environments and base URLs.
Initialization and authentication
Section titled “Initialization and authentication”Zentalk uses wallet-signature authentication plus JWT session tokens. See Authentication for the protocol details.
import { zentalkAPI } from '@/api';
// 1. Register a signing function (used for signature-based auth and mesh storage)zentalkAPI.setSignFunction((message) => walletClient.signMessage({ message }));
// 2. Sign the login message with the walletconst message = 'Login to Zentalk';const signature = await walletClient.signMessage({ message });
// 3. Initialize the sessionconst response = await zentalkAPI.initialize( walletAddress, // Ethereum address (0x...) username, // 3-32 characters signature, message,);initialize() validates and sanitizes all inputs client-side (wallet address format, username pattern and length) and throws an APIError with ErrorCode.INVALID_INPUT on failure.
interface InitializeResponse { success: boolean; address: string; username: string; message: string; access_token?: string; // present after successful auth refresh_token?: string; expires_at?: string; token_type?: string;}Returning users with a valid JWT can skip the wallet signature:
const response = await zentalkAPI.sessionResume();Auth headers pattern
Section titled “Auth headers pattern”Every module-level function accepts a callback instead of a static token, so tokens can be refreshed transparently between calls:
type AuthHeadersFunction = () => Promise<HeadersInit>;When using the singleton, zentalkAPI.getAuthHeaders() implements this callback for you and is bound automatically.
Token manager
Section titled “Token manager”src/api/token-manager.ts handles the JWT lifecycle, refreshing short-lived access tokens automatically:
| Function | Purpose |
|---|---|
isTokenExpired() |
True if the access token is expired or within 60 s of expiry |
hasValidToken() |
True if an unexpired access token is present |
refreshAccessToken() |
Exchanges the refresh token for a new access token (deduplicates concurrent calls) |
revokeRefreshToken() |
Revokes the refresh token server-side (logout) |
fetchWithTokenRefresh(url, options) |
fetch wrapper with automatic 401-triggered refresh and retry |
startSilentRefresh() / stopSilentRefresh() |
Background proactive refresh loop |
initializeTokenManager() |
Wires the token manager on app start |
setupVisibilityRefresh() / setupOnlineRefresh() |
Refresh on tab focus / network reconnect; both return cleanup functions |
Rate-limited responses (HTTP 429) are handled by fetchWithRateLimitDetection (src/api/rate-limit-interceptor.ts), which retries with backoff based on the Retry-After header. See Rate limits.
Sending messages
Section titled “Sending messages”Direct messages are encrypted end-to-end when E2EE is enabled (see Security overview).
const result = await zentalkAPI.sendMessage( recipientAddress, 'Hello from the reference client',);
// result: { success, message_id, timestamp, message, duplicate? }The full module-level signature supports idempotent sends and replies:
async function sendMessage( recipientAddress: string, content: string, getAuthHeaders: () => Promise<HeadersInit>, senderAddress?: string, // required for E2EE encryption messageId?: string, // client-generated ID for idempotent retries replyToId?: string, replyToPreview?: ReplyPreviewData,): Promise<SendMessageResponse>If messageId is provided, the server deduplicates retries with the same ID and sets duplicate: true in the response. Related functions in the same module: sendMessageWithLinkPreviews() (fetches and embeds link previews inside the encrypted payload so the recipient never fetches URLs), sendMediaMessage(), getMessages(), and decryptMessage().
E2EE integration points
Section titled “E2EE integration points”E2EE is controlled and integrated at the messages module level:
import { setE2EEEnabled, isE2EEEnabled, publishKeyBundle } from '@/api/messages';
setE2EEEnabled(true); // toggle E2EE for outgoing messagesawait publishKeyBundle(/* ... */); // publish this device's key bundle (API + mesh)When E2EE is active, sendMessage() encrypts content before it leaves the client, and decryptMessage() decrypts incoming payloads. Failures surface as E2EEError instances (see below). In mesh-only storage mode, E2EE is mandatory and plaintext sends are rejected client-side.
Groups
Section titled “Groups”import { createGroup, sendGroupMessage } from '@/api/groups';
const group = await createGroup( { name: 'Protocol Team', description: 'Core protocol discussion', members: ['0xAbc...', '0xDef...'], // wallet addresses is_public: false, }, getAuthHeaders,);interface CreateGroupRequest { name: string; description?: string; members: string[]; // wallet addresses is_public?: boolean; avatar_chunk_id?: number; avatar_key?: string;}The module also provides:
- Lifecycle —
getGroups(),getGroupInfo(),updateGroup(),deleteGroup() - Membership —
addGroupMembers(),getGroupMembers(),removeGroupMember(),joinGroup(),leaveGroup() - Messaging —
sendGroupMessage(),getGroupMessages() - Invites —
createGroupInvite(),joinGroupByInvite() - Moderation —
promoteGroupMemberToAdmin(),demoteGroupMemberToMember()
Mesh storage guards. In mesh-only mode, central-API group creation is blocked and throws; in mesh-first mode, falling back to the central API requires an explicit opt-in via the options parameter ({ allowCentralFallback: true }). The same guards apply to channels.
Channels
Section titled “Channels”Channels follow the same request-object pattern:
import { createChannel, subscribeToChannel, sendChannelMessage } from '@/api/channels';
const channel = await createChannel(request, getAuthHeaders);await subscribeToChannel(/* ... */);await sendChannelMessage(request, getAuthHeaders, options);sendChannelMessage() attempts channel E2EE when options.ownerAddress and options.currentUserAddress are provided: content is encrypted client-side and the plaintext content field is cleared before the request is sent (the backend rejects requests carrying both plaintext and encrypted content).
WebSocket usage
Section titled “WebSocket usage”Real-time events are delivered by WebSocketManager (src/api/websocket.ts). See WebSocket for the wire protocol and Events for the full event catalog.
Credential placement. Authentication is never placed in the URL. The client first requests a short-lived WebSocket ticket, then connects with the ticket in the Sec-WebSocket-Protocol subprotocol (ticket.<ticket>); if ticket issuance fails, it falls back to the JWT in the subprotocol (token.<accessToken>). Tokens therefore never appear in server access logs or referrer headers.
Handler registration. Register handlers before connecting. connectWebSocket() is intentionally manual so that no events arrive before handlers exist:
import { zentalkAPI } from '@/api';
// 1. Register handlers — each returns an unsubscribe functionconst offMessage = zentalkAPI.onMessage((msg) => { console.log('New message', msg);});
const offTyping = zentalkAPI.onTyping((indicator) => { if (indicator.typing) console.log(`${indicator.from} is typing`);});
const offReaction = zentalkAPI.onReactionAdded((reaction) => { console.log(`${reaction.user} reacted with ${reaction.emoji}`);});
// 2. Connect (requires an initialized session and a valid access token)zentalkAPI.connectWebSocket();
// 3. Clean upoffMessage();offTyping();offReaction();zentalkAPI.disconnect();Registration methods exist for every event family, all with the same shape — pass a typed handler, receive an unsubscribe function: direct messages (onMessage, onReadReceipt, onTyping, onMessageEdited, onMessageDeleted, onReactionAdded, onReactionRemoved, onVoiceMessage), presence (onOnlineStatusChange, onStatusChange, onProfileUpdate), groups (onGroupMessage, onGroupCreated, onGroupMemberJoined, …), channels (onChannelMessage, onChannelCreated, onChannelMessagePinned, …), calls, stories, polls, live location, and encrypted-metadata events.
The manager also handles reconnection with cooldown, heartbeats (getConnectionQuality(), isHeartbeatHealthy(), performHealthCheck()), and an offline queue that buffers up to 100 authenticated outgoing messages while disconnected.
Error classes
Section titled “Error classes”All error types are defined in src/api/errors.ts. Full hierarchy and codes: Error handling.
Handling E2EE errors
Section titled “Handling E2EE errors”The E2EE-specific class is E2EEError, whose securityLevel field distinguishes recoverable failures from ones that require user attention:
| Level | Typical codes | Action |
|---|---|---|
critical |
MITM_DETECTED, INVALID_PREKEY |
Warn the user; require manual key verification |
warning |
KEY_NOT_FOUND, DECRYPTION_FAILED, EXPIRED_PREKEY |
Often recoverable via key-bundle refresh or session reset |
import { isE2EEError } from '@/api/errors';
try { await decryptMessage(encryptedPayload);} catch (error) { if (isE2EEError(error)) { if (error.securityLevel === 'critical') { showSecurityWarning(error.getUserMessage()); } else { // e.g. request a fresh key bundle and retry } }}