UBNet Browser SDK

The browser SDK is loaded from /ubnet.js. The loader retrieves the tracked runtime modules under /ubnet/ and exposes window.UBNet.

API version: 2.2.0 SDK version: 3.1.0 Docs version: 3.1.0

Current publishing model: top-level posts require explicit categories at creation. The SDK does not infer or default a category. Edit calls can change text and visibility, but cannot replace categories, media, or thumbnails.

Code alignment: this page mirrors the public modules loaded by /ubnet.js: core.js, transport.js, events.js, posts.js, lists.js, profiles.js, uploads.js, chats.js, nfts.js, reservedSubdomains.js, domainRegistration.js, support.js, and eventQueries.js.

Load and create a client

<script src="https://my.eth-ub.net/ubnet.js"></script>
<script>
  const ubnet = window.UBNet.createClient({
    apiBase: "https://my.eth-ub.net",
    walletProvider: window.ethereum
  });
</script>

apiBase may be an absolute HTTP(S) URL, a same-origin path, or empty for the current origin. Cross-origin sites still require backend CORS permission.

Global SDK

CallUse
window.UBNet.createClient(config)Create a client. Config supports apiBase/apiBaseUrl/baseUrl, walletProvider, custom fetch, and spasmSubmitPath/submitPath.
window.UBNet.configure(options)Persist compatible global runtime settings in browser storage.
window.UBNet.UbnetErrorSDK error class with status, code, and payload.
window.UBNet.versionSDK compatibility version.
window.UBNet.versions{ api, sdk, docs } compatibility versions.

Client object

{
  apiBase,
  api,
  cache,
  posts,
  lists,
  profiles,
  uploads,
  chats,
  nfts,
  reservedSubdomains,
  domainRegistration,
  support,
  events,
  configure,
  version,
  versions
}

Low-level transport and cache

CallUse
ubnet.api.get(path, settings)GET through the configured API base.
ubnet.api.post(path, body, settings)JSON POST through the configured API base.
ubnet.api.request(path, init, settings)Raw request wrapper with UbnetError handling and optional cache policy.
ubnet.cache.remember(key, load, options)Load or reuse a cached value.
ubnet.cache.invalidate(key)Remove one entry.
ubnet.cache.invalidatePrefix(prefix)Remove matching entries.
ubnet.cache.invalidateTag(tag)Remove tagged entries.
ubnet.cache.clear(options)Clear all entries or selected tags.
ubnet.cache.stats()Read fresh, stale, pending, and maximum-entry counts.

Posts

CallAPI / actionUse
ubnet.posts.list(filters)GET /api/posts/feedRead the public Feed. Server allow lists decide Feed inclusion.
ubnet.posts.get(id, opts)GET /api/posts/:idRead post detail.
ubnet.posts.replies(id, opts)GET /api/posts/:id/repliesRead cursor-paged replies.
ubnet.posts.mine(wallet, opts)GET /api/posts/wallet/:walletRead wallet/owner posts.
ubnet.posts.create(wallet, input)Signed postCreate a top-level post with content, optional media, and explicit categories.
ubnet.posts.edit(wallet, postId, input)Signed post editEdit content or visibility through edit_of. Categories, media, and thumbnails cannot be changed.
ubnet.posts.update(wallet, postId, input)Alias of editUpdate a post.
ubnet.posts.reply(wallet, targetId, content, opts)Signed replyReply to a post.
ubnet.posts.react(wallet, targetId, type, opts)Signed reactSubmit like, upvote, or downvote.
ubnet.posts.publication(wallet, targetId, state)Alias via editSet published or private state.
ubnet.posts.withdraw(wallet, targetId)Alias of removeDelete a post.
ubnet.posts.remove(wallet, targetId)Signed deleteDelete one post.
ubnet.posts.deleteThread(wallet, targetId)Signed delete-threadDelete the requesting author's owned parts of a thread.

Validated category input

function normalizePostCategory(raw) {
  const clean = String(raw || "").trim().toLowerCase();
  if (!/^[a-z0-9][a-z0-9_-]{0,119}$/.test(clean)) {
    throw new Error("Choose a category using lowercase letters, numbers, dashes, or underscores.");
  }
  return { name: clean };
}

