UBNet API Reference

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

Updated for the current backend route surface.

Messaging inactivity sessions

POST /api/messages/access/session?wallet=0x...&profile=... uses the four standard X-UBNet-Chat-* credential headers. It is private and never cached. The SDK handles this protocol when messagingIdleUnlock: true is selected.

The signed UBNet messaging unlock message uses Version: 6, Access Mode: inactivity, Idle Timeout Ms: 1800000 (allowed range 60000–1800000), a random 32-byte hexadecimal Session, the exact wallet/profile/identity, issued/initial expiry timestamps and Authorizer: wallet or messaging-key-p256. The initial expiry minus issued time equals the idle timeout. Authorizers use personal-sign or a P-256 SHA-256 signature respectively. The latter must match the profile's currently published messaging key.

Bodies are { "action": "create" }, { "action": "activity", "activity_at": 1234567890000 } or { "action": "revoke" }. Creation verifies the signed initial window and registers a credential digest bound to the identity and messaging key. Replaying creation never advances expiry. Activity timestamps are epoch milliseconds, clamped to server time; old/replayed activity cannot advance expiry, and future values beyond the clock-skew allowance are rejected. Only activity on an unexpired, unrevoked session extends its idle deadline. Reads and ordinary polling do not. Responses contain expires_at and server_now in milliseconds. The SDK uses these to account conservatively for clock skew and round-trip latency. Invalid/expired access returns HTTP 401 chat_access_signature_required; invalid actions return HTTP 400 invalid_chat_access_action.

Lock revokes the root; expired or revoked roots cannot be reused to unlock. Key replacement or messaging disable invalidates access. Expired records are cleaned in bounded lifecycle-worker batches, retaining revocation records until their original signed creation window also closes. Apply the 20261019_chat_access_sessions.sql migration before enabling clients. Legacy version-4/5 fixed-duration access remains accepted. Renewal authorizes private reads only; sending messages and changing settings still require their existing exact-event signatures.

Quick Start

Local base URL:

http://localhost:3001

Public read endpoints do not require authentication. User-owned writes, private chat reads, uploads, protected list access, and domain registration use the relevant wallet-signature flow. Privileged admin/founder routes are intentionally omitted from this public reference.

Read installed compatibility versions from GET /api/version. File-sales SDK/API and agent documentation are introduced in the 6.2 public contract; a version response does not enable the service.

The API is independent of the browser application. UBNet, JA876 and NoEyes use this same route contract and backend image, with separate installation databases and settings. Route /api/ and /uploads/ directly to the backend when hosting a client at the same public origin. For a client on another origin, configure its public API URL and allow that client origin with CORS. CORS does not replace wallet-signature authentication.

Posts

Generic/public post discovery:

MethodPathPurpose
GET/api/postsGeneric public post feed; no UBNet Post Feed Allow policy.
GET/api/posts/searchPublic post search, controlled by post_search_enabled and approved search categories.
GET/api/posts/top-by-categoryGeneric public post ranking by one category.
GET/api/posts/wallet/:walletGeneric posts by wallet.
GET/api/posts/:id/historyImmutable post versions.
GET/api/posts/:id/repliesReplies for a post.
POST/api/posts/:id/accessResolve access details for a post.
GET/api/posts/:idPost detail/thread root.

UBNet Feed-curated post reads:

MethodPathPurpose
GET/api/posts/feedUBNet-curated post Feed.
GET/api/ubnet/posts/feedUBNet-curated post Feed used by the browser frontend namespace.
GET/api/ubnet/posts/top-by-categoryCategory-ranked posts with Post Feed Allow applied.
GET/api/ubnet/posts/wallet/:walletUBNet Feed-policy wallet post view.

The generic /api/posts and /api/posts/top-by-category routes never inherit Post Feed Allow. The curated Feed routes do. Block Powers are global/public and use GET /api/posts/top-by-category?category=blockpower&sort=upvotes&limit=5.

Post feed filters include limit, cursor, author, post_type, keyword, schema_name, schema_version, media_type, has_cid, and source_instance_host. Visibility is a wallet-read concern, not a feed filter.

GET /api/settings exposes settings.post_search_enabled, which defaults to false. Hide the post-search control unless it is explicitly true. GET /api/posts/search?keyword=wallet&limit=25 returns { posts, has_more, next_cursor }; pass cursor for subsequent pages. When disabled or no categories are approved, it returns { posts: [], has_more: false, next_cursor: null }. Search excludes private/deleted posts, replies and list items. Imported posts must also have an approved search category. This policy is independent of Feed placement and cannot be overridden by request parameters. Generic post reads remain unchanged.

