API Reference

Everything a new developer needs to build against Ogmara: REST endpoints, the WebSocket protocol, Klever message signing, the on-chain smart-contract interface, rate limits, and the @ogmara/sdk JS/TS SDK — grouped by topic, not by tutorial order.

l2-node 0.130.0 smart-contract 0.10.3 @ogmara/sdk 0.62.0 protocol v2

Overview

Ogmara is a three-layer system: the Klever mainnet holds identity, registration, and anchoring; the L2 node network (Rust, one binary, community-run) serves the REST/WebSocket API that clients actually talk to; IPFS stores media. There is no single "Ogmara API server" — every node exposes the same API, and you pick (or let a user pick) which node to talk to.

Most of what you build — a bot, a widget, a client, an integration — talks to a node's REST/WebSocket API, optionally through the @ogmara/sdk package. The smart contract is consulted directly only for identity/registration proofs, governance, or node discovery.

Base URL & versioning

Client-facing REST/WebSocket routes live under /api/v1/ on whichever node you connect to, e.g. https://your-node.example.com/api/v1/channels. Node-operator/admin routes (see Admin & Dashboard API) live under /admin/ with no version prefix — they're a separate surface, gated to the node operator, not part of the public client API.

Signed message envelopes (chat, news, DMs, etc.) carry their own protocol_version, currently 2. A verifying node always recomputes hashes against its own configured network_id rather than trusting a transmitted one, which is what keeps testnet and mainnet signatures from being replayable against each other.