async function createTextPost(ubnet, wallet, form) {
  const category = normalizePostCategory(form.category.value);
  const content = form.content.value.trim();
  if (!content) throw new Error("Write some content before posting.");

  return ubnet.posts.create(wallet, {
    categories: [category],
    content,
    publicationStatus: form.visibility.value === "private" ? "offline" : "published"
  });
}

No category fallback exists. Omitting categories throws post_categories_required. Post edits reject category changes with post_categories_immutable.

Post with URL or CID media

await ubnet.posts.create(wallet, {
  categories: [{ name: "project-update" }],
  content: "Read the full article.",
  mediaValue: "https://example.com/article"
});

await ubnet.posts.create(wallet, {
  categories: [{ name: "audio" }],
  content: "Listen to the full recording.",
  mediaValue: resolved.cid,
  thumbnailUrl: customThumbnail,
  cidMetadata: resolved
});

cidMetadata should identify audio, video, or image content. An explicit thumbnailUrl overrides a thumbnail included in the CID metadata. Post edits accept content and visibility only.

Lists

CallAPI / actionUse
ubnet.lists.list(filters)GET /api/collectionsRead the public list index.
ubnet.lists.get(id, opts)GET /api/collections/:idRead list metadata.
ubnet.lists.items(id, opts)GET /api/collections/:id/itemsRead list items.
ubnet.lists.item(id, itemId, opts)GET /api/collections/:id/items/:itemIdRead one list item.
ubnet.lists.mine(wallet, opts)GET /api/collections/owner/:walletRead owner lists.
ubnet.lists.publicForWallet(wallet, opts)GET /api/collections/wallet/:walletRead public lists for a wallet.
ubnet.lists.create(wallet, input)Signed listCreate a list.
ubnet.lists.update(wallet, listId, input)Signed list editUpdate list metadata.
ubnet.lists.addItem(wallet, listId, input)Signed ubnet-collection-itemAdd an audio, video, image, link, or post item.
ubnet.lists.updateItem(wallet, listId, itemId, input)Signed item editEdit an item owned by the signer. CID identity cannot be changed.
ubnet.lists.removeItem(wallet, listId, targetId)Signed removeRemove an item.
ubnet.lists.addPost(wallet, listId, postId)Alias of addItemAdd a post item.
ubnet.lists.removePost(wallet, listId, postId)Alias of removeItemRemove a post item.
ubnet.lists.react(wallet, targetId, type, opts)Delegates to post reactionsReact to a list or list-item target.
ubnet.lists.reply(wallet, targetId, content, opts)Delegates to post repliesComment on a list or list item.
ubnet.lists.remove(wallet, targetId)Signed deleteDelete a list.
ubnet.lists.unlockChallenge(listId, wallet)POST /api/collections/:id/unlock-challengeRequest the message the wallet must sign before unlocking a token-gated list.
ubnet.lists.unlock(listId, wallet, signature, message)POST /api/collections/:id/unlockUnlock a token-gated list with the signed challenge.
ubnet.lists.unlockItemMediaChallenge(listId, itemId, wallet)POST /api/collections/:id/items/:itemId/unlock-challengeRequest the message the wallet must sign before unlocking item media.
ubnet.lists.unlockItemMedia(listId, itemId, wallet, signature, message)POST /api/collections/:id/items/:itemId/unlockUnlock token-gated item media with the signed challenge.

Cursor pagination and infinite scrolling

Treat every next_cursor as opaque. Do not decode, modify, combine, or reuse it with another endpoint, wallet, chat, search term, visibility filter, or post type.

Reset the cursor, duplicate set, finished state, and rendered items whenever the query changes. Keep only one request in flight for a cursor, and stop if the API returns no cursor, repeats the requested cursor, or produces no new item IDs.

const postPager = {
  cursor: null,
  loading: false,
  finished: false,
  seen: new Set(),
  controller: null
};

function postId(post) {
  return String(post.root_id || post.latest_id || post.spasm_id || "");
}