Lists and Collections

The generic list router is mounted at both /api/lists and /api/collections.

MethodPathPurpose
GET/api/listsGeneric public lists; no List Feed Allow.
GET/api/lists/top-by-categoryGeneric public list ranking.
GET/api/lists/owner/:walletOwner list view.
GET/api/lists/wallet/:walletPublic lists by wallet.
GET/api/lists/:id/itemsList item page.
GET/api/lists/:id/items/:itemIdOne list item.
POST/api/lists/:id/unlock-challengeCreate protected-list wallet challenge.
POST/api/lists/:id/unlockUnlock protected list.
POST/api/lists/:id/items/:itemId/unlock-challengeCreate protected-item wallet challenge.
POST/api/lists/:id/items/:itemId/unlockUnlock protected item media.
GET/api/lists/:idList detail.
GET/api/collectionsAlias for generic public lists.
GET/api/collections/top-by-categoryAlias for generic public list ranking.

UBNet Feed-curated list reads:

MethodPathPurpose
GET/api/ubnet/listsCurated list Feed.
GET/api/ubnet/lists/top-by-categoryCategory-ranked lists with List Feed Allow when enabled.
GET/api/ubnet/lists/owner/:walletCurated owner list view.
GET/api/ubnet/lists/wallet/:walletCurated public wallet list view.

The generic ranking and Feed ranking routes are type-specific. There is no route that combines ranked posts and lists.

Generic list feeds support keyword, limit, and cursor. List items use limit plus offset and return next_offset.

list_type is the canonical list classifier. post_type is reserved for posts; list-item events use target_type and media_type. A list's type is immutable after its root event. Empty post-list and mixed-list roots remain out of public browsing until they contain an active item.

Events

MethodPathPurpose
GET/api/eventsGeneric indexed public event discovery.
GET/api/events/summaryIndexed event summary counts.
GET/api/events/:idRead one indexed public event.
POST/api/events/submitRelay a signed SPASM event after UBNet validation.

Event discovery is not a combined ranking API. Rank posts or lists through their type-specific routes above.

GET /api/events supports context filters such as action, author/wallet, category, category_mode, schema_name, schema_version, post_type, list_type, source_instance_host, nft_sale, limit, cursor, and latest where applicable. post_type filters posts and list_type filters lists.

List event responses expose the immutable classifier resolved from the list root as list_type. SDK list edits omit type fields because a list's type cannot be changed.

Submissions use canonical list_type values for list roots and target_type plus media_type for list items. Post submissions may use text, post, link, image, audio, video, or reply.

Users and profiles

MethodPathPurpose
GET/api/users/searchProfile search.
GET/api/users/:wallet/namesENS and registered-name data.
GET/api/users/:wallet/ens-bindingsIndexed ENS profile associations.
GET/api/users/:wallet/ens-controlVerify ENS control for a requested name.
GET/api/users/:wallet/follow-exportExport follower/following data.
GET/api/users/:walletProfile detail.
GET/api/users/:wallet/followingFollowing page.
GET/api/users/:wallet/followersFollowers page.

The Default profile uses a blank profile name. Named profiles are validated and invalid names do not silently fall back to Default. Invalid profile names return HTTP 400 with code invalid_profile_name.

Profile detail returns profile, name, profile_name, social_links and aggregate_counts. Counts are calculated from the current visible content and follow relationships. Profile information is not authorization to access gated content: protected access requires a wallet-signed challenge and a server-side on-chain token check.

Follow relationships are keyed by both wallets and both profile names. The paged follower/following routes return active projected relationships, accept limit plus an opaque cursor, and support name, profile, or profile_name for profile selection. The public follow protocol documents signed writes, ordering, projection state, response fields, and exports.

Messages

Read-status requests require the same short-lived wallet/profile-bound chat authorization as private message reads. Supply wallet and profile in the query. GET returns { enabled, conversations, next_cursor }; pass cursor to continue. POST accepts { chat_id, message_id } and returns { saved }. Only a participant can advance their own marker, and an older message cannot move it backwards. Markers contain identifiers and times, not plaintext or keys, and are not broadcast to Spasm. The signed profile field sync_chat_read_status controls this opt-in feature; disabling it deletes the profile's server markers. Polling or opening a route does not itself mark messages read.

