Messaging integration examples

API version: 6.4.1 · SDK version: 6.5.3 · Docs version: 6.5.3

API 6.4.1 · SDK 6.5.3 · documentation 6.5.3.

One client.chats session handles text, files, direct/group conversations and recipient-only Whispers. The server stores ciphertext; decryption and message search run in the browser. Each example uses the current instance and the exact wallet/profile.

DeliveryHistoryRetentionOpening
ConversationParticipants, including senderOptional absolute expiryNormal authenticated history
WhisperRecipient only; no recoverable sent copyRequired unopened expiry and direct viewing windowExplicit Open action only

A viewing window is 30–3600 seconds and is not supported in groups. Once opened, its deletion deadline is fixed; reopening does not restart it. Downloaded copies and screenshots cannot be revoked.

For headless signers, protected key storage, deterministic message IDs and restart-safe delivery tracking, use the agent messaging guide. The SDK includes canonical Spasm ID calculation: signed messages expose spasm_id before submission, and send/submit results return the same spasm_id, event_id and message_event_id even when the server returns only a success string. Persist that ID and the exact signed event; do not re-encrypt or re-sign after an ambiguous timeout.

1. Connect and unlock

Include <script src="/ubnet.js"></script> before your application script. Run the following from a Connect/Unlock button, not on page load. If your UI uses a different wallet provider, pass that EIP-1193 provider instead of window.ethereum.

const UBNet = await window.UBNetReady;
const provider = window.ethereum;
if (!provider?.request) throw new Error("Connect a supported wallet.");
const [wallet] = await provider.request({ method: "eth_requestAccounts" });
const profileName = ""; // Default always exists.
const client = UBNet.createClient({
  apiBase: window.location.origin,
  walletProvider: provider,
  profileName
});
const identity = client.chats.getIdentity(wallet, { profile: profileName }).identity;
const status = await client.chats.getStatus(identity);
if (status.state === "setup_required" || status.state === "restore_required") {
  throw new Error("Enable messaging or restore access through the site's messaging settings first.");
}
if (!client.chats.isUnlocked(identity)) await client.chats.unlock(identity);

Do not generate a replacement key to resolve an unlock or network error. Enable, restore and destructive key replacement are separate, user-approved operations.

2. Send once, after user confirmation

Use a wallet address and an explicit target profile. Resolve ENS names through your application's identity resolver before this step. file is a browser File or null; use the server's current limit before reading it.

async function sendConversation(targetWallet, targetProfile, message, file = null) {
  const limits = await client.chats.limits();
  if (file && (!limits.inline_attachments_enabled ||
      file.size > limits.inline_attachment_max_bytes)) {
    throw new Error("This file is not allowed by the current server policy.");
  }
  const direct = client.chats.directId(identity, targetWallet, {
    target_profile_name: targetProfile
  });
  return client.chats.sendMessage(identity, direct.chat_id, {
    message,
    file,
    expiresAt: Date.now() + 24 * 60 * 60 * 1000
  });
}

async function sendWhisper(targetWallet, targetProfile, message, file = null) {
  const direct = client.chats.directId(identity, targetWallet, {
    target_profile_name: targetProfile
  });
  return client.chats.sendMessage(identity, direct.chat_id, {
    message, file,
    message_type: "whisper",
    delivery_mode: "recipient",
    expiresAt: Date.now() + 24 * 60 * 60 * 1000,
    viewSeconds: 60
  });
}

Both methods locally encrypt and request wallet approval. Disable the Send button while waiting. A timeout is not proof of failure: retain the returned event ID/submission state and reconcile it; never automatically sign and send a duplicate. Group conversation sends use an existing group chat ID, with no viewSeconds.

The filename, MIME type, caption and file key are in the small encrypted message manifest. File bytes are encrypted separately within the same event and signed once. Chat history does not fetch file ciphertext; the explicit download call fetches it and verifies its hash before decryption. Identities, category labels, attachment presence and lifecycle settings are public metadata. Signatures authorize the encrypted transmission, not a plaintext attestation.

3. List sealed Whispers without opening them

async function loadWhisperRows() {
  const result = await client.chats.inbox(identity, { limit: 25 });
  return result.messages; // Metadata only. No ciphertext and no timer started.
}

Bind each row's Open button to an explicit opening operation. The following viewer expects an existing DOM element viewer; render untrusted text with textContent, never innerHTML. It clears displayed text when the session locks, the deadline passes, the user closes the view or the wallet changes.

let viewerGeneration = 0;
let viewerTimer = null;
let currentOpened = null;
function clearWhisper() {
  viewerGeneration += 1;
  clearInterval(viewerTimer);
  viewerTimer = null;
  currentOpened = null;
  viewer.textContent = "";
}