async function loadMorePosts(filters = {}) {
  if (postPager.loading || postPager.finished) return [];
  postPager.loading = true;
  const requestedCursor = postPager.cursor;
  const controller = new AbortController();
  postPager.controller = controller;

  try {
    const page = await ubnet.api.request(
      "/api/posts/feed",
      { signal: controller.signal },
      { query: { ...filters, limit: 10, cursor: requestedCursor } }
    );

    const added = [];
    for (const post of page.feed || []) {
      const id = postId(post);
      if (!id || postPager.seen.has(id)) continue;
      postPager.seen.add(id);
      added.push(post);
    }

    const nextCursor = page.next_cursor || null;
    const madeProgress = added.length > 0 && nextCursor !== requestedCursor;
    postPager.finished = !madeProgress || !nextCursor || page.has_more === false;
    postPager.cursor = postPager.finished ? null : nextCursor;
    return added;
  } catch (error) {
    if (error?.name === "AbortError") return [];
    throw error;
  } finally {
    if (postPager.controller === controller) postPager.controller = null;
    postPager.loading = false;
  }
}

IntersectionObserver with manual fallback

const sentinel = document.querySelector("#posts-sentinel");
const loadButton = document.querySelector("#load-more-posts");

async function appendNextPostPage() {
  const posts = await loadMorePosts({ keyword: searchInput.value });
  renderPosts(posts);
  loadButton.disabled = postPager.loading || postPager.finished;
}

const observer = new IntersectionObserver((entries) => {
  if (entries.some((entry) => entry.isIntersecting)) {
    appendNextPostPage().catch(showError);
  }
}, { rootMargin: "520px 0px" });

if (sentinel) observer.observe(sentinel);
loadButton?.addEventListener("click", () => {
  appendNextPostPage().catch(showError);
});

Keep the button visible for retry and keyboard accessibility even when automatic loading is enabled.

Profiles and follows

CallAPI / actionUse
ubnet.profiles.get(wallet, opts)GET /api/users/:walletRead profile fields, social links, aggregate counts, and messaging metadata.
ubnet.profiles.search(keyword, limit)GET /api/users/searchSearch profiles.
ubnet.profiles.followers(wallet, opts)GET /api/users/:wallet/followersRead followers.
ubnet.profiles.following(wallet, opts)GET /api/users/:wallet/followingRead following.
ubnet.profiles.followExport(wallet)GET /api/users/:wallet/follow-exportExport follower/following data.
ubnet.profiles.uploadAvatar(wallet, file, options)Challenge + signed uploadUpload an avatar with wallet challenge signing.
ubnet.profiles.uploadBanner(wallet, file, options)Challenge + signed uploadUpload a banner with wallet challenge signing.
ubnet.profiles.save(wallet, input)Signed profile-updateSave profile fields.
ubnet.profiles.update(wallet, input)Alias of saveUpdate profile fields.
ubnet.profiles.saveSocialLink(wallet, input)Signed profile-social-linkAdd or update a social link.
ubnet.profiles.deleteSocialLink(wallet, link)Signed profile-social-link-deleteDelete a social link.
ubnet.profiles.follow(wallet, targetWallet)Signed followFollow a wallet.
ubnet.profiles.unfollow(wallet, targetWallet)Signed unfollowUnfollow a wallet.

Follow button must wait for confirmed state

async function loadConfirmedFollowingSet(viewerWallet) {
  let cursor = null;
  const followed = new Set();

  for (let page = 0; page < 10; page += 1) {
    const result = await ubnet.profiles.following(viewerWallet, { limit: 200, cursor });
    for (const row of result.following || []) {
      if (row.following_wallet) followed.add(row.following_wallet.toLowerCase());
    }
    cursor = result.next_cursor || null;
    if (!cursor || result.has_more === false) {
      return { confirmed: true, followed };
    }
  }

  return { confirmed: false, followed };
}

function followButtonState(targetWallet, confirmedState) {
  const target = targetWallet.toLowerCase();
  if (confirmedState.followed.has(target)) return "following";
  if (!confirmedState.confirmed) return "checking";
  return "not-following";
}

// Only show Follow when state === "not-following".
// Use a disabled Checking button or hide the control while state === "checking".

Named profiles

await ubnet.profiles.save(wallet, {
  profile_name: "business-profile",
  display_name: "Acme Corporation",
  bio: "Business profile"
});

const business = await ubnet.profiles.get(wallet, {
  profile: "business-profile"
});

Profile names are normalized to lowercase, must start with a letter or number, may contain lowercase letters, numbers, dashes, and underscores, and may be at most 64 characters.