MethodPathPurpose
GET/api/messages/chats/:walletGeneric chat list.
GET/api/messages/activityGeneric incoming chat activity.
GET/api/messages/read-statusPrivate profile-scoped read/unread state when synchronization is enabled.
POST/api/messages/read-statusAdvance the authenticated profile's read marker for a viewed message.
GET/api/messages/chat-idDeterministic direct chat ID.
GET/api/messages/profile/:walletMessaging profile metadata.
GET/api/messages/profiles/:walletMessaging profile metadata.
GET/api/messages/key-backup/:walletMessaging key backup.
GET/api/messages/chat/:chat_id/stateChat participant/visibility state.
GET/api/messages/chat/:chat_idChat metadata.
GET/api/messages/chats/:chat_id/messagesChat messages.
GET/api/messages/inbox/:walletRecipient-only metadata, with optional category and limit; no ciphertext.
POST/api/messages/message/:message_id/openExplicitly open an authorized recipient-only message and start its fixed viewing timer.
GET/api/messages/message/:message_id/attachmentFetch the separately encrypted file for an authorized participant; a Whisper must already be opened and unexpired. Never starts or resets a timer.
GET/api/messages/message/:message_id/statusAuthorized sender/recipient live existence check; no content and no stored receipt.
GET/api/ubnet/messages/chats/:walletUBNet uncategorized-message chat list.
GET/api/ubnet/messages/activityUBNet uncategorized-message activity.
GET/api/ubnet/messages/chat/:chat_id/stateUBNet uncategorized-message state.
GET/api/ubnet/messages/chats/:chat_id/messagesUBNet uncategorized-message history.

Private chat reads are authorized through the browser SDK high-level lifecycle. Call client.chats.getStatus(identity) and, when required, client.chats.unlock(identity). The SDK attaches short-lived X-UBNet-Chat-* authorization headers to both generic /api/messages/... and UBNet /api/ubnet/messages/... reads. New application code should not call the low-level chat transport authorization method directly.

Messages share one signed ubnet-message@2.0.0 contract. Direct action is dm; group action is group-dm. Normal messages explicitly set delivery_mode: "conversation"; recipient-only notes set delivery_mode: "recipient". Direct encoding is aes-gcm-envelope-v2; group encoding is group-aes-gcm-envelope-v2 with a wrapped key per available recipient, including the sender. Group senders and wrapped-key recipients must be active members. The signed envelope includes has_attachment, optional public categories and lifecycle: { expires_at, view_seconds }. The SDK encrypts text and attachment separately within one signed event. The file is stored in a separate projection column and excluded from message history, previews and Open responses; only the attachment endpoint reads it. The encrypted manifest binds its private key, IV, size and encrypted-file hash. The old combined attachment envelope is unsupported.

An absolute expiry can apply to direct or group conversation messages. Viewing timers are recipient-only and direct-message only: 30–3600 seconds, fixed by the first successful Open. Opening again does not extend the deadline. Recipient discovery, history, previews, activity and read markers never open a note or expose its content. Expired content is unavailable immediately; a separate lifecycle worker later purges source records and projections without a backup or retained receipt. Inbox, Open and status responses use Cache-Control: no-store; Open/status query parameters are wallet and profile.

New message signatures include signature_intent: "authorize_encrypted_message" and plaintext_attestation: "not_asserted". The signature authenticates the ciphertext and routing metadata, not authorship or truth of the decrypted plaintext. These fields are not required on previously signed events. message_type: "whisper" denotes recipient-only delivery and requires both lifecycle fields.

A successful wallet-recovery restore establishes the normal short-lived unlocked session, so it does not require a second immediate unlock.

Encrypted message rows expose the signed event timestamp separately from the unsigned server receipt field server_received_at. Ordering logic may use server receive time to make message ordering resilient to signer clock skew.

Uploads and IPFS

MethodPathPurpose
GET/api/uploads/profile-images/:idServe a validated event-backed avatar or banner as raster image bytes.
POST/api/uploads/avatarAvatar upload using the configured wallet-approval policy.
GET/api/uploads/avatar/challenge/:walletAvatar upload challenge.
POST/api/uploads/avatar/:walletAvatar upload.
GET/api/uploads/banner/challenge/:walletBanner upload challenge.
POST/api/uploads/banner/:walletBanner upload.
GET/api/uploads/pinata/targetsPublic Pinata target/policy metadata.
POST/api/uploads/pinata/challengeOne-time public upload signing challenge.
POST/api/uploads/pinata/presigned-urlVerify signature and issue short-lived direct Pinata URL.
GET/api/ipfs/discoverDiscover a CID/media reference through configured gateways.
GET/api/ipfs/gatewaysRead public gateway configuration.

