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.
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.
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.
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.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!' });
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.
Every wallet-signature route below requires these four headers:
| Header | Value |
|---|---|
X-Ogmara-Auth | base64(64-byte Ed25519 signature) |
X-Ogmara-Address | klv1... or ogd1... |
X-Ogmara-Timestamp | Unix time in milliseconds |
X-Ogmara-Nonce | client-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.)
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.
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.
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 case | Format | Signed by |
|---|---|---|
| REST/WS auth headers, device claims | Klever message signing | wallet or device key |
| On-chain transactions | Klever TX signing (raw hash, no prefix) | wallet key |
| Chat/news/DM/etc. envelopes | Ogmara node-to-node format | device key (or wallet if undelegated) |
Auth key: none public · optional richer response if signed, never rejects · wallet required.
| Method | Path | Auth | Rate limit | Description |
|---|---|---|---|---|
| GET | /channels | optional | 100/min (IP) | List channels, paginated; private channels shown only to members |
| GET | /channels/by-slug/{slug} | optional | 100/min (IP) | Look up a channel by its human-readable slug |
| GET | /channels/{channel_id} | optional | 100/min (IP) | Channel metadata, moderators, pinned messages, member count |
| GET | /channels/{channel_id}/messages | optional | 100/min (IP) | Paginated message history, before/after cursors, limit ≤500 |
| GET | /channels/{channel_id}/members | optional | 100/min (IP) | Member list, filterable by role |
| GET | /channels/{channel_id}/pins | optional | 100/min (IP) | Pinned messages (max 10 per channel) |
| GET | /channels/{channel_id}/bots | optional | 100/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}/bans | wallet | 100/min (IP) | Ban list (response shape not fully spec'd — verify against a live node) |
| GET | /channels/unread | wallet | 100/min (IP) | Per-channel unread + mention counts, capped at 99 each |
| POST | /channels | wallet | 100/min | Create a channel — signed ChannelCreate envelope |
| DELETE | /channels/{channel_id} | wallet | 30/hr | Creator-only, local tombstone only — does not propagate. Send a signed ChannelDelete envelope instead for a real network-wide delete |
| POST | /channels/{channel_id}/read | wallet | 100/min | Mark a channel read |
| POST | /channels/{channel_id}/moderators | wallet | 10/day | Add a moderator (creator only) |
| DELETE | /channels/{channel_id}/moderators/{address} | wallet | 10/day | Remove 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} | wallet | 30/hr | Remove a member |
| POST / DELETE | /channels/{channel_id}/ban/{address} | wallet | 30/hr | Ban / unban a member |
| POST / DELETE | /channels/{channel_id}/mute/{address} | wallet | 30/hr | Timed or permanent mute (duration_secs: 0 = permanent) |
| POST / DELETE | /channels/{channel_id}/pin/{msg_id} | wallet | 20/hr | Pin / unpin a message (max 10 pins) |
| POST | /channels/{channel_id}/invite/{address} | wallet | 20/hr | Invite a user to a private channel |
| POST | /channels/{channel_id}/federate | wallet | 100/min | Federate with a channel hosted on another node (SSRF-hardened fetch); per-wallet cap 256 |
| GET / POST | /channels/{channel_id}/keys | wallet | 100/min | E2E channel-key envelope distribution (encrypted channels only) |
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.const { channel_id } = await client.createChannel({
slug: 'my-community',
channelType: 'public',
});
| Method | Path | Auth | Rate limit | Description |
|---|---|---|---|---|
| POST | /messages | wallet | by category — see Rate limits | Submit any signed message envelope (chat, news, DM, reaction, etc.) |
| PUT | /profile | wallet | 100/min | Update display name / avatar / bio via signed ProfileUpdate |
| POST | /dm/{address} | wallet | 30–60/min | Send a direct message |
| GET | /dm/conversations | wallet | 100/min (IP) | Conversation list with last-message preview and unread counts |
| GET | /dm/{address}/messages | wallet | 100/min (IP) | Paginated DM history with one peer |
| POST | /dm/{address}/read | wallet | 100/min | Mark a conversation read |
| GET | /dm/unread | wallet | 100/min (IP) | Unread count per conversation, capped at 99 |
| GET / POST | /users/{address}/enc-keys | none / wallet | 10/hr (POST) | Publish or fetch a wallet's E2E device encryption keys |
| GET | /keys/{key_scope} | wallet | 100/min (IP) | Fetch a specific encrypted key envelope by scope/epoch |
| GET | /settings | wallet | 100/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-vault | wallet | 10/min | Encrypted 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/export | wallet | 100/min (IP) | Full data export: profile, posts, memberships, bookmarks, DM ciphertext, follow graph, settings |
| GET | /notifications | wallet | 100/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/register | wallet or device | 100/min | Register a delegated device key — see Device delegation |
| DELETE | /devices/{device_address} | wallet | 100/min | Revoke a device |
| GET | /devices | wallet | 100/min (IP) | List a wallet's registered devices |
await client.sendMessage({ channelId: 42, text: 'gm' });
// or send a DM
await client.sendDm({ address: 'klv1...', text: 'hey' });
| Method | Path | Auth | Rate limit | Description |
|---|---|---|---|---|
| GET | /news | optional | 100/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-topics | none | 100/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} | optional | 100/min (IP) | A single post plus its comments |
| GET | /news/{msg_id}/reactions | optional | 100/min (IP) | Reaction counts by emoji |
| GET | /news/{msg_id}/reposts | optional | 100/min (IP) | Who reposted this post |
| POST | /news/{msg_id}/react | wallet | 60/min | React with an emoji |
| POST | /news/{msg_id}/repost | wallet | 10–30/hr | Repost, optionally with a comment |
| GET | /feed | wallet | 100/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 | /bookmarks | wallet | 100/min (IP) | Your bookmarked posts |
| POST / DELETE | /bookmarks/{msg_id} | wallet | 100/min | Bookmark / unbookmark a post |
| GET | /users/{address}/posts | optional | 100/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.| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /users/search | none | Search by display name / address prefix, query q (1–64 chars) |
| GET | /users/{address} | none | Public profile + follower/following counts (empty-profile fallback, never 404) |
| GET | /users/{address}/followers | none | Follower list, paginated |
| GET | /users/{address}/following | none | Following list, paginated |
| POST / DELETE | /users/{address}/follow | wallet | Follow / unfollow |
| GET | /network/identity | none | This node's peer ID, network, version, public URL |
| GET | /network/presence | none | Live presence-gossip cache: every peer this node currently hears from |
| GET | /network/presence/{peer_id} | none | A single presence record, 404 if unknown/expired |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /moderation/reports?target={msg_id} | none | Reports + counter-votes for a message, plus a computed score |
| GET | /moderation/user/{address} | none | Reputation 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.
| Method | Path | Auth | Description | |
|---|---|---|---|---|
| GET | /health | none | Liveness + node_id/network (needed to build the REST auth string) | |
| GET | /network/stats | none | Totals (messages, channels, users), uptime, protocol version, anchor status | |
| GET | /network/nodes | none | Known nodes merged from the peer directory + live libp2p connections | |
| POST | /pow/challenge | none | 100/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/verify | none | 100/min (IP) | Submit a solved challenge. On success the wallet is recorded as known and skips PoW on subsequent writes. |
| GET | /registration/info | none | What 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-candidates | none | Ranked candidate list for cold-start bootstrap (peer book / config / on-chain union, 5-min cache) |
curl https://some-known-node.example.com/api/v1/network/discovery/bootstrap-candidates
WS /api/v1/ws — authenticated realtime stream. Send the auth frame first. Client → server frames:
| Frame | Effect |
|---|---|
{"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":[...]} / unsubscribe | Accepted, but currently a no-op — see the callout below |
Server → client frames:
| Frame | When |
|---|---|
{"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.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.
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):
| Category | Applies to | Unverified wallet | Registered (on-chain) wallet |
|---|---|---|---|
| Chat messages | Chat/DM send, edit, delete | 30/min | 60/min |
| News posts (burst) | News post/edit/delete, comments | 5 / 10min | 20 / 10min |
| News posts (sustained) | same | 50/day | 300/day |
| Reactions | Chat/DM/news reactions | 60/min | — |
| Reposts | News repost | 10/hr | 30/hr |
| Channel admin | Kick/ban/mute/delete | 30/hr | — |
| Moderator changes | Add/remove moderator | 10/day | — |
| Channel invites | Invite to private channel | 20/hr | — |
| Pin / unpin | Message pinning | 20/hr | — |
| Key vault sync | Encrypted vault upload | 10/min | — |
| Device enc keys | Bind/revoke encryption keys | 10/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.
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.
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.
| Endpoint | Args | Purpose |
|---|---|---|
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_type | On-chain channel skeleton (public/read-public only — private channels are L2-only) |
delegateDevice / revokeDevice | device_pub_key, permissions, expires_at | On-chain device delegation (distinct from the free L2 path in Device delegation) |
anchorState | block_height, state_root, counts, node_id | Submit an L2 state-root anchor; canonical once 3 anchorers agree |
tip (payable) | recipient, msg_id, channel_id, note | Forward a KLV tip directly to a content author |
createProposal / vote | description, param_key, param_value, voting_period_seconds | User-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 / executeNodeProposal | description, param_key, param_value, voting_period_seconds | Node-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. |
| View | Returns |
|---|---|
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) |
divergence_escalated) instead of silently flipping. Anyone can call resolveTiebreak(block_height) after the grace window to deterministically lock a winner on-chain.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));
| Module | What it's for |
|---|---|
client.ts — OgmaraClient | Every REST call above: channels, messages, DMs, news, follow graph, moderation, devices, media, notifications, account export |
auth.ts — WalletSigner | Builds the 4 REST auth headers per request; supports both direct wallet keys and delegated device signers |
envelope.ts | Builders for every signed envelope type — buildChatMessage, buildNewsPost, buildFollow, buildChannelCreate, etc. |
crypto.ts / encryption.ts / dm.ts / media.ts | E2E primitives: X25519 key agreement, AEAD, DM content encrypt/decrypt, media file/thumbnail encryption |
keyVault.ts | Seal/open the encrypted key-recovery vault |
pow.ts | Solve anti-spam proof-of-work challenges |
ws.ts | subscribe({nodeUrl, channels, onEvent}) helper over the raw WebSocket API |
sc_discovery.ts | discoverNodesViaSc() — 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).
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).
| Area | Key endpoints |
|---|---|
| Metrics | GET /admin/metrics/snapshot, /metrics/history, /metrics/peers, /metrics/storage; live stream via WS /admin/dashboard/ws (pushes every 2s) |
| Alerts | GET /admin/alerts/history, /alerts/config, POST /admin/alerts/test (6/min, burst 3) |
| Node lifecycle | GET /admin/node/registration, /node/metadata, /node/pause-status; POST /admin/node/pause/resume (returns unsigned calldata — the node never signs on your behalf) |
| Network internals | GET /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 sync | GET /admin/snapshot/status |
See the Admin Dashboard tutorial for the operator-facing walkthrough.
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:
/channels/{channel_id}/threads endpoint and thread_root query filter are speced but not implemented — see the callout under Channels.subscribe/unsubscribe are accepted but not enforced — see WebSocket API.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" }.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.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.