async function openWhisperFromClick(messageId) {
  clearWhisper();
  const generation = viewerGeneration;
  const opened = await client.chats.openMessage(identity, messageId);
  if (generation !== viewerGeneration || !client.chats.isUnlocked(identity) ||
      (opened.delete_at != null && Number(opened.delete_at) <= Date.now())) return;
  currentOpened = opened;
  viewer.textContent = opened.plaintext || "";
  viewerTimer = setInterval(() => {
    if (!client.chats.isUnlocked(identity) ||
        (opened.delete_at != null && Number(opened.delete_at) <= Date.now())) clearWhisper();
  }, 250);
}
const stopSessionListener = client.chats.subscribe(() => {
  if (!client.chats.isUnlocked(identity)) clearWhisper();
});
const walletChanged = () => { client.chats.lock(identity); clearWhisper(); };
provider.on?.("accountsChanged", walletChanged);
window.addEventListener("pagehide", clearWhisper);

Also call clearWhisper() on profile change and Close. Never call Open from background polling, preview generation, search, a notification handler or a mount effect. Opening does not mark an ordinary conversation read.

4. Read history and download deliberately

async function loadConversation(chatId, cursor = null) {
  return client.chats.messages(chatId, identity, { limit: 25, cursor });
}
async function attachmentFromClick(chatId, historyMessage) {
  // Fetches file ciphertext only now, verifies it, and returns a decrypted Blob.
  return client.chats.downloadAttachment(identity, chatId, historyMessage);
}
async function openedWhisperAttachmentFromClick() {
  if (!currentOpened) throw new Error("Open a Whisper first.");
  return client.chats.downloadAttachment(identity, currentOpened);
}

Before initiating a browser download after the awaited Blob, recheck that the same profile/view remains active and messaging is unlocked. Revoke any object URL after use and on close. Discard decrypted history/search state on lock, identity change and expiry. Only call markRead after the latest decrypted message has actually been viewed.

Stop watchers with stop(), call stopSessionListener(), remove wallet/DOM listeners, clear the viewer and call client.chats.dispose() when your integration is destroyed. No sender copy, deletion receipt or backup is created for a Whisper.

See the SDK reference for the full contract, limits and error handling.

Whisper metadata pagination and unread notifications (6.1.0)

GET /api/messages/inbox/:wallet?profile=&limit=50&cursor=... requires the existing signed chat-access session. It returns { messages, has_more, next_cursor, unread_count }. Rows contain metadata only, never message ciphertext, attachment data or decrypted content. unread_count counts all live unopened Whispers for the requested wallet/profile and category, across pages. Cursors use the last row's creation timestamp and message ID, so equal timestamps do not repeat or skip rows. Invalid cursors return HTTP 400. Opening remains an explicit authenticated action and starts the existing viewing timer.

Use client.chats.inbox(identity, { limit: 50, cursor }) to load another metadata page. client.chats.watchInbox({ identity, on_update, on_error }) shares the existing identity refresh controller with regular chat/unread watches. getUnread(identity) and watchUnread include whisper_unread_count; the total count includes regular unread conversations and unopened Whispers. Subscribe without opening or decrypting sealed messages. Stop watches when their view or identity is removed.

The public client.categories.list({ signal }) reads the current server's /api/categories without wallet authentication or caching. It reports category descriptions and feed/search flags for agents and public readers.

Admin event purge requires { confirmation: root_id, expected_event_ids }. Chat purge requires { confirmation, selected_chat_ids, expected_event_ids }. A changed plan must be refreshed and explicitly confirmed again; clients must not weaken or bypass these checks.

Read every metadata page without opening messages

// begin-doc:whisper-pages
async function collectWhisperMetadata(identity) {
  const messages = [];
  let cursor;
  do {
    const page = await client.chats.inbox(identity, { limit: 50, cursor });
    messages.push(...page.messages);
    if (!page.has_more) return messages;
    if (!page.next_cursor || page.next_cursor === cursor) throw new Error("Invalid inbox continuation");
    cursor = page.next_cursor;
  } while (cursor);
  return messages;
}
// end-doc:whisper-pages

This example reads metadata only. Display a recipient's explicit Open action separately; never call openMessage while scanning, counting or searching unopened Whispers.

Consistent conversation arrival order

API 6.4.1 and SDK 6.5.3 use (server_received_at, message_id) for regular conversation history, discovery, previews and private read markers, falling back to creation time only where a server receipt timestamp is unavailable. Author timestamps remain available for display and signed lifecycle/deletion semantics. A valid repeated or older read-marker write returns saved: true without regressing the server marker. An invalid/inaccessible target or disabled sync returns saved: false; always refresh read status rather than stopping discovery behind a rejected local marker.

Apply the backend receipt-order migration before updating clients. The SDK versions its device-local discovery cursors and highlights, retains old marker message IDs for server reconciliation, and never compares old author-time cursor values with receipt order. It establishes a new discovery baseline for local-only unread tracking. Low-level integrations that persist activity after/after_id cursors must discard pre-upgrade cursors and rebuild their baseline. Existing read synchronization remains opt-in and profile scoped. None of these operations opens or decrypts a sealed Whisper.