Avatar and banner files are PNG, JPEG or WebP, non-empty and at most 5 MiB. The SDK uses the challenge endpoint's storage: "spasm-event" capability to create a wallet-signed profile-image action with schema ubnet-profile-image/3.0.0 through POST /api/events/submit. Its image object contains kind (avatar or banner), encoding: "base64", content_type, sha256 and base64 data; profile_name identifies the owner profile. The profile references /api/uploads/profile-images/<spasm_id>. Reads return 404 until indexed or after purging and use no-store caching. Event uploads always require a valid wallet signature.

The raw-file POST endpoints have their own authorization flow: By default, obtain a challenge, sign its message with personal_sign, then send the raw image bytes with Content-Type, x-wallet-address, x-wallet-challenge and x-wallet-signature headers. Challenges expire after ten minutes and are single-use. Both avatar POST URLs enforce the same policy. The SDK performs this flow automatically; uploading a file does not save a profile change.

GET /api/settings includes avatar_uploads_enabled and avatar_upload_approval_required, both defaulting to true. Disabled avatar uploads return 403 avatar_uploads_disabled without saving a file. When approval is explicitly disabled, the avatar challenge returns approval_required: false and the upload can omit signature headers. Banner approval remains mandatory. Missing/invalid approval returns 401 avatar_upload_signature_required (or the banner equivalent). Avatar POSTs are limited to 20 requests per source IP per minute; image challenges to 40 per source IP per minute. Rate counters are local to the API process, not a persistent storage-byte quota.

Server-side gateway discovery, package validation, gateway-list import and health checks have bounded response sizes and complete-request deadlines. Gateway connection safeguards validate and pin public DNS addresses and recheck redirect destinations. An administrator can explicitly disable these address safeguards; HTTPS, download limits and deadlines still apply. This does not disable federation signature checks or its protected transport. Browser playback uses the configured public gateways directly and cannot inherit the backend's socket-level checks.

The public upload path does not require a persistent upload token. The server applies administrator enable/disable settings, blocklists, quotas, file-type checks, and file-size limits before issuing a direct Pinata upload URL. public_ipfs_uploads_disabled indicates that the administrator disabled public wallet-signed uploads.

Client-side playback/failover helpers use the existing /api/ipfs/gateways route.

NFT galleries, ads, reactions, support

MethodPathPurpose
GET/api/nft-galleries/editor/:walletNFT gallery editor data.
GET/api/nft-galleries/profile/:walletPublic NFT gallery.
GET/api/reactions/:idReaction counts.
GET/api/ads/key/:keyActive ad by key.
GET/api/ads/slot/:slotActive ads by slot.
POST/api/support/contactCreate a support message/DM request.

Reserved names and registration

MethodPathPurpose
GET/api/reserved-subdomains/checkCheck reserved subdomain status.
POST/api/registrations/domain_registrationSubmit an authorized ENS subname registration request.

Domain registration supports the current signed V3 free/sponsored and user-paid registration flow. The signed request binds the requested label/name, registrant wallet, payment/sponsorship mode, challenge context, and expiry before server submission.

Errors and pagination

Most errors use a JSON object with an error code. Cursor endpoints commonly return next_cursor and may return has_more; cursors are opaque and must stay with the exact route/query that produced them. List-item pagination is the notable offset-based exception and uses next_offset.

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.

Private file sales

All routes below require a fresh EIP-191 wallet signature, including reads. This is a separate authentication protocol from public Spasm events and chat sessions. The service defaults off. Normal calls check effective allowances and the service switch, plus current access-token ownership when the explicit private policy flag requireAccessToken is true; inspection requires both global and seller switches. There is no API-key alternative. See the complete autonomous-agent guide for all response fields and recovery policy.

MethodPathSigned operationAllowed body / query
POST/api/file-sales/uploadsprepare{name, bytes}
POST/api/file-sales/uploads/:id/completecomplete{id}
POST/api/file-sales/salescreate{fileId, price}
POST/api/file-sales/sales/:id/disabledisable{id, cleanup: true}
POST/api/file-sales/sales/:id/enableenable{id}
DELETE/api/file-sales/files/:iddelete{id, confirm: "DELETE_PRIVATE_FILE"}
PUT/api/file-sales/account/inspectionpreference{enabled} boolean
GET/api/file-sales/filesinspectFiles{limit?, cursor?} query
GET/api/file-sales/files/:idinspectFiles{id} from route
GET/api/file-sales/salesinspectSales{limit?, cursor?} query
GET/api/file-sales/sales/:idinspectSales{id} from route
GET/api/file-sales/account/usageinspectUsage{}

