API overview
A small read-only HTTP API over the same ledger the dashboard uses. No key, no signup, and no versioning games: six public endpoints, JSON in and out, rate limited by IP.
Base URL and conventions
| Item | Value |
|---|---|
| Base path | /api on this origin |
| Methods | GET for everything public |
| Content type | application/json |
| Errors | { "error": "human readable" } with a correct status |
| Authentication | None for public endpoints. See Authentication. |
The public endpoints
GET /api/stats
GET /api/nodes/:address
GET /api/node/:id?limit=50
GET /api/distributions?limit=50
GET /api/deploy-quote
GET /api/price/api/deploy-quote answers { priceWei, paymentAddress, chainId }, and it is the only honest source for the first two. Buying a node is a plain ETH transfer to a payments wallet, so the contract never sees the payment: it has no price() to read and no address to point at, and both figures live in the server’s environment instead. Quote them from here rather than hardcoding either. A transfer sent to the wrong address, or carrying the wrong amount, does not become a node on its own.
/api/price has no page of its own because there is nothing to it: it proxies a spot ETH quote so the dashboard can print a dollar figure beside an ETH one, and answers { "usd": null } with a 200 when no quote is available. There is no fallback price, because a made-up number beside a real balance is worse than no number. It is a market rate for display, and unrelated to priceWei above.
Two more routes exist behind a wallet session, GET /api/me and POST /api/nodes/sync, and are covered on Authentication. Nothing in this API withdraws, and no endpoint here can move funds; see Withdrawing.
Wei is a string, always
Every value field ending in Wei is a decimal string of wei, never a number. Values routinely exceed what a double can represent exactly, and a client that parses them as numbers loses precision silently rather than failing.
const res = await fetch("/api/stats");
const data = await res.json();
const total = BigInt(data.totalDistributedWei); // correct
const wrong = Number(data.totalDistributedWei); // loses precision above 2^53Timestamps are ISO 8601 strings in UTC, or null when the underlying column is empty. Node ids come in two flavours, and every response says which is which; see Node numbering.
Rate limits
Public endpoints are limited per IP in a fixed window of one minute. The limit differs by endpoint because the work behind them differs.
| Endpoint | Requests per minute per IP |
|---|---|
/api/stats | 120 |
/api/nodes/:address | 60 |
/api/node/:id | 60 |
/api/distributions | 60 |
/api/deploy-quote | 60 |
/api/price | Not limited. The upstream quote is fetched at most once a minute for everyone. |
/api/me | 120 |
/api/nodes/sync, /api/auth/nonce, /api/auth/verify | 20 |
/api/auth/logout | 30 |
Every answer from a limited endpoint carries the state of your window in headers, whether it succeeded or not:
x-ratelimit-limit: 60
x-ratelimit-remaining: 57
x-ratelimit-reset: 1756041600 # unix seconds
retry-after: 23 # only on a 429Caching
Public reads are cacheable for a few seconds at the edge, which is why a figure can lag the chain slightly. Nothing derived from a session is ever cached by a shared cache.
| Endpoint | Cache-Control |
|---|---|
/api/stats | public, max-age=0, s-maxage=10, stale-while-revalidate=30, plus a short in-process memo |
/api/nodes/:address, /api/node/:id | public, max-age=0, s-maxage=5, stale-while-revalidate=15 |
/api/distributions | public, max-age=0, s-maxage=10, stale-while-revalidate=30 |
/api/deploy-quote | public, max-age=0, s-maxage=30, stale-while-revalidate=90. Short, because it is the number a user is about to send money against. |
/api/price | public, max-age=60, browser included, since a spot quote is the same for everyone |
/api/cron/* | no-store. A liveness reading is not a document. |
| Anything behind a session | private, no-store |
Operational endpoints
Three routes under /api/cron exist for the scheduler that keeps the protocol moving. Two of them do work and are gated by a secret; the third only reports and is open to anyone.
| Endpoint | Gate | What it does |
|---|---|---|
/api/cron/payments | x-cron-key | One pass of the payment pipeline: read new blocks for transfers to the payments wallet, then mint a node for each payment that checks out. Every step is idempotent, so calling it twice costs RPC and nothing else. |
/api/cron/credit | x-cron-key | One credit pass over the nodes that are due. Held by an advisory lock, because two passes crediting the same nodes would pay twice. |
/api/cron/health | None | Whether the credit worker is alive and whether it can still pay. Read only, and public on purpose. |
Both working routes answer to GET as well as POST, because several hosted schedulers only issue GET. There is no body either way. An overlapping run is not an error: the second caller gets a 200 with ran: false, because a job that runs every minute and occasionally takes longer than a minute is behaving normally.
/api/cron/health
Public because everything in it is already public. The contract’s balance, what it owes, whether it is solvent and whether payouts are switched on can all be read from the chain by anyone, and a node holder has a fair claim to see them without an operator’s key. What it deliberately leaves out is the identity of any key-holding account. The distributor’s balance appears as a number with no address attached, which says the float is running low without saying where the float lives.
{
"lastTickAt": "…", "secondsSinceLastTick": 0,
"stale": false, "staleAfterSec": 300, "tickSec": 60,
"distEnabled": true, "distMode": "treasury",
"paused": false,
"dueNodes": 0, "scheduledNodes": 0,
"distributorBalanceWei": "…",
"contractBalanceWei": "…",
"outstandingWei": "…",
"isSolvent": true
}Any reading that could not be taken is null, never a zero that would look measured. A worker that has never ticked reports stale: true with secondsSinceLastTick: null: absent and stale are both wrong, but they are different problems. isSolvent is the contract’s own answer to whether its balance covers everything it owes, and it can be checked directly with isSolvent(); see Factory interface.
The chain is the source of truth
This API serves the operator’s ledger. The parts of it that involve money are also on chain, and where the two disagree, the chain is right: node ownership through nodesOf(address), and the owner, balance, credited total and withdrawn total for one node through nodeInfo(id), which returns all five at once. Anything that matters for money can be verified without this API at all; see Factory interface.
Stability
- Fields may be added. Ignore ones you do not recognise.
- Existing fields will not change type or meaning without the change appearing in the changelog.
- There is no version prefix in the path, and no plan to add one.
- The endpoints under
/api/adminare not public and answer 404 when no admin key is configured. The same is true of/api/cron/payments. Neither is part of this API’s surface, and neither is documented field by field.