There's no fixed hostname to hardcode. Discover live nodes via the Network page, the SDK's discoverNodesViaSc() (reads the smart contract's getActiveNodes view directly), or a node's own GET /api/v1/network/discovery/bootstrap-candidates. Let users pick a node, or default to one you operate.

Quick start

Raw HTTP — check a node is alive and list its public channels:

curl https://your-node.example.com/api/v1/health
curl https://your-node.example.com/api/v1/channels

With the SDK — connect, list channels, send a signed message:

import { OgmaraClient, WalletSigner } from '@ogmara/sdk';

const client = new OgmaraClient({ nodeUrl: 'https://your-node.example.com' });
client.withSigner(new WalletSigner(privateKeyBytes));

const { channels } = await client.listChannels();
await client.sendMessage({ channelId: channels[0].channel_id, text: 'Hello from the SDK!' });

Klever message signing

Every wallet-authenticated request — REST auth headers, WebSocket auth, device delegation/claims — is signed with the same message scheme Klever wallets already implement (verified against klever-io/kos-rs):

1. Prefix:  "\x17Klever Signed Message:\n"   (0x17, 24 bytes total)
2. Length:  decimal ASCII string of message.length
3. Message: raw message bytes
4. Hash:    Keccak-256(prefix + length + message)
5. Sign:    Ed25519_Sign(private_key, hash)  -> 64-byte signature

Addresses are bech32-encoded 32-byte Ed25519 public keys: klv1... for wallets, ogd1... for delegated device keys (BIP44 path m/44'/690'/0'/0'/{index}'). This is a distinct scheme from raw Klever transaction signing (hex-decoded tx hash, no prefix, signed directly) — don't mix the two up.

REST auth headers

Every wallet-signature route below requires these four headers:

HeaderValue
X-Ogmara-Authbase64(64-byte Ed25519 signature)
X-Ogmara-Addressklv1... or ogd1...
X-Ogmara-TimestampUnix time in milliseconds
X-Ogmara-Nonceclient-chosen single-use hex string, 16–128 chars

The signed string is host-bound (hard cutover 2026-08-26 — the older unbound format is rejected):

"ogmara-auth:" + network + ":" + node_id + ":" + nonce + ":" + timestamp + ":" + method + ":" + path

network and node_id come from the verifying node's own GET /api/v1/health response — that's what stops a signature captured on one node/network from being replayed against another. The nonce (tracked server-side) stops same-node replay. Timestamps are accepted up to 60s in the past and only 5s of clock skew into the future.

There is no session token for the regular API — every request is independently signed. (The admin dashboard has its own short-lived session cookie; see Admin & Dashboard API.)

Device delegation

Apps normally shouldn't ask a user for their wallet's raw private key on every request. Instead, register a per-device key delegated by the wallet once, then sign ongoing requests with the device key:

claim = "ogmara-device-claim:" + devicePubkeyHex + ":" + walletAddress + ":" + timestamp

Submit a dual-signed claim to POST /api/v1/devices/register: wallet_signature (signed by the wallet key, proves authorization) and, since l2-node 0.49.0, an optional device_signature (signed by the device key, proves possession). When both are valid the node gossips the delegation network-wide — no on-chain transaction required. Max 10 devices per wallet. All API responses (author fields, etc.) always resolve back to the canonical wallet address, never the raw device key.

WebSocket auth

Send an auth JSON frame as your first WebSocket message, within 10 seconds of connecting, or the node closes the connection with a 401 error frame:

{
  "address": "klv1...",
  "timestamp": 1735689600000,
  "signature": "base64(64 bytes)",
  "nonce": "hex-16-to-128-chars"
}

Signed with the same scheme as REST auth, bound to the literal path /api/v1/ws. On success the node auto-subscribes your wallet's DM topic (so DMs land even while you were offline) and registers you for mention notifications. See WebSocket API for the full frame contract.

Envelope signing (node-to-node)

Chat messages, news posts, DMs, reactions, etc. are submitted as signed envelopes — a different, more compact scheme than the REST auth headers, because these get gossiped node-to-node as well as submitted by clients:

signed_bytes = "ogmara-msg:" + network_id_len(1B) + network_id
             + version(1B) + msg_type(1B) + msg_id(32B)
             + timestamp(8B BE) + payload(msgpack)
signature    = Ed25519_Sign(priv, Keccak-256(signed_bytes))

msg_id = Keccak-256(network_id_len + network_id + author_pubkey + payload + timestamp)

In practice you don't hand-build these — the SDK's envelope builders (buildChatMessage, buildNewsPost, etc.) do it for you, then you hand the bytes to POST /api/v1/messages or the WebSocket {"type":"message",...} frame.

Use caseFormatSigned by
REST/WS auth headers, device claimsKlever message signingwallet or device key
On-chain transactionsKlever TX signing (raw hash, no prefix)wallet key
Chat/news/DM/etc. envelopesOgmara node-to-node formatdevice key (or wallet if undelegated)

REST API — Channels

Auth key: none public · optional richer response if signed, never rejects · wallet required.

MethodPathAuthRate limitDescription
GET/channelsoptional100/min (IP)List channels, paginated; private channels shown only to members
GET/channels/by-slug/{slug}optional100/min (IP)Look up a channel by its human-readable slug
GET/channels/{channel_id}optional100/min (IP)Channel metadata, moderators, pinned messages, member count
GET/channels/{channel_id}/messagesoptional100/min (IP)Paginated message history, before/after cursors, limit ≤500
GET/channels/{channel_id}/membersoptional100/min (IP)Member list, filterable by role
GET/channels/{channel_id}/pinsoptional100/min (IP)Pinned messages (max 10 per channel)
GET/channels/{channel_id}/botsoptional100/min (IP)Bots in the channel and the commands they answer to — the source for the /-autocomplete. Banned/muted bots filtered. Check scan_capped/result_capped. l2-node 0.127.0+
GET/channels/{channel_id}/banswallet100/min (IP)Ban list (response shape not fully spec'd — verify against a live node)
GET/channels/unreadwallet100/min (IP)Per-channel unread + mention counts, capped at 99 each
POST/channelswallet100/minCreate a channel — signed ChannelCreate envelope
DELETE/channels/{channel_id}wallet30/hrCreator-only, local tombstone only — does not propagate. Send a signed ChannelDelete envelope instead for a real network-wide delete
POST/channels/{channel_id}/readwallet100/minMark a channel read
POST/channels/{channel_id}/moderatorswallet10/dayAdd a moderator (creator only)
DELETE/channels/{channel_id}/moderators/{address}wallet10/dayRemove a moderator (creator only). Note the address is a path segment, not a body field — the two verbs use different paths.
POST/channels/{channel_id}/kick/{address}wallet30/hrRemove a member
POST / DELETE/channels/{channel_id}/ban/{address}wallet30/hrBan / unban a member
POST / DELETE/channels/{channel_id}/mute/{address}wallet30/hrTimed or permanent mute (duration_secs: 0 = permanent)
POST / DELETE/channels/{channel_id}/pin/{msg_id}wallet20/hrPin / unpin a message (max 10 pins)
POST/channels/{channel_id}/invite/{address}wallet20/hrInvite a user to a private channel
POST/channels/{channel_id}/federatewallet100/minFederate with a channel hosted on another node (SSRF-hardened fetch); per-wallet cap 256
GET / POST/channels/{channel_id}/keyswallet100/minE2E channel-key envelope distribution (encrypted channels only)
The spec describes a GET /channels/{channel_id}/threads endpoint and a thread_root query filter on the messages endpoint. Neither exists in the current node — the wire format supports a thread_root field on chat messages, but nothing in the REST layer serves it yet. Don't build against it.

Example — create a channel

const { channel_id } = await client.createChannel({
  slug: 'my-community',
  channelType: 'public',
});

REST API — Messages & DMs

MethodPathAuthRate limitDescription
POST/messageswalletby category — see Rate limitsSubmit any signed message envelope (chat, news, DM, reaction, etc.)
PUT/profilewallet100/minUpdate display name / avatar / bio via signed ProfileUpdate
POST/dm/{address}wallet30–60/minSend a direct message
GET/dm/conversationswallet100/min (IP)Conversation list with last-message preview and unread counts
GET/dm/{address}/messageswallet100/min (IP)Paginated DM history with one peer
POST/dm/{address}/readwallet100/minMark a conversation read
GET/dm/unreadwallet100/min (IP)Unread count per conversation, capped at 99
GET / POST/users/{address}/enc-keysnone / wallet10/hr (POST)Publish or fetch a wallet's E2E device encryption keys
GET/keys/{key_scope}wallet100/min (IP)Fetch a specific encrypted key envelope by scope/epoch
GET/settingswallet100/min (IP)Encrypted cross-device settings blob (publish via SettingsSync envelope). Since l2-node 0.125.0 it replicates to every node (gossiped on the profile topic) — you get the same blob on whichever node you connect to, and switching nodes never changes it. The node last-writer-wins by the cleartext updated_at in the payload (see SDK v0.54.0 below).
GET/key-vaultwallet10/minEncrypted key-recovery vault (publish via KeyVaultSync envelope). Also replicates across every node since l2-node 0.125.0, so a fresh device restores it from any node. Same cleartext updated_at LWW.
GET/account/exportwallet100/min (IP)Full data export: profile, posts, memberships, bookmarks, DM ciphertext, follow graph, settings
GET/notificationswallet100/min (IP)Mentions and other notifications, 30-day retention, capped at 1000. Optional ?type= filter (e.g. channel_invite) widens the node's internal scan so a low-volume type isn't silently starved out of the page by a high-volume one like mention (l2-node 0.129.0+, SDK v0.59.0+). channel_invite (l2-node 0.128.0+, SDK v0.58.0+) fires when your wallet is invited to a channel and carries an optional anchor_node — the inviting client's own node URL (l2-node 0.130.0+, SDK v0.60.0+) — federate via that host first if your own node has never heard of the channel yet.
POST/devices/registerwallet or device100/minRegister a delegated device key — see Device delegation
DELETE/devices/{device_address}wallet100/minRevoke a device
GET/deviceswallet100/min (IP)List a wallet's registered devices

Example — send a channel message

await client.sendMessage({ channelId: 42, text: 'gm' });
// or send a DM
await client.sendDm({ address: 'klv1...', text: 'hey' });

REST API — News & Feed

MethodPathAuthRate limitDescription
GET/newsoptional100/min (IP)Public feed, newest first; cursor pagination via ?before=/?after= (hex msg_id, l2-node 0.123.0+); hashtag filter ?tag=<t> or OR-set ?tags=t1,t2,… (max 50, tags wins, l2-node 0.124.0+) — tags are normalized to [a-z0-9-]{1,64}; response carries has_more; enriched with reaction/repost/comment counts
GET/news/hot-topicsnone100/min (IP)Trending news hashtags across the network over a rolling 24h window, with usage counts (l2-node 0.124.0+). { "scope": "network" | "local", "topics": [ { "hashtag", "count" } ] }, sorted by count desc. count is a network-wide distinct-post estimate; scope: "local" means a fresh/partitioned node still converging. ?window=24h (only value accepted) and ?limit= (default 50, capped at 100). 60s server-side cache — poll no faster.
GET/news/{msg_id}optional100/min (IP)A single post plus its comments
GET/news/{msg_id}/reactionsoptional100/min (IP)Reaction counts by emoji
GET/news/{msg_id}/repostsoptional100/min (IP)Who reposted this post
POST/news/{msg_id}/reactwallet60/minReact with an emoji
POST/news/{msg_id}/repostwallet10–30/hrRepost, optionally with a comment
GET/feedwallet100/min (IP)Personalized feed — posts from wallets you follow, merged newest first; same ?before=/?after= hex-msg_id cursors and has_more as /news (l2-node 0.123.0+)
GET/bookmarkswallet100/min (IP)Your bookmarked posts
POST / DELETE/bookmarks/{msg_id}wallet100/minBookmark / unbookmark a post
GET/users/{address}/postsoptional100/min (IP)A user's post history
NewsPost/NewsComment have their own dual burst+sustained rate-limit tier — see Rate limits. Envelope objects returned from these endpoints also carry repost_count, comment_count, and (for reposts/comments) a preview of the original/parent post — added in sdk-js 0.50.0's Envelope type.

REST API — Users, Identity & Presence

MethodPathAuthDescription
GET/users/searchnoneSearch by display name / address prefix, query q (1–64 chars)
GET/users/{address}nonePublic profile + follower/following counts (empty-profile fallback, never 404)
GET/users/{address}/followersnoneFollower list, paginated
GET/users/{address}/followingnoneFollowing list, paginated
POST / DELETE/users/{address}/followwalletFollow / unfollow
GET/network/identitynoneThis node's peer ID, network, version, public URL
GET/network/presencenoneLive presence-gossip cache: every peer this node currently hears from
GET/network/presence/{peer_id}noneA single presence record, 404 if unknown/expired

REST API — Moderation

MethodPathAuthDescription
GET/moderation/reports?target={msg_id}noneReports + counter-votes for a message, plus a computed score
GET/moderation/user/{address}noneReputation score: account age, report ratio, counter-votes received

Reports and counter-votes themselves are submitted as signed envelopes via POST /messages, and require a verified (on-chain-registered) identity.

REST API — Network & Node Discovery

MethodPathAuthDescription
GET/healthnoneLiveness + node_id/network (needed to build the REST auth string)
GET/network/statsnoneTotals (messages, channels, users), uptime, protocol version, anchor status
GET/network/nodesnoneKnown nodes merged from the peer directory + live libp2p connections
POST/pow/challengenone100/min (IP)Request an anti-spam proof-of-work challenge. Unknown wallets (no on-chain registration, no prior solve) must solve one before their first write — a handler answers {"error":"pow_required","challenge":{...}} and this is where you fetch a fresh one. pow.ts in the SDK solves it for you.
POST/pow/verifynone100/min (IP)Submit a solved challenge. On success the wallet is recorded as known and skips PoW on subsequent writes.
GET/registration/infononeWhat it costs to verify a wallet on this node (l2-node 0.126.0+): the live registration_fee, the node_fee_share_bps split, and the operator_address to credit as register’s optional second argument. Read it before building the transaction — the fee is set by node governance and changes with no client release. registration_fee is a decimal string of raw units; every fee field is null when the node has no contract configured, meaning unknown, not free. 60s cache.
GET/network/discovery/bootstrap-candidatesnoneRanked candidate list for cold-start bootstrap (peer book / config / on-chain union, 5-min cache)

Example — bootstrap discovery

curl https://some-known-node.example.com/api/v1/network/discovery/bootstrap-candidates

WebSocket API

WS /api/v1/ws — authenticated realtime stream. Send the auth frame first. Client → server frames:

FrameEffect
{"type":"message","envelope":{...}}Submit a signed envelope (same router as POST /messages), then gossip it
{"type":"dm","envelope":{...}}Same, for direct messages
{"type":"subscribe","channels":[...]} / unsubscribeAccepted, but currently a no-op — see the callout below

Server → client frames:

FrameWhen
{"type":"message","envelope":{...}}A new message in any public channel, or in a private channel/DM you're a party to
{"type":"dm","envelope":{...}}A new direct message addressed to you
{"type":"notification","mention":{...}}You were mentioned, or invited to a public channel — the node reuses this same mention key for every broadcast notification type, so check the nested object's own type field rather than assuming its shape. A private-channel invite never arrives this way at all: the all-clients broadcast is skipped entirely for it (would otherwise leak the invite to every connected client), and it is delivered only via the persisted, per-recipient GET /api/v1/notifications (and the push gateway, if configured) — poll or check on reconnect rather than waiting on this frame for it.
{"type":"channel_members_changed","channel_id":..,"action":"join"|"leave"|"kick"|"ban",...}Membership change in a private/encrypted channel you're in
{"type":"channel_deleted","channel_id":..}A channel you were in was deleted
{"type":"settings_changed"}Your cross-device settings blob was rewritten with a newer copy (l2-node 0.124.0+) — payload-free nudge to re-fetch GET /api/v1/settings. Since l2-node 0.125.0 this also fires when the change originated on a different node (the write reached here over gossip), so your other devices converge without waiting for a re-login. The blob stays E2E-encrypted.
{"type":"error","code":<u16>,"message":"..."}Auth failure or malformed frame

There's also WS /api/v1/ws/public — no auth required, read-only, receives public-channel traffic only (never DMs or notifications). Useful for read-only widgets that shouldn't need a wallet.

subscribe/unsubscribe frames are accepted but not enforced server-side — every connection receives every public-audience broadcast regardless of what it "subscribed" to. Filter by channel_id client-side. Don't rely on the server to scope traffic for you.

IPFS & Media

Upload: POST /api/v1/media/upload, multipart/form-data, field file (add encrypted=1 for opaque E2E blobs). Response: {cid, size, thumbnail_cid}. 50MB default cap, MIME allowlist + magic-byte verification, EXIF stripped, thumbnails auto-generated for images/video.

Retrieval: GET/HEAD /api/v1/media/{cid} — the node itself is the gateway (there's no separate public ipfs.io/ipfs/{cid}-style URL to construct). Supports Range requests, content-addressed ETag, and forces SVG/HTML to download rather than render inline (stored-XSS defense). Falls back to other nodes' pinned copies before generic Bitswap.

For E2E-encrypted attachments, the file key/nonce travel inside the already-encrypted message payload — there's no separate key-delivery step. Decrypt the message, then fetch and AEAD-decrypt the referenced CID with aad="ogmara-media-v1".

Check GET /health's media_uploads boolean before showing upload UI — a node with no working IPFS backend returns 503 on upload.

Rate Limits

Two independent layers apply. Per-IP, at the HTTP layer, before auth: 100 requests/min by default (api.rate_limit_per_ip), keyed on the real client IP even behind a reverse proxy.

Per-wallet, per message category, enforced only on live submission/gossip (the historical sync/backfill path is intentionally exempt):

CategoryApplies toUnverified walletRegistered (on-chain) wallet
Chat messagesChat/DM send, edit, delete30/min60/min
News posts (burst)News post/edit/delete, comments5 / 10min20 / 10min
News posts (sustained)same50/day300/day
ReactionsChat/DM/news reactions60/min—
RepostsNews repost10/hr30/hr
Channel adminKick/ban/mute/delete30/hr—
Moderator changesAdd/remove moderator10/day—
Channel invitesInvite to private channel20/hr—
Pin / unpinMessage pinning20/hr—
Key vault syncEncrypted vault upload10/min—
Device enc keysBind/revoke encryption keys10/hr—
Other (fallback)Channel create, profile update, bookmarks, etc.100/min—

"Registered" means the wallet has an on-chain registered_at > 0 (checked via the SC, cached 60s). Media retrieval has its own separate concurrency cap (32 concurrent globally, 4 per IP) on top of the per-IP request-rate limit. New/unverified wallets posting for the first time may be asked to solve a proof-of-work challenge (SHA-256, ~2–3s) before their first few messages count — this is anti-spam, not a permanent gate.

Error Handling

There is no single error envelope across this API. Many handlers return a plain-text body with just an HTTP status code (404 channel not found). Others return JSON, in varying shapes: {"error": "..."}, {"ok": false, "error": "..."}, or a PoW-specific {"error":"pow_required","challenge":{...}}. Check Content-Type before parsing a body as JSON. The one consistent shape is the WebSocket error frame: {"type":"error","code":<u16>,"message":"..."}.

Successful mutations mostly return {"ok": true}, sometimes with extra fields ({"ok":true,"msg_id":"...","channel_id":...} for channel creation, for example) — check each endpoint's row above for specifics.

Smart Contract Interface

The KApp smart contract (klever-sc, currently v0.10.3) is the source of truth for identity registration, channel ownership, device delegation, state anchoring, node registration, and governance. Most app data lives on L2 and is read through the REST API above — go to the SC directly only when you need an independent, on-chain-verifiable answer.

Key mutating endpoints

EndpointArgsPurpose
register (payable)public_key, via_node (optional)On-chain identity registration (“verification”). Costs the Klever network fee (~4.4 KLV) plus the governance-set registration_fee — read it live from getRegistrationFee or the node’s /registration/info, never hard-code it. via_node is an OptionalValue encoded by presence: append the node operator’s address to credit them node_fee_share_bps of the fee, or omit it entirely to route the whole fee to the treasury. A one-argument call from a pre-0.10.0 client still decodes.
claimNodeEarnings—Node operator claims the registration-fee share accrued from users who verified through their node. Permissionless and strictly self-crediting; works even after unregisterNode, since earnings are already earned.
createChannel (payable)slug, channel_typeOn-chain channel skeleton (public/read-public only — private channels are L2-only)
delegateDevice / revokeDevicedevice_pub_key, permissions, expires_atOn-chain device delegation (distinct from the free L2 path in Device delegation)
anchorStateblock_height, state_root, counts, node_idSubmit an L2 state-root anchor; canonical once 3 anchorers agree
tip (payable)recipient, msg_id, channel_id, noteForward a KLV tip directly to a content author
createProposal / votedescription, param_key, param_value, voting_period_secondsUser-track governance. description ≤ 512 bytes, param_key ≤ 64 bytes (klever-sc 0.10.3+); param_key must be one of the votable keys or the call reverts Unsupported parameter key.
registerNode / unregisterNode (payable)—Node operator registration
createNodeProposal / nodeVote / executeNodeProposaldescription, param_key, param_value, voting_period_secondsNode-track governance — execution is permissionless, not owner-gated. Same description ≤ 512 / param_key ≤ 64 byte bounds as the user track; voting_period_seconds is 7–30 days here.

Key views

ViewReturns
isRegistered(address)Whether a wallet is on-chain registered
getRegistrationFee()Current user verification fee, raw KLV units. 0 means free. Governance-controlled — read before every register.
getNodeFeeShareBps()Share routed to the referring node, in basis points (capped on-chain at 8000 = 80%)
getNodeEarnings(address)Unclaimed KLV accrued to that node operator
getTotalUnclaimedNodeEarnings()Network-wide unclaimed operator earnings
getCanonicalAnchor(block_height)Quorum-agreed state root for a height — prefer this over the legacy getStateRoot
getEscalatedCanonical(block_height)Materialized tiebreak result for a height where two roots both reached quorum
getActiveNodes(offset, limit)(address, last_anchor_at) pairs, excludes paused nodes — the primary bootstrap discovery view
getNodeMetadata(address)Published libp2p multiaddrs for a node
getStats()(user_count, channel_count, protocol_version)
If a competing state root also reaches quorum at a height that already has a canonical one, the SC raises the bar (divergence_escalated) instead of silently flipping. Anyone can call resolveTiebreak(block_height) after the grace window to deterministically lock a winner on-chain.

SDK — @ogmara/sdk (JS/TS)

npm install @ogmara/sdk

OgmaraClient is the main entry point (~85 methods, one per REST endpoint above). Attach a signer to unlock wallet-authenticated calls:

import { OgmaraClient, WalletSigner } from '@ogmara/sdk';

const client = new OgmaraClient({ nodeUrl: 'https://your-node.example.com' });
client.withSigner(new WalletSigner(privateKeyBytes));
ModuleWhat it's for
client.ts — OgmaraClientEvery REST call above: channels, messages, DMs, news, follow graph, moderation, devices, media, notifications, account export
auth.ts — WalletSignerBuilds the 4 REST auth headers per request; supports both direct wallet keys and delegated device signers
envelope.tsBuilders for every signed envelope type — buildChatMessage, buildNewsPost, buildFollow, buildChannelCreate, etc.
crypto.ts / encryption.ts / dm.ts / media.tsE2E primitives: X25519 key agreement, AEAD, DM content encrypt/decrypt, media file/thumbnail encryption
keyVault.tsSeal/open the encrypted key-recovery vault
pow.tsSolve anti-spam proof-of-work challenges
ws.tssubscribe({nodeUrl, channels, onEvent}) helper over the raw WebSocket API
sc_discovery.tsdiscoverNodesViaSc() — reads getActiveNodes directly for cold-start bootstrap
sc_queries.ts (v0.47.0+)General-purpose on-chain reads for reconciliation/verification tooling — not the primary data path for normal app data

v0.50.0: the Envelope type gained optional repost_count/comment_count, parent-post preview fields for comments, and a full repost preview (original_id, original_author, original_content, etc.) — purely additive, all optional.

v0.51.0: three fixes. Envelope.payload/signature are now correctly typed number[] — they were never base64 despite the old type saying so; every REST/WS read returns them as a raw byte array, matching the node's Vec<u8>. addressToPubkey now verifies the bech32 checksum and the decoded length (previously a mistyped address character could silently decode to a different, still-valid-looking key instead of throwing). And every OgmaraClient HTTP read is now capped at 100 MB of response body, so a misbehaving or compromised node can't force unbounded memory use before your code gets control back.

v0.52.0: cursor pagination for the news feeds (needs l2-node 0.123.0+). listNews() now also accepts a NewsFeedOptions object — listNews({ before }) returns the page of posts strictly older than that hex msg_id, listNews({ after }) the page strictly newer (after wins if both are set). getFeed() gains the same before/after cursors, and both responses carry an optional has_more. The positional listNews(page, limit, tag) form still works; a numeric getFeed({ before }) is accepted for source compatibility but no longer paginates.

v0.53.0: News Feed topic tooling (needs l2-node 0.124.0+). client.getHotTopics() returns the network's trending hashtags over a rolling 24h window with usage counts ({ scope, topics: [{ hashtag, count }] }); it degrades to an empty local result on a node too old to expose the endpoint. normalizeHashtag(raw) is exported — the canonical tag form (trim, lowercase, strip a leading #, require [a-z0-9-]{1,64}, else null), byte-for-byte identical to the node; always run a tag through it before a follow/filter. listNews({ tags }) adds an OR-set hashtag filter (any of up to 50, deduped, wins over tag); listNews({ tag }) and extractHashtags now normalize with it, so { tag: 'Klever' } matches indexed klever and an underscore tag is no longer surfaced.

v0.54.0: cross-node settings sync (needs l2-node 0.125.0+). SettingsSyncData and KeyVaultSyncData gain an optional updated_at (ms epoch, cleartext) threaded into the SettingsSync / KeyVaultSync payloads. l2-node 0.125.0 gossips both per-wallet blobs across the mesh and uses updated_at as its last-writer-wins key. Pass the max updatedAt across your own synced objects (the edit time of the content, not "now") — that is what lets a device re-upload an older copy to seed a fresh node without rolling back newer content elsewhere. Omit it and the builder defaults to Date.now() and the node falls back to the signed envelope timestamp.

v0.55.0: read the user registration fee before registering (needs l2-node 0.126.0+ and smart-contract 0.10.0+). client.registrationInfo() returns the live fee, the node/treasury split, and the operator address to credit as register's optional second argument. Four on-chain accessors — getRegistrationFee, getNodeFeeShareBps, getNodeEarnings, getTotalUnclaimedNodeEarnings — cover consumers with no L2 node to talk to. Three things worth knowing before you integrate: the balance accessors return bigint, not number (raw KLV amounts have no on-chain ceiling and would lose precision past 253); registration_fee is a decimal string of raw units while node_fee_share_bps is a number; and every fee field is null when the node has no contract configured, which means the fee is unknown, not zero — treat null as “let the transaction show the real amount”, never as “free”. Pin contract_address from your own config and use the node’s only to cross-check.

v0.56.0 — security: OgmaraClient now sends every request with cache: 'no-store'. The browser/webview HTTP cache keys a GET response by URL alone, blind to the signed X-Ogmara-Auth/X-Ogmara-Address headers that actually scope it to one wallet — on a client that holds multiple wallets and switches between them in one session, a request to the same path under a NEW identity could be silently served the PREVIOUS identity's cached response, private channels included, with no network round trip and no re-validation. Every client built on this SDK inherited this bug before 0.56.0 — update immediately if you hold more than one wallet identity per session.

v0.57.0: bot self-declaration and the /-command picker (protocol §3.11; needs l2-node 0.127.0+). ProfileUpdateData.bot carries a BotDescriptor { is_bot, handle, commands } — omitting it means unchanged, never “clear”, so an ordinary display-name edit cannot wipe a bot's command list; clear explicitly with clearBotIdentity(). client.setBotCommands({ handle?, commands }) validates caps locally (validateBotDescriptor, also exported) before signing. client.getChannelBots(channelId) is the data source for GET /channels/{channel_id}/bots above — check its scan_capped/result_capped. parseCommand(message, myAddress, myHandle?) is the bot author's half of the contract: parses /name and /name@handle, splits arguments, and answers “is this addressed to me”. Call setBotCommands() unconditionally on every start rather than tracking what you last published — that local state desyncs after a node wipe or a dropped gossip message, and the node suppresses its own broadcast when nothing changed, so republishing costs nothing. See Known Gaps for two bot-author caveats (private-channel read access, per-invoker rate limiting). v0.57.1 followed immediately with three fixes an npm install today already includes: a spread-order bug in setBotCommands() that could silently WIPE a bot's identity instead of setting it, a bot answering commands explicitly addressed to a different bot, and descriptor caps measured in UTF-16 units instead of the node's UTF-8 bytes (undercounting CJK/Cyrillic/emoji).

v0.58.0: Notification.type gains 'channel_invite' and an optional channel_name (l2-node 0.128.0+) — see the notifications row and WebSocket API above for the full behavior, including the private-channel WS-suppression quirk.

v0.59.0: getNotifications gains an optional third type argument (l2-node 0.129.0+) — see the notifications row above.

v0.60.0: inviteUser/buildInvite now populate ChannelInvitePayload.anchor_node with your own node URL (l2-node 0.130.0+) — only when it's a public https:// address, since the receiving node's federate SSRF guard requires one; a local/dev node is omitted rather than sending an unusable value. Notification.anchor_node (channel_invite only) is the corresponding read side.

v0.60.1: Notification.channel_name/anchor_node widened to string | null (was string | undefined) — the node's JSON serializes an absent value as null, not an omitted key, so a consumer checking only !== undefined let a real null straight through. Update the type check in your own code if you narrowed on undefined against either field.

v0.61.0: message buttons (protocol §3.3; needs l2-node 0.131.0+). Any wallet can attach a row/grid of interactive buttons to a chat message — each carries a label (shown) and a literal command (sent verbatim as content when pressed). client.sendMessage(channelId, content, { buttons }) attaches a row (validated locally via validateButtons, also exported, before signing, mirroring validateBotDescriptor). client.pressButton({ channelId, msgId, author }, command) sends the press — an ordinary signed ChatMessage with via_button: true and reply_to pointing at the origin message, not a new message type, so it inherits attribution/moderation/rate-limits for free; read channelId/msgId/author fresh from the message you're currently rendering, never a cached copy. client.editMessage(channelId, msgId, content, { buttons }) is the button lifecycle mechanism (protocol §3.7) — omit buttons to leave a row unchanged, pass [] to clear it, or a new array to replace it wholesale (a bot swapping in a sub-menu in place, or disabling a row after use). Security note surfaced by this release's audit, worth building into your UI: a button's label and command are independent strings and pressing signs command under the presser's own wallet with no confirmation — a button reading “Show chart” can carry command: "/ban someone". pressButton is plaintext-channel only, matching sendMessage's existing limit; for an encrypted/private channel, build the press with buildEncryptedChannelMessage({ ..., viaButton: true }) (now also buttons-aware) and send it via sendMessageEnvelope.

Latest (v0.62.0): buildEncryptedChannelEdit({ channelId, msgId, convKey, epoch, text, media?, buttons? }) closes the encrypted half of the button lifecycle mechanism above (needs l2-node 0.133.0+) — client.editMessage() stays plaintext-channel only, matching sendMessage/pressButton's existing limit, so editing a message's text in an encrypted/private channel (the default for new Public/ReadPublic/Private channels) needs this builder, sent via sendMessageEnvelope the same way an encrypted press does. The node cross-checks an edit's encrypted-or-plaintext shape against the message it targets and rejects a mismatch — there is no way to flip a message's encryption status via an edit. If the message you're editing carries encrypted media, pass the same media descriptors you already have from decrypting it (an earlier build of this method dropped them silently, permanently stranding that media's decryption keys the first time anyone edited the message's text — caught and fixed before release, but worth knowing why the parameter is there).

Admin & Dashboard API

Everything under /admin/ is for the person running a node, not for building end-user apps — it's how the built-in operator dashboard works, and how node-track governance gets exercised. Localhost bypasses auth entirely; remote access requires a signed-challenge session (GET /admin/auth/challenge → POST /admin/auth/login → ogmara_session cookie, 24h TTL, invalidated on every node restart).

AreaKey endpoints
MetricsGET /admin/metrics/snapshot, /metrics/history, /metrics/peers, /metrics/storage; live stream via WS /admin/dashboard/ws (pushes every 2s)
AlertsGET /admin/alerts/history, /alerts/config, POST /admin/alerts/test (6/min, burst 3)
Node lifecycleGET /admin/node/registration, /node/metadata, /node/pause-status; POST /admin/node/pause/resume (returns unsigned calldata — the node never signs on your behalf)
Network internalsGET /admin/network/mesh-stats, /network/peer-telemetry, /peers
Governance (node track)GET /admin/governance/node/proposals, POST /admin/governance/node/{create-proposal,vote,execute-proposal} — the node signs and broadcasts with its own anchor wallet; dedicated rate limit 10/min, burst 5. Since l2-node 0.126.6 a contract-side failure (a require! revert or VM error during the build simulation) is returned verbatim in the error field instead of the transaction being broadcast and rejected with a misleading fee error.
Snapshot syncGET /admin/snapshot/status

See the Admin Dashboard tutorial for the operator-facing walkthrough.

Known Gaps & Spec Drift

This reference is checked against the current node/SC/SDK source, not just the specs, and a few things are worth flagging explicitly rather than glossing over:

  • The /channels/{channel_id}/threads endpoint and thread_root query filter are speced but not implemented — see the callout under Channels.
  • WebSocket subscribe/unsubscribe are accepted but not enforced — see WebSocket API.
  • There is no unified error response shape — see Error Handling.
  • The public WebSocket's documented "5 concurrent connections per IP" limit has no corresponding implementation as of l2-node 0.122.1 — don't rely on it being enforced.
  • GET /news/{msg_id}/reactions's exact response shape may differ subtly from the flat reaction_counts map used elsewhere — verify against a live node before hardcoding a parser.
  • callValue must be a JSON number, never a string. The Klever node refuses a string at JSON decode (cannot unmarshal string into Go struct field SmartContractRequest.callValue of type int64) — confirmed against live testnet on both the browser-extension and direct-RPC signing paths. An empty {} is correct only when no value is attached, which is why this stayed hidden until the first payable call shipped. Use { KLV: 100000000 }, not { KLV: "100000000" }.
  • Pin the contract address in your own config, per network. A node reports contract_address in /network/stats and /registration/info, but anyone may run a node — and since register now carries a real KLV payment, a hostile operator naming their own contract would receive the fee outright, with no refund. Treat the node's value as a cross-check only, and never attach value to an address you did not pin.
  • A bot invited into a private channel can read every message in it. Private channels are force-encrypted with a symmetric epoch key, so there is no key that decrypts one message and not the rest — a bot that can read its own commands can read the whole conversation. This cannot be prevented today; clients disclose it at invite time. Joiners receive only the current epoch key, so history from before the invite stays sealed, and removal rotates the epoch.
  • Per-invoker rate limiting is the bot's job, not the node's. A slash command is an ordinary chat message and indistinguishable from chat on the wire — deliberately, so a hostile relay cannot filter commands — which means a node cannot identify or throttle command traffic either. It is also the right place for it: only your bot knows what a given command costs it to answer. Key a token bucket on the wallet address, weight per command, evict idle entries, and reply “slow down” at most once per user per cooldown — answering every throttled request turns your limiter into an amplifier using your own wallet.
  • node_registration_fee and channel_creation_fee have no on-chain ceiling; only registration_fee (10,000 KLV) and node_fee_share_bps (8000) are capped. If you build tooling that attaches either uncapped fee as callValue, bound it client-side.

Further Reading