Send Content-Type: application/json, X-File-Sales-Envelope containing the JSON envelope, and X-File-Sales-Signature containing the hex EIP-191 message signature. The envelope has exactly these fields:

{
  "version": 1,
  "audience": "https://operator-supplied-audience.example",
  "wallet": "0x1111111111111111111111111111111111111111",
  "operation": "create",
  "method": "POST",
  "path": "/api/file-sales/sales",
  "bodyHash": "<lower-case SHA-256 hex of canonical body>",
  "nonce": "<fresh UUID>",
  "issuedAt": 1792270000000,
  "expiresAt": 1792270060000,
  "requestId": "<persisted logical operation UUID>"
}

The timestamps above illustrate epoch milliseconds; compute them at execution time. The SDK uses a 60-second lifetime. The server rejects expired signatures, issue times more than 30 seconds in the future, and lifetimes longer than five minutes. Nonce and request ID must be 16–128 characters matching [a-zA-Z0-9_-]. Lower-case the wallet. The audience must exactly match the operator's HTTPS configuration, even when it differs from the API base.

Canonical JSON recursively sorts plain-object keys, preserves array order, uses JSON string/number/boolean/null encoding and contains no extra whitespace. Unsupported values such as undefined or non-finite numbers are rejected. Compute bodyHash as the lower-case hexadecimal SHA-256 of the UTF-8 canonical body. Sign the exact UTF-8 message NoEyes private file sales v1, followed by one newline and canonical JSON of the entire envelope, using EIP-191 message signing. Do not sign a pre-hashed string or an Ethereum transaction.

For GET, send no JSON body. The signed body is the query object with string values, plus route id when applicable. Omit undefined query fields; sign every submitted query key. Sort query keys and encode keys and values with encodeURIComponent; include the resulting query in the signed path. Duplicate query keys are rejected. For other methods, hash the JSON body with the route's actual id injected. Do not send expiry, buyer or recipient fields: unknown parameters are rejected.

Prepare returns a short-lived private upload URL. Transfer a multipart file and network=private directly to that URL, without NoEyes signature headers or Pinata JWT. Then complete the reservation through NoEyes; this verifies the actual private file before it can be sold. Only share the purchaseUrl returned after successful sale creation. Prices are fixed positive USDC strings with up to six decimal places; recipient is the signing wallet, payment mode is direct and fee units are zero.

Reuse a logical API request ID only with the identical operation, path and parameters; generate a fresh nonce and signature on every attempt. Preserve IDs durably. The server can replay completed results or resume pending work, while current permissions still apply. Definitive client errors accepted into the operation journal can be terminal; a new authorized attempt after correcting them requires a new ID. A new ID does not resolve uncertain provider state. Never automatically retry a direct file transfer after an unknown outcome; resolve the existing upload through completion.

Disable with cleanup: true and delete with the confirmation above bypass token ownership, product quotas and the service switch. They still require a fresh owner signature and remain subject to transport protection and provider availability. Enable, completion and inspection have no cleanup bypass. Responses use Cache-Control: no-store. The transport limit is separate from product allowances. File/sale pages return {items, nextCursor}, default 50 and maximum 100; usage returns {storedBytes, reservedBytes, limits}. Neither retention scheduling nor public catalogue access is part of this API.

Feed category selection

GET /api/categories returns all active category metadata by default. Add ?feed_only=true to return only categories also marked allow_in_feed: true. The SDK exposes this as client.categories.list({feedOnly: true, signal}); omitting the option preserves default discovery. No wallet, API key or cache is used.

client.ubnet.feed.posts.list({category, cursor, limit}) sends the optional single category to GET /api/ubnet/posts/feed. Selection narrows the approved feed and applies to imported posts too. An unavailable category returns an empty page, including when approval is revoked between pages. Empty/omitted category selects the ordinary feed. Category strings are normalized to lower case; repeated/structured values or values over 120 characters return invalid_category (400). Keep cursors within the same category and filters, and start at the first page on selection changes.

Post edit history

GET /api/posts/:id/history returns { root_id, has_edits, edit_count, versions } in API 6.4.1. Visibility/publication-only updates and repeated unchanged text/media do not count. The original post remains in versions; clearing previously populated text or media does count. Sparse changes are compared with their parents, raw signed events remain stored, and is_latest marks the newest returned content revision. Use has_edits to show View History.