Uploads

CallAPI / actionUse
ubnet.uploads.avatar(wallet, file)POST /api/uploads/avatar/:walletRaw low-level call without challenge headers. Prefer ubnet.profiles.uploadAvatar().
ubnet.uploads.pinataPresignedUrl(data)POST /api/uploads/pinata/presigned-urlOpen public Pinata URL route. Current backend intentionally returns a disabled error.
ubnet.uploads.pinata(data)Public presigned URL + upload helperConvenience helper; currently fails while open public Pinata uploads remain disabled.
ubnet.uploads.tokenGatedAccess(data)POST /api/uploads/pinata/token-gated-accessCheck token-gate access.
ubnet.uploads.tokenGatedChallenge(data)POST /api/uploads/pinata/token-gated-challengeCreate a wallet challenge.
ubnet.uploads.tokenGatedPresignedUrl(data)POST /api/uploads/pinata/token-gated-presigned-urlCreate a signed upload URL after wallet and token checks.
ubnet.uploads.tokenGatedPresignedUrlForTarget(target, data)WrapperAdd a configured target to the presigned URL request.
ubnet.uploads.pinataSignedUrl(uploadUrl, file)Direct Pinata signed-URL uploadUpload a non-empty file with FormData.
ubnet.uploads.tokenGatedPinata(data)Presigned URL + upload helperCreate a token-gated URL and upload data.file.
ubnet.uploads.tokenGatedPinataForTarget(target, data)WrapperUpload to a configured target.

Chats and messaging v2

Read this before integrating messaging: the public browser SDK is a signing and transport layer. It reads messaging metadata, signs messaging-key events containing a public key and optional protected recovery data, signs message events, and submits already encrypted payloads. It does not generate private keys, store private keys, restore recovery data, encrypt plaintext, decrypt ciphertext, or accept plaintext message payloads.

Never pass plaintext to ubnet.chats.send(). The only valid call is ubnet.chats.send(wallet, encryptedPayload), where encryptedPayload already follows the v2 contract shown below.

LayerResponsibilities
window.UBNetFetch public messaging profiles and protected recovery data; calculate direct chat IDs; publish public keys with optional protected recovery data; create group metadata; sign and submit encrypted message events; hide chats; leave groups; disable messaging; clear the local SDK-managed key handle.
Your crypto layerGenerate a P-256 ECDH key pair; persist the private key as a non-extractable CryptoKey in IndexedDB; unlock it; create and restore protected recovery data; derive AES keys; encrypt and decrypt direct messages; create, wrap, unwrap, and use group keys.
UBNet server/indexerStore public keys, protected recovery data, chat metadata, encrypted message envelopes, IVs, and wrapped group keys. It does not receive plaintext or the private messaging key.

The UBNet web client implements its crypto layer in frontend/src/services/crypto.ts, with usage references in frontend/src/components/MessagingKeyControls.tsx, frontend/src/services/messagingKeySync.ts, and frontend/src/pages/ChatPage.tsx. The helper names below must come from your compatible crypto module; they are not methods on window.UBNet.

Wait until messaging is indexed

async function waitForIndexedMessagingKey(
  ubnet,
  wallet,
  expectedPublicKey,
  { timeoutMs = 60000, intervalMs = 1000, requireBackup = true } = {}
) {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() <= deadline) {
    const profile = await ubnet.chats.messagingProfile(wallet);
    if ((profile.encryption_public_key || "") !== expectedPublicKey) {
      await new Promise((resolve) => setTimeout(resolve, intervalMs));
      continue;
    }

    if (!requireBackup) return;
    const backup = await ubnet.chats.messagingKeyBackup(wallet);
    if ((backup.messaging_key_backup?.public_key || "") === expectedPublicKey) return;
    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }

  throw new Error("Messaging key event was accepted but not indexed in time.");
}

One-time setup: enable messaging

// These helper names match frontend/src/services/crypto.ts.
const publicKey = await generateMessagingKeyPair(wallet);
const recoveryBackup = await createWalletRecoveryMessagingKeyBackup(wallet, window.ethereum);

await ubnet.chats.enableMessaging(wallet, {
  encryption_public_key: publicKey,
  messaging_key_backup: recoveryBackup
});

