Skip to content

Node API Reference

A merod node serves one HTTP API on its --listen address. The route table is router in crates/node/src/api.rs; this page lists every route in it. Calls, reads, events (server-sent events) and administration are all routes of this one API.

  • In the clear: only GET /health and GET /attestation.
  • Sealed: everything else travels inside the sealed channel (calimero-sealed, wire format v2, Noise NK). A client fetches /attestation, checks the quote binds the node’s transport key and that the quote’s measurements match a signed mero-tee release it trusts, and only then seals requests to that key. The sealed envelope wraps the router from outside, so an opened request meets the same handlers as a direct one.
  • A node started with --dev also accepts plain requests; a TDX node refuses them.
  • Requests are limited to 64 MiB.

Use a client that does this: meroctl, calimero-client (Rust), mero-js CalimeroClient, or calimero-client-py CloudClient. A hand-written curl reaches only /health and /attestation on a TDX node.

The Authorization: Bearer header carries one of:

Credential Form Obtained from
Session token v2.<claims>.<signature> by the issuing node, 15 minutes a passkey ceremony, or /auth/refresh
API key cal_… POST /auth/apikeys
Ops token the configured token --ops-token / --ops-token-sha256

Cluster peers add an x-cluster-auth header on /internal/*. See Authentication for lifetimes, scopes and revocation.

The Who column below uses these terms:

Who Means
anyone no credential needed
signed in a session or an API key
session a passkey session; an API key is refused (403)
account holder a passkey session not bound to an app’s frontend
installer an account holder, or an API key scoped to no app and no method and not read-only
app access whoever the app’s access setting admits (public admits anyone); an API key’s app, method and read-only scope apply
ops the ops token
peer another node of the cluster

Authority beyond the credential (an org admin, the app’s owner, the holder of a governance decision) is checked when the command applies to the system log, so every node reaches the same verdict; a refusal there answers 400 REJECTED.

An error answer has an HTTP status and a JSON body:

{ "status": 403, "error": "FORBIDDEN", "message": "this needs a passkey session, not an API key" }

with details when there is more to say (for example leaderUrl with 421 NOT_LEADER). The codes an operator meets most:

Status error Means
400 BAD_REQUEST, BAD_NONCE, BAD_PACKAGE, BAD_BUNDLE, UNKNOWN_CHALLENGE, EXPIRED_CHALLENGE, TOO_FEW, NO_REGISTRY The request is malformed, refers to nothing valid, or needs a registry the node lacks.
400 REJECTED The system log refused the command when it applied; message says why (for example the caller is not an admin of the organization).
401 UNAUTHENTICATED No, unknown, expired or revoked credential.
401 PASSKEY_UNKNOWN_HERE A sign-in or step-up with a passkey no account of this cluster has: one from another cluster under the same rp-id, or a removed one.
403 FORBIDDEN The credential may not do this (scope, role, governance, read-only).
404 NOT_FOUND No such object, or one the caller may not see.
409 CONFLICT, UPGRADE_PENDING The state does not allow it now; a fuel cap waits for every node to run this release.
412 CONFIRM_REQUIRED A passkey assertion is needed; details carries the challenge.
413 TOO_LARGE Over a size limit (a bundle over 16 MiB, a log entry over 1 MiB).
421 NOT_LEADER Send it to the leader named in details.leaderUrl.
422 INIT_FAILED, APP_ERROR, UPGRADE_FAILED The app’s code refused.
429 QUOTA_EXCEEDED, RATE_LIMITED An account quota, or a node’s limit on open streams or ceremonies.
503 UNAVAILABLE, NOT_CAUGHT_UP The cluster cannot answer now, or a replica is behind minIndex.
Route Who What meroctl
GET /health anyone { ok, node, systemLeader, release } network status
GET /attestation?nonce=HEX[&app=ID | &appHash=HEX][&purpose=receipts] anyone The node’s attestation document: quote, transport key, app hash, nonce, release and profile. purpose=receipts attests the receipt key and the governance instead. attest
Route Who What meroctl
GET /sign-up anyone The cluster’s sign-up policy: { policy, orgInvites, signUpInvites, inviteRequired }
POST /auth/passkey/register/begin anyone Start a sign-up: { name, invite? } → { challengeId, options }; 403 SIGN_UP_REFUSED when the policy would refuse it login (new file)
POST /auth/passkey/register/finish anyone { challengeId, registration, invite? } → 412 CONFIRM_REQUIRED (the new passkey signs the account it creates); the retry with confirm (and the same invite) → a session. invite (an organization or cluster sign-up invite token) is redeemed for the new account login
POST /sign-up, POST|GET /sign-up/invites, DELETE /sign-up/invites/{key} the cluster’s owner, once named Set the policy and run sign-up invites with a passkey (412 first)
POST /auth/passkey/login/begin anyone Start a sign-in login
POST /auth/passkey/login/finish anyone { challengeId, assertion } → a session login
POST /auth/passkeys/add/begin, /finish account holder Add a passkey (a device): /finish { challengeId, registration, confirm? } answers 412 and is sent again with confirm signed by a passkey the account already has account device add
POST /auth/passkeys/{cred} account holder { label }: name a device account device label
DELETE /auth/passkeys/{cred} account holder Remove a device; its sessions end. 412 first; the retry’s JSON body is { confirm } account device revoke
POST /auth/recovery, DELETE /auth/recovery account holder Make (shown once, replacing any) or remove the account’s recovery code; 412 first -
POST /auth/recovery/begin, /start signed out begin { code } → a passkey registration; start { challengeId, code, registration } spends the code and logs the new passkey as pending → { accountId, credentialId, startedAt, readyAt }. 401 wrong or spent code, 409 RECOVERY_PENDING -
POST /auth/recovery/cancel account holder Cancel the recovery under way with a passkey the account has; 412 first -
POST /auth/recovery/finish signed out { accountId? }, 412 first, approved by the new passkey once readyAt passed (72 h): adds it, ends every session of the account → a session. 409 RECOVERY_NOT_READY before -
POST /auth/refresh anyone with a refresh token { refreshToken } → a new session and the refresh token to use next (the same one until it is an hour old) (automatic)
POST /auth/logout session End this session
POST /auth/logout-all account holder End every session of the account logout --all
GET /auth/sessions account holder The account’s live sessions, latest active first (at most 256): { sessions: [{ session, passkey, app, origin, openedAt, lastActiveAt, current }] } (Sessions) account session list
DELETE /auth/sessions/{session} account holder End one of the account’s sessions; 404 for one it does not hold live account session revoke
GET /auth/activity[?after=T] account holder What the account’s passkeys did, newest first (the last 100): { activity: [{ at, passkey, kind, app, origin, method, subject }] }, kind signIn (with app, origin) or approval (with method, subject); after: only entries after T (Unix seconds) (Activity) account activity
GET /auth/me signed in The account, passkeys (each with addedAt and lastSignInAt), passkeyId, organizations, approvals, recoveryCode, recoveryPending whoami, account show
GET /auth/apps[?after=ID&limit=N] signed in The account’s own apps (outside any organization; a package-bound session: its package’s): { scope, governance, approvedReleases, clusterReleases, apps, next }, each app’s release, newer releases it may upgrade to, last upgrade, replicas’ releases and lastWriteAt (as in GET /ops/apps) (Reading the approvals)
GET /auth/usage signed in Apps, bundle bytes, API keys and owned orgs against the quotas; developerFuelToday and developerWritesToday: what today’s runs of the apps the account is the developer of used (D76, D78). bundleBytes, developerFuelToday and developerWritesToday are decimal strings account usage
GET /auth/aliases signed in The account’s aliases context alias list
GET/PUT/DELETE /auth/aliases/{alias} signed in Resolve, set ({ app }) or remove an alias context alias get/add/remove, context use
POST /auth/apikeys account holder (a passkey session) { label, apps?, methods?, readOnly, allowConfirm, ttlSecs?, confirm? } → { keyId, key }, approved with a passkey (412 first) account api-key create
GET /auth/apikeys account holder The account’s keys account api-key list
DELETE /auth/apikeys/{key} account holder Revoke a key, approved with a passkey (412 first; the retry’s body carries confirm) account api-key revoke
POST /auth/releases account holder { version, approved, confirm? }: approve a mero-tee release for the account’s own apps (where the owner holds release approvals) account approve-release
POST /auth/outbound account holder { package, prefixes, replace, confirm? }: approve outbound prefixes for the account’s own apps account outbound
Route Who What meroctl
POST /bundles installer Upload raw WASM (16 MiB at most). Only with --allow-unsigned-bundles or --dev. app install --path X.wasm
POST /packages installer Upload a signed .mpk. Only with --dev; otherwise 403 UPLOADS_DISABLED. app install --path X.mpk
POST /packages/install installer { package, version } from the registry app install P@V
GET /packages signed in Installed packages, only the releases the caller may see (who sees a release) app list
GET /packages/{package} signed in One package and the versions the caller may see; 404 when none app get, app versions, app get-latest-version
DELETE /packages/{package} account holder Uninstall every version; refused while an app runs one app uninstall
DELETE /packages/{package}/{version} account holder Uninstall one version app uninstall --version

Before an install is logged the node checks the publisher signature and every artifact digest, minRuntimeVersion, the package’s pinned signer (the first release’s wins) and that each service’s WASM compiles. A node that later runs the release fetches and verifies it again.

{app} is an app id or its context id (hex(SHA-256(app_id))).

Route Who What meroctl
POST /apps installer { name, package?, version?, service?, bundle?, org?, access?, confirmMethods?, initArgs?, metadata? } context create
GET /apps[?package=P] signed in or anyone Apps the caller reaches context list
GET /apps/{app} signed in or anyone One app context get
DELETE /apps/{app} session Delete (owner or org admin) context delete
POST /apps/{app}/intents app access { method, args, idempotencyKey?, confirm? }: a call. A confirm method answers 412 first. call
POST /apps/{app}/views app access { method, args, consistency?: "stale", minIndex? }: a read call --view
GET /apps/{app}/events?from=N app access Server-sent events: committed events from N, then live ones context watch
POST /apps/{app}/upgrade account holder { bundle? | package?, version?, service?, migrate?, confirm? }; where the owner holds app upgrades context update
POST /apps/{app}/transfer account holder { to, confirm? }: hand the app to another account (the owner, or for an organization’s app its owner while a member, and in the app’s team for an app on a team, or an organization admin, with a passkey step-up; an organization’s app only to a member) context transfer
POST /apps/{app}/move account holder { toOrg, bringMembers?, confirm? }: move the app to another organization, its data where it is (whoever manages it now, as an admin or the owner of toOrg, with a passkey step-up). Its files, runs and quotas go to the target; org or app-team access becomes the target’s app team of its package (made when missing, closed when every app team of the target is; org access in a legacy target). bringMembers adds the source’s members who reach it, capped at member. Answers { appId, from, org, access, created, added, revokedInvites }; a node without the route answers 404 context move
POST /apps/{app}/access session { access: { kind: owner | org | team | accounts | public, … } } context access
POST /apps/{app}/metadata signed in { metadata } (owner or org admin; 16 KiB) context metadata
GET /apps/{app}/storage app access Keys, bytes and blobs held, and lastWriteAt (Unix seconds of its last committed write, null before one) context storage
GET /apps/{app}/usage owner or org admin A component’s runs per UTC day: today and the 30 days before it, each { date, runs, fuel, wallMs, writes, bytesWritten, events }, figures as decimal strings; wallMs is information only
GET /apps/{app}/abi app access The bundle’s embedded ABI context abi
GET /apps/{app}/runs/{run} app access A compute run’s record without its arguments or keys: method, state, cancelRequested, startTsMs, attempts, input and output ids (with plaintext sizes and SHA-256 only for callers who may list the app’s files), usage, and progress (percent, step) as the app’s leader last polled it -
GET /apps/{app}/attestation app access Every replica’s receipt-key attestation attest --context
GET/POST /apps/{app}/presence app access Who is present; publish { seq, state } context presence list/publish
GET /apps/{app}/presence/stream app access Server-sent events: snapshot, then presence diffs context presence stream
POST /apps/{app}/blobs app access Upload a blob blob upload
GET /apps/{app}/blobs app access List blobs (plain blobs only) blob list
POST /apps/{app}/files owner, org member Upload a sealed file of up to 16 MiB: { id, blobId, size, sha256, index } file upload
GET /apps/{app}/files, GET /apps/{app}/files/{id} owner, org member List a component’s files; one file’s size and plaintext SHA-256 -
POST /apps/{app}/files/uploads owner, org member Begin an upload session for { size }: { uploadId, partBytes, parts, expiresAt } file upload
PUT /apps/{app}/files/uploads/{upload}/parts/{k} session’s account One part, raw bytes: { next } file upload
GET /apps/{app}/files/uploads/{upload}, DELETE ... session’s account Where the session is (next); abort it file upload
POST /apps/{app}/files/uploads/{upload}/complete session’s account Charge and commit the file; a retry answers the same file file upload
GET /apps/{app}/blobs/{id} app access Download (id base58 or hex) blob download
GET /apps/{app}/blobs/{id}/info app access A blob’s size blob info
DELETE /apps/{app}/blobs/{id} app access Forget (owner or org admin) blob delete
POST /apps/{app}/blobs/{id}/revoke app access Invalidate the grants made so far for a published blob (owner or org admin) blob revoke

Every /intents and /views answer carries a receipt signed by the answering node’s receipt key over (app, index, SHA-256(JCS(result)), node).

Route Who What meroctl
POST /orgs session { name, recoveryKey? } (a recovery key’s public half) namespace create
GET /orgs/{org} signed in Members, teams (filtered to those the caller reaches or manages), metadata namespace get, groups, members list, metadata get
GET /orgs/{org}/members?detail=security account holder, owner or admin Each member’s lockout risk: { orgId, members: [{ account, name, role, teams, passkeys, recoveryCode, lastActiveDay, sessions }] }, passkeys 1, 2 or "3+", lastActiveDay a UTC day (YYYY-MM-DD, null: unknown). 403 below admin, 404 outside the organization (Members’ security) namespace members list --security
DELETE /orgs/{org} account holder Delete with teams, invites and apps (owner; 412 first) namespace delete
POST /orgs/{org}/metadata signed in { metadata } namespace metadata set
GET /orgs/{org}/usage org admin What today’s runs of the apps the org is the developer of used: apps, developerFuelToday, developerWritesToday (decimal strings), the quotas (D78); a member gets 403
POST /orgs/{org}/members session { account, role, confirm? }; owner moves ownership. 412 first namespace members add
DELETE /orgs/{org}/members/{account} session Remove a member, or leave. 412 first; the retry’s JSON body is { confirm } namespace members remove, namespace leave
POST /orgs/{org}/members/{account}/metadata signed in { metadata } namespace member-metadata
POST /orgs/{org}/capabilities session { account?, team?, capabilities, confirm? }. 412 first namespace capabilities, group settings
POST /orgs/{org}/invites session { role, teams, ttlSecs?, uses?, confirm? } → a token. 412 first namespace invite
POST /invites/{token}/redeem session Join (no passkey prompt: the node signs with the token’s key) namespace join
POST /orgs/{org}/recovery account holder { publicKey, confirm? }: set, rotate or (null) remove the recovery key (owner; 412 first); cancels a recovery under way -
POST /orgs/{org}/recovery/start account holder { nonce, signature }: the seed’s signature handing the org to the caller → { startedAt, readyAt }. 403 WRONG_RECOVERY_KEY, 409 RECOVERY_PENDING -
POST /orgs/{org}/recovery/cancel account holder Cancel the recovery under way (owner; 412 first) -
POST /orgs/{org}/recovery/finish account holder Become the owner once readyAt passed (the account that started it; 412 first; 409 RECOVERY_NOT_READY before) -
POST /orgs/{org}/teams session { name, parent?, join: everyone | invite }: an admin, a lead of parent, a member with CREATE_TEAM at the top, or a member who writes parent and holds CREATE_CHILD_TEAM there (a restricted team only) namespace create-group
GET /orgs/{org}/teams/{team} signed in One team group get, group members list
POST /orgs/{org}/teams/{team} session { name?, join?, parent?, metadata? } group update, reparent, metadata
DELETE /orgs/{org}/teams/{team} session Delete with the teams and apps below group delete
POST /orgs/{org}/teams/{team}/members session { account, role?, capabilities?, confirm? }. 412 first group members add
DELETE /orgs/{org}/teams/{team}/members/{account} session Remove, or leave. 412 first; the retry’s JSON body is { confirm } group members remove, group leave
GET /orgs/{org}/app-teams signed in (a member) { mode, appTeams }: each app team’s package, id, name, join, member count, default capabilities, and whether the caller is in it or manages it (a guest: only their own)
POST /orgs/{org}/app-teams session { apps?: [{ package, name?, instances?, join?, keep? }], shared?, confirm? } (owner or admin): give apps app teams, or open or close one (join), keeping everyone or the accounts in keep. 412 first
GET /orgs/{org}/apps[?after=ID&limit=N] signed in (a member) As GET /auth/apps, for the organization’s apps the caller reaches or manages; 404 to a non-member
POST /orgs/{org}/releases account holder { version, approved, confirm? } (owner) namespace approve-release
POST /orgs/{org}/outbound account holder { package, prefixes, replace, confirm? } (owner) namespace outbound
POST /orgs/{org}/upgrade account holder { package, version, team?, cascade?, migrate?, confirm? }: an upgrade run (owner) group upgrade trigger
GET /orgs/{org}/upgrades/{run} signed in (a member) A run’s progress group upgrade status
POST /orgs/{org}/upgrades/{run}/retry account holder, or ops for an operator’s run Retry the failed apps as a new run group upgrade retry

Every route needs the ops token. The Ops API returns counts, Raft status, placement and per-app usage by id, never app state, names or credentials. A node started with --metrics-listen ADDR also serves the /ops/metrics text as plain-HTTP GET /metrics on that separate address, and nothing else there.

Route What meroctl
GET /ops/status { node, system, governance, groups, nodes, apps, accounts } ops status
GET /ops/metrics Prometheus text, including per-app usage for billing; see Observability and usage metering ops metrics
GET /ops/placement { fresh, apps: [{ app, group, replicas }], nodes: [{ id, zone, release, apps }] }: where every app’s replicas are, ids and counts only, read after a read index (fresh: this node caught up with the system leader); release is the node’s attested release, null when unknown ops placement
GET /ops/apps[?after=ID&limit=N] { fresh, governance, apps: [{ app, group, scope, bundle, package, publisher: { signer, org }, upgradesHeldBy, releasesApprovedBy, lastUpgrade, approvedReleases, replicas: [{ node, release, approved }], lastWriteAt }], next }: what each app runs and which releases may hold it; lastWriteAt is when the app last committed a write (Unix seconds, the timestamp the leader stamped into the entry, so every node names the same one; null before its first write or when no replica answered within 2 s); package only for a release the operator installed, the upgrade’s actor by kind only (What runs where)
GET /ops/orgs[?after=ID&limit=N] { fresh, governance, orgs: [{ org, approvedReleases }], next }
GET /ops/served-origins { fresh, origins: [{ origin, host, label, addedAt }] }: every custom domain a package’s manager registered as a served origin, by the package’s label (app_host::label), never its name; what certificates and routes are made for
POST /ops/nodes { id, addr, zone?, operatorProof? }: register a node, signed with the operator key (428 OPERATOR_SIGNATURE_REQUIRED first, over cluster / __register_node / { id, addr, zone }); the nodes in the first leader’s --peers are registered at genesis; the system leader adds it as a learner, and promotes caught-up learners while fewer than 3 voters are left (straight to 3, never 2) ops add-node
DELETE /ops/nodes/{id} Remove a node no app keeps a replica on (system leader; elsewhere 421 NOT_LEADER), signed (428 first, __remove_node / { id }; the retry’s JSON body is { operatorProof }) ops remove-node
POST /ops/apps/{app}/replicas { replicas: [id, …], operatorProof? }: move an app, signed (428 first, <app> / __set_replicas / { replicas }, the target sorted; asked before the leader redirect, so the signed body goes to the leader as it is) (at least min(3, nodes in the cluster) registered nodes, and only nodes on a release the app’s owner approved) → { status, unchanged }. The set it already has: unchanged: true, nothing written. Another move of the app running: 409 MOVE_IN_PROGRESS. On a follower: 421 NOT_LEADER. ops move
POST /ops/backup Snapshot the apps this node leads into --backup-dir → { written } ops backup
POST /ops/sign-up, POST|GET /ops/sign-up/invites, DELETE /ops/sign-up/invites/{key} Set the sign-up policy ({ policy }) and issue, list and withdraw cluster sign-up invites ({ token, uses?, expiresAt }); each change signed (428 first), refused once the cluster’s owner runs sign-up
GET /ops/quotas, POST /ops/quotas Read or change the per-account quotas; a change is signed (428 first, cluster / __set_quotas / { quotas } with every limit after the change; operatorProof sits beside the limits) ops quotas, ops set-quotas
POST /ops/quotas with org Override one payer’s maxConcurrentRuns, computeFuel (decimal string) and maxFileBytes (null clears a field; none left removes the override); signed (cluster / __set_org_quotas / { payer, quotas }), refused 409 UPGRADE_PENDING while a node runs an older release. GET /ops/quotas lists them under orgs
GET /ops/pools, POST /ops/pools Read the compute pools and the runs each takes, or set them ({ pools: { name: { runsPerNode, maxMemoryMib } } }); a change is signed (428 first, cluster / __set_pools / { pools }) and refused 409 UPGRADE_PENDING while a node runs an older release, POOL_IN_USE when a pool to remove still has job nodes or runs. POST /ops/nodes takes pool for a job node
POST /ops/releases { version, approved }, where the operator holds release approvals ops release
POST /ops/outbound { package, prefixes, replace }, where the operator holds outbound approvals ops outbound
POST /ops/packages, POST /ops/packages/install, GET /ops/packages Install (upload or registry) and list the releases the operator upgrades to ops install, ops packages
DELETE /ops/packages/{package}[/{version}] Uninstall what the operator installed ops uninstall
POST /ops/apps/{app}/upgrade { bundle? | package?, version?, service?, migrate? }, where the operator holds app upgrades ops upgrade
POST /ops/orgs/{org}/upgrade { package, version, team?, cascade?, migrate? }, an operator’s upgrade run ops upgrade-namespace

Quotas (POST /ops/quotas changes the fields it names; a field left out keeps its value):

Field Default
maxAppsPerAccount 100
maxBundleBytesPerAccount 1073741824 (1 GiB)
maxApiKeysPerAccount 50
maxOrgsPerAccount 20
maxAccounts 100000
maxFileBytesPerOrg 50000000000 (50 GB)
maxConcurrentRunsPerOrg 4
computeFuelPerOrg 10000000000000
maxFuelPerDeveloperPerDay "0" (a decimal string, D20; no limit; per developer: a deployment’s owner for its instances, else the app’s org or owner, D78; 409 UPGRADE_PENDING until every node runs this release)

/internal/raft, /internal/system, /internal/system/index, /internal/call, /internal/challenge/take, /internal/view, /internal/blob, /internal/blob/get, /internal/blob/held, /internal/blob/delete, /internal/storage, /internal/last-write, /internal/presence, /internal/app-ready and /internal/events are for the cluster’s own nodes: they need the cluster secret (an HMAC of the cluster key and the cluster name) inside a sealed channel whose key was attested like any client’s. Nothing outside the cluster calls them.

/internal/cluster-key is the one internal route without the cluster secret: a new node, often of the next mero-tee release, asks it for the cluster key K_c ({ cluster, node, token }). It is never answered with the key: the peer delivers K_c to the address the cluster has for that node id, inside a sealed channel to the key the TD there attests (403 for another cluster, an unknown or removed node, or a delivery that fails; 429 BUSY past four at once). See The cluster key.