Skip to content

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.

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:

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.

Zentalk uses wallet-signature authentication plus JWT session tokens. See Authentication for the protocol details.

Initialize a session
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 wallet
const message = 'Login to Zentalk';
const signature = await walletClient.signMessage({ message });
// 3. Initialize the session
const 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();

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.

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.

Direct messages are encrypted end-to-end when E2EE is enabled (see Security overview).

Send a direct message
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:

src/api/messages.ts
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 is controlled and integrated at the messages module level:

import { setE2EEEnabled, isE2EEEnabled, publishKeyBundle } from '@/api/messages';
setE2EEEnabled(true); // toggle E2EE for outgoing messages
await 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.

Create a group
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:

  • LifecyclegetGroups(), getGroupInfo(), updateGroup(), deleteGroup()
  • MembershipaddGroupMembers(), getGroupMembers(), removeGroupMember(), joinGroup(), leaveGroup()
  • MessagingsendGroupMessage(), getGroupMessages()
  • InvitescreateGroupInvite(), joinGroupByInvite()
  • ModerationpromoteGroupMemberToAdmin(), 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 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).

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:

WebSocket lifecycle
import { zentalkAPI } from '@/api';
// 1. Register handlers — each returns an unsubscribe function
const 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 up
offMessage();
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.

All error types are defined in src/api/errors.ts. Full hierarchy and codes: Error handling.

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
E2EE error handling
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
}
}
}