await waitForIndexedMessagingKey(ubnet, wallet, publicKey);
clearPendingMessagingKeyMaterial(wallet);

Do not send the private JWK to UBNet. enableMessaging() receives only the public key and already protected recovery data.

Restore and unlock on another browser

const backupResponse = await ubnet.chats.messagingKeyBackup(wallet);
const backup = backupResponse.messaging_key_backup;
if (!backup) throw new Error("No messaging recovery backup exists.");

await restoreMessagingKeyFromWalletRecoveryBackup(wallet, backup, window.ethereum);

if (!await hasLocalMessagingPrivateKey(wallet)) {
  throw new Error("Restore or replace the messaging key on this browser.");
}
if (!isMessagingUnlocked(wallet)) {
  await unlockMessagingKey(wallet, window.ethereum);
}

Send a direct encrypted message

const recipientWallet = "0x...";
const { chat_id } = await ubnet.chats.directId(wallet, recipientWallet);
const recipient = await ubnet.chats.messagingProfile(recipientWallet);

if (!recipient.messaging_enabled || !recipient.encryption_public_key) {
  throw new Error("Recipient has not enabled encrypted messaging.");
}

const senderPublicKey = await getLocalMessagingPublicKey(wallet);
const encrypted = await encryptDmMessage(
  wallet,
  recipient.encryption_public_key,
  "Hello"
);

await ubnet.chats.send(wallet, {
  messaging_protocol: "ubnet-e2ee-v2",
  is_group: false,
  chat_id,
  participants: [wallet, recipientWallet],
  sender_public_key: senderPublicKey,
  recipient_public_key: recipient.encryption_public_key,
  ciphertext: encrypted.ciphertext,
  iv: encrypted.iv,
  encoding: "aes-gcm-v2",
  encrypted: true
});

Create a group and send a group message

const participants = [wallet, "0x...", "0x..."];
const chatId = `group:${crypto.randomUUID()}`;

await ubnet.chats.createGroup(wallet, {
  chat_id: chatId,
  title: "Project group",
  participants
});

const recipients = await Promise.all(participants.map(async (participantWallet) => {
  if (participantWallet.toLowerCase() === wallet.toLowerCase()) {
    return { wallet: participantWallet, publicKey: await getLocalMessagingPublicKey(wallet) };
  }
  const profile = await ubnet.chats.messagingProfile(participantWallet);
  return { wallet: participantWallet, publicKey: profile.encryption_public_key };
}));

if (recipients.some((recipient) => !recipient.publicKey)) {
  throw new Error("Every group member must enable encrypted messaging.");
}

const senderPublicKey = await getLocalMessagingPublicKey(wallet);
const encrypted = await encryptGroupMessage(wallet, recipients, "Hello group");

await ubnet.chats.send(wallet, {
  messaging_protocol: "ubnet-e2ee-v2",
  is_group: true,
  chat_id: chatId,
  participants,
  sender_public_key: senderPublicKey,
  ciphertext: encrypted.ciphertext,
  iv: encrypted.iv,
  encoding: "group-aes-gcm-v2",
  encrypted_keys: encrypted.encrypted_keys,
  encrypted: true
});

Encrypt the group AES key separately for every intended reader, including the sender. Each encrypted_keys entry must contain wallet, ciphertext, iv, and encoding: "aes-key-wrap-ecdh-v2".

Read and decrypt messages

const page = await ubnet.chats.messages(chatId, wallet, { limit: 50, cursor: null });

for (const message of page.messages) {
  if (message.encoding === "aes-gcm-v2") {
    const otherPublicKey =
      message.sender_wallet.toLowerCase() === wallet.toLowerCase()
        ? message.recipient_public_key
        : message.sender_public_key;
    const plaintext = await decryptDmMessage(wallet, otherPublicKey, message.ciphertext, message.iv);
  }

  if (message.encoding === "group-aes-gcm-v2") {
    const plaintext = await decryptGroupMessage(
      wallet,
      message.sender_public_key,
      message.encrypted_keys,
      message.ciphertext,
      message.iv
    );
  }
}

Messaging calls

CallAPI / actionUse
ubnet.chats.list(wallet, opts)GET /api/messages/chats/:walletRead cursor-paged chats.
ubnet.chats.get(chatId, wallet, opts)GET /api/messages/chat/:chat_idRead chat metadata for a participant.
ubnet.chats.messages(chatId, wallet, opts)GET /api/messages/chats/:chat_id/messagesRead cursor-paged encrypted messages.
ubnet.chats.state(chatId, wallet, opts)GET /api/messages/chat/:chat_id/stateRead participant and visibility state.
ubnet.chats.activity(wallet, opts)GET /api/messages/activityRead incoming activity after a timestamp and message ID.
ubnet.chats.directId(wallet, targetWallet)Local helperReturn { chat_id, wallet, target_wallet } for a direct conversation.
ubnet.chats.messagingProfile(wallet, opts)GET /api/messages/profile/:walletRead messaging_enabled, the public key, and backup availability metadata.
ubnet.chats.messagingKeyBackup(wallet, opts)GET /api/messages/key-backup/:walletFetch the encrypted recovery backup. This does not restore or decrypt it.
ubnet.chats.createGroup(wallet, input)Signed group-chat-createCreate group metadata.
ubnet.chats.publishMessagingKey(wallet, input)Signed messaging-keyPublish an already generated v2 public key, with protected recovery data when supplied.
ubnet.chats.enableMessaging(wallet, input)AliasAlias of publishMessagingKey().
ubnet.chats.signMessage(wallet, payload)Wallet signingSign an already encrypted message payload without submitting it.
ubnet.chats.submitSignedMessage(signedEvent, body)POST /api/events/submitSubmit a previously signed message.
ubnet.chats.send(wallet, payload)Sign + submitSign and submit an already encrypted v2 payload.
ubnet.chats.remove(wallet, chatId)Signed delete-chatHide the chat for this wallet only.
ubnet.chats.leaveGroup(wallet, chatId)Signed group-chat-leaveLeave and hide a group chat for this wallet.
ubnet.chats.clearBrowserCache(wallet)Local IndexedDB operationDelete this browser's local private-key handle and unlock markers.
ubnet.chats.clearBrowserMessagingCache(wallet)AliasAlias of clearBrowserCache().
ubnet.chats.destroyMessagingKey(wallet)Signed messaging-key + local clearDisable messaging for the wallet and remove this browser's local key.

NFTs, names, support, and public events

CallAPI / actionUse
ubnet.nfts.publicGallery(wallet, cursor)GET /api/nft-galleries/profile/:walletRead a public NFT gallery.
ubnet.nfts.editor(wallet)GET /api/nft-galleries/editor/:walletRead editor data.
ubnet.nfts.inventory(wallet, networkIds)Local placeholderReturn an empty configured inventory with the supplied networks marked unavailable.
ubnet.nfts.save(wallet, input)Signed nft-gallerySave NFT gallery settings.
ubnet.reservedSubdomains.check(name)GET /api/reserved-subdomains/checkCheck whether a normalized name is reserved.
ubnet.domainRegistration.buildMessage(input)Local helperBuild the exact wallet-authentication message.
ubnet.domainRegistration.submit(input)POST /api/registrations/domain_registrationSubmit a sponsored request, or pass sponsored: false for a user-paid authorization.
ubnet.domainRegistration.registerAuthorized(response)Ethereum Mainnet walletValidate and submit a user-paid V3 authorization.
ubnet.support.contact(input)POST /api/support/contactCreate a support request.
ubnet.support.sendMessage(input)AliasAlias of contact.
ubnet.events.list(queryOrLimit)GET /api/eventsList public events using a query object or a numeric limit.
ubnet.events.get(id)GET /api/events/:idRead one public event.
ubnet.events.summary()GET /api/events/summaryRead the public event summary.
ubnet.events.topByCategory(query)GET /api/events/top-by-categoryRank categorized posts and lists by indexed likes.

Use ubnet.events.list({ category: "example-category", latest: true, wallet }) for filtered discovery. Supported query fields are limit, cursor, action, author, wallet, category, category_mode, schema_name, schema_version, post_type, source_instance_host, and latest=true.

Generated bundle output

Tracked SDK source modules live under frontend/public/ubnet/. The frontend build generates:

frontend/public/ubnet.bundle.js
frontend/public/sdk/v1/ubnet.bundle.js