BlockYard serves its browser UI and a JSON API from the same port. Everything the dashboard draws comes from the endpoints documented here, so any script can read the same data. This reference is derived from server/http/api.js (the route table), server/http/server.js (routing, auth, limits), server/http/sse.js (the event stream), server/http/explorer.js, server/collect/markets.js, server/collect/monitor.js and server/rpc/allowlist.js.
Contents
- Conventions
- Endpoint index
- Health and build
- State, sync and nodes
- Chart history: /api/series
- Mempool
- Blocks and transactions (drill-down)
- Explorer: /api/x/*
- Mining
- Peers and network
- Events
- Markets
- RPC console and the allowlist
- Node actions
- Accounts, sessions, users and audit
- Telemetry and configuration
- The event stream: /api/stream
- End-to-end examples
The server listens on port 21000 by default (BLOCKYARD_PORT), on the addresses in server.host / BLOCKYARD_BIND. HTTPS is on by default (server.tls.enabled), with a certificate the server makes for itself under <data>/tls/ unless BLOCKYARD_TLS_CERT + BLOCKYARD_TLS_KEY name your own. BLOCKYARD_TLS=0 serves plain HTTP behind a TLS-terminating proxy. The examples below use:
http://127.0.0.1:21000
Against a default install use https:// (and curl --cacert, or -k for the self-signed certificate). /api/build and /api/health report which one is in effect (scheme, tls).
- Every
/api/*response isapplication/json; charset=utf-8withCache-Control: no-store. The only exception is/api/stream, which istext/event-stream. - Request bodies (POST) can be JSON or
application/x-www-form-urlencoded, and are capped at 1 MB. A larger body is refused with413, and a body that is not valid JSON with400(kind: "body"). - Timestamps named
at,t,ts,*Atare Unix milliseconds. Fields namedtime,mediantime,blocktimeare node-supplied Unix seconds. - A figure the node does not report is
null, never0. Several endpoints also carrynotReported/unavailable/notestrings saying why.
A monitor can watch several nodes. Every per-node endpoint accepts ?node=<id> (?nodeId= is an alias, except on /api/stream). When it is omitted, the first configured node (the "primary") answers. /api/nodes lists the configured ids. The built-in default id is main, and the examples use it.
An unknown id is a 404:
{ "error": { "message": "no node \"nope\"; known: main", "kind": "api", "code": null } }These endpoints are not per-node: /api/health, /api/build, /api/nodes, /api/telemetry, /api/config, /api/config/node* (they always address nodes[0]), /api/settings, /api/events (rows carry a node field but there is no node filter), /api/markets*, and the account/admin routes.
Transport-level failures use one envelope:
{ "error": { "message": "human-readable sentence", "kind": "api", "code": "rpc_denied" } }code is present only on errors raised by a handler (kind: "api"). Some errors add sibling fields: login (on 401) or accounts (on admin refusals in open mode).
kind |
Status | Meaning |
|---|---|---|
api |
400 / 401 / 403 / 404 / 429 / 503 | A handler refused the request. See code. |
auth |
401 | Accounts are on and there is no valid session. The body includes "login": "/login". |
forbidden |
403 | Address outside the CIDR gate, disabled account, or an admin route without the admin role. |
csrf |
403 | X-CSRF-Token missing or wrong (accounts on only). |
ratelimited |
429 | The token bucket is empty. The message says how many seconds to wait (no Retry-After header is sent). |
not_found |
404 | No such /api/... path. |
method |
405 | The path exists with another method. The Allow header lists the valid ones. |
body |
400 / 413 | The body is unreadable, not JSON, or larger than 1 MB. |
unknown_node |
404 | /api/stream?node= named a node that does not exist. |
internal |
500 | Unexpected server error. Details go to the server log only. |
Handler code values: accounts_disabled, throttled, locked, bad_credentials, rpc_denied, action_denied, confirm_required, or null.
Node errors are not HTTP errors. When the node itself refuses or fails, the answer is 200 with ok: false and the node's own message. This applies to /api/rpc, /api/action, /api/block, /api/tx and all of /api/x/*. Always check ok, not just the status.
Other statuses: 204 for OPTIONS on any path (Allow: GET,POST,DELETE,HEAD). HEAD works for static files only. On /api/* a HEAD gets 405.
Each route in the table has one of three auth levels:
| Level | Accounts off (BLOCKYARD_AUTH=0) |
Accounts on (the default) |
|---|---|---|
none |
Open. | Open. No session needed. |
any |
Served as the built-in anonymous user (role: "viewer"). |
Any valid, non-disabled session. Without one: 401. |
admin |
Always 403 with "accounts": false: nothing in open mode has the admin role. |
Session with role admin, else 403. |
The roles are viewer < operator < admin. No route in the table requires viewer or operator by itself. Those two roles matter in two places only: each node action names a minimum role (section 14), and POST /api/password needs admin to change another user's password.
Open mode (auth.enabled=false, a choice; the default is accounts on). No sign-in. Anyone who can reach the port can read everything (state, charts, events, peers, mempool, explorer, markets, the read-only RPC console) as viewer. The ceiling is fixed and nothing can raise it. As a result:
- user administration,
/api/auditand password changes are closed; - node writes (
/api/action) are refused unlessBLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH=1was set deliberately; /api/loginanswers403 accounts_disabled;GET /loginredirects (302) to/.
Accounts mode (the default). Sign-in, roles, sessions, CSRF protection and a per-user audit trail. On first start with no users, an admin account is created. Its password comes from BLOCKYARD_ADMIN_PASSWORD, or is generated and printed once to the server log.
POST /api/login sets two cookies:
| Cookie | Attributes | Purpose |
|---|---|---|
blockyard_sid (configurable as auth.cookieName) |
HttpOnly; SameSite=Strict; Path=/; Max-Age=259200 (+ Secure over TLS or with BLOCKYARD_SECURE_COOKIE=1) |
The session token. Only its SHA-256 is stored server-side. |
blockyard_csrf |
Same, but not HttpOnly |
The CSRF double-submit value, readable by page JavaScript. |
Sessions expire 72 hours after creation (auth.sessionTtlMs), or after 8 hours without a request (auth.idleTtlMs). A role change or a disable takes effect on the next request, not at next login.
With accounts on, every mutating route (POST /api/logout, /api/logout-all, /api/rpc, /api/action, /api/password, /api/config/node/test, /api/config/node, /api/settings, and all POST /api/users*) needs the session's CSRF token. Send it in the X-CSRF-Token header, or as a csrf field in the body. The cookie alone is not accepted, because a cross-site request would carry it too. The value is returned by /api/login as csrf and is also in the blockyard_csrf cookie.
/api/login itself is exempt. In open mode there is no session, so no CSRF check runs.
| Bucket | Key | Capacity / refill | Applies to |
|---|---|---|---|
| General | anon:<ip> in open mode, the user id with accounts |
120 requests, refilled at 40/s | Every auth: any / admin route. |
| Stream | sse:anon:<ip> or sse:<userId> |
120 / 40 per s | Opening /api/stream. |
| Login | login:<ip> |
10 attempts, refilled at 1 per 2 s | POST /api/login (answers 429 throttled). |
| Login lockout | per username and per IP | 8 failures in 5 minutes locks for 10 minutes | POST /api/login (answers 429 locked). |
auth: none routes (/api/health, /api/build) and static files are not rate limited.
When server.allowCidrs / BLOCKYARD_ALLOW_CIDRS is set (comma-separated IPv4/IPv6 CIDRs, for example 192.0.2.0/24,127.0.0.1/32), every request is checked first: API, static files and the stream, before any KDF or session work. An address outside the list gets:
{ "error": { "message": "this address is not permitted", "kind": "forbidden" } }The reason is logged server-side only. The client IP is the socket peer, unless server.trustProxy / BLOCKYARD_TRUST_PROXY=1 is set, in which case the first X-Forwarded-For entry is used. Enable that only behind a proxy you control.
Every JSON, static and error response carries:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=()
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:;
font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
HTML pages get a per-response script nonce (script-src 'self' 'nonce-...'). Strict-Transport-Security is added only when TLS is on and server.tls.hstsMs > 0. Static files are Cache-Control: no-cache with a weak ETag. Asset URLs in pages carry ?v=<build>.
| Method | Path | Auth | Section |
|---|---|---|---|
| GET | /api/health |
none | 3 |
| GET | /api/about |
any | 3 |
| GET | /api/build |
none | 3 |
| GET | /api/state |
any | 4 |
| GET | /api/sync |
any | 4 |
| GET | /api/nodes |
any | 4 |
| GET | /api/series |
any | 5 |
| GET | /api/mempool |
any | 6 |
| GET | /api/mempool/dense |
any | 6 |
| GET | /api/blocks |
any | 7 |
| GET | /api/block |
any | 7 |
| GET | /api/tx |
any | 7 |
| GET | /api/x/search |
any | 8 |
| GET | /api/x/tx |
any | 8 |
| GET | /api/x/block |
any | 8 |
| GET | /api/x/address |
any | 8 |
| GET | /api/mining |
any | 9 |
| GET | /api/nextblock |
any | 9 |
| GET | /api/peers |
any | 10 |
| GET | /api/net |
any | 10 |
| GET | /api/events |
any | 11 |
| GET | /api/markets |
any | 12 |
| GET | /api/markets/depth |
any | 12 |
| POST | /api/rpc |
any + CSRF | 13 |
| GET | /api/actions |
any | 14 |
| POST | /api/action |
any + CSRF (+ action role) | 14 |
| POST | /api/login |
none | 15 |
| POST | /api/logout |
any + CSRF | 15 |
| POST | /api/logout-all |
any + CSRF | 15 |
| GET | /api/me |
any | 15 |
| POST | /api/password |
any + CSRF | 15 |
| GET | /api/users |
admin | 15 |
| POST | /api/users |
admin + CSRF | 15 |
| POST | /api/users/generate |
admin + CSRF | 15 |
| POST | /api/users/:username/role |
admin + CSRF | 15 |
| POST | /api/users/:username/disabled |
admin + CSRF | 15 |
| GET | /api/audit |
admin | 15 |
| GET | /api/telemetry |
any | 16 |
| GET | /api/config |
any | 16 |
| POST | /api/config/node/test |
any + CSRF (admin with accounts on) | 16 |
| POST | /api/config/node |
any + CSRF (admin with accounts on) | 16 |
| GET | /api/settings |
any | 16 |
| POST | /api/settings |
any + CSRF (admin with accounts on) | 16 |
| GET | /api/stream |
any (checked in server.js) |
17 |
Any other path that is not an /api/* path is served from public/ as a static file (/ is index.html, /login is login.html). A missing page gets 404.html with status 404.
Auth none, not rate limited. Built for uptime probes. ok is true when at least one node is configured and every non-optional node's RPC is online. An optional node that is down shows up in degraded but does not turn ok false.
{
"ok": true,
"degraded": [],
"version": "0.1.0",
"build": "0.1.0-fd620adc52",
"scheme": "http",
"tls": false,
"uptimeSec": 1519,
"authRequired": false,
"nodes": [
{ "id": "main", "label": "Bitcoin Core", "online": true, "optional": false,
"chain": "main", "tip": 966546, "lastError": null }
]
}What the About page shows: the monitor's version and live build, and the shape of the machine it runs on.
{ "version": "0.1.0", "build": "0.1.0-a6ecedff3c", "platform": "linux", "release": "7.0.0-31-generic",
"arch": "x64", "cpus": 32, "cpuModel": "AMD Ryzen 9 9950X3D 16-Core Processor",
"totalMemGb": 132.3, "node": "v22.23.2", "uptimeSec": 2355 }auth: any rather than none, unlike /api/health and /api/build: a version string answers "is my tab running current code", which a login page legitimately needs, whereas the host's processor and memory should not be readable before sign-in when accounts are on.
It reports no hostname, no username, no network addresses and no environment. In open mode (BLOCKYARD_AUTH=0) everything here is readable by anyone who can reach the port; the operating system and processor describe a machine's shape, not its owner. A test pins those absences.
Auth none. Answers "is the code in my tab the code on disk?" The build id is <version>-<10 hex>, a digest of the sizes and mtimes of the files under public/.
| Query | Type | Meaning |
|---|---|---|
build |
string | Optional. The build id the page was served with. |
{
"version": "0.1.0",
"build": "0.1.0-5223f98d84",
"bootBuild": "0.1.0-fd620adc52",
"matchesClient": null,
"scheme": "http",
"tls": false,
"uptimeSec": 1571
}build is computed live, bootBuild at process start. matchesClient is true/false when ?build= was sent, otherwise null.
The whole read model for one node: the same object the stream pushes as snapshot (section 17), plus app, user and seq.
| Query | Type | Default | Meaning |
|---|---|---|---|
node |
string | primary | Node id. |
series |
none |
— | series=none returns "series": {}: the cheap poll. |
range |
duration | 1h |
Range of the "hour" chart windows. |
range6 |
duration | 6h |
Range of the "hours6" chart windows. |
A duration is a number with an optional unit s (default), m, h or d, for example 15m, 24h, 7d. It is clamped to 1 s through 31 d. Anything unparseable falls back to the default.
Top-level keys:
id label color online chain ibd tip sync progress warnings difficulty hashrateEstEh hashrateNote
avgBlockGapSec sizeOnDisk pruned chainwork uptimeSec network mempool peers net attribution blocks
network (the Mining tab's network row: rewards over 144 blocks, adjustment, halving, adjustments[], hashrate { networkHashPs, series[] }, pools { blocks, luckPct, pools[], filled, todo }; collect/network.js)
fees mining utxo chaintxstats indexes tips deployments rpcInfo log health series app user seq
Trimmed example:
{
"id": "main",
"label": "Bitcoin Core",
"color": "#f7931a",
"online": true,
"chain": "main",
"ibd": false,
"tip": { "height": 966546, "headers": 966546,
"hash": "0000000000000000000019f6fb9d8e1e43e9d72916b2ed8aef080c778685a4aa",
"time": 1789154285, "mediantime": 1789151883, "ageSec": 1403, "behindHeaders": 0 },
"sync": { "state": "synced", "height": 966546, "headers": 966546, "behind": 0, "pct": 100,
"verificationProgress": 100, "etaSec": null, "eta": null, "rateTrend": "stalled",
"node": "main", "nodeLabel": "Bitcoin Core", "endpoint": "http://127.0.0.1:8332",
"strip": [ { "label": "chain", "...": "..." } ] },
"difficulty": 127450789715843.1,
"hashrateEstEh": 200.1,
"hashrateNote": null,
"mempool": { "count": 21398, "bytes": 8123456, "usage": 41234567, "maxUsage": 300000000,
"usagePct": 13.7, "totalFee": 0.123, "minFee": 0.00001, "minRelayFee": 0.00001,
"rejects": null, "dist": { "count": 21398, "p50Feerate": 1.2, "scatterPoints": 1500, "...": "..." } },
"peers": { "connections": 11, "in": 0, "out": 11, "identitySource": "getpeerinfo", "...": "..." },
"net": { "inBps": null, "downloadMeasured": false, "outBps": null, "uploadMeasured": false, "...": "..." },
"attribution": { "recent": [], "pools": [], "...": "same shape as /api/mining" },
"blocks": { "count": 31, "recent": [ { "height": 966546, "totalfee": 1929031, "...": "..." } ],
"backfilled": true, "reorgs": 0 },
"fees": { "f1": 3.1, "f2": 2.0, "f6": 1.1, "f24": 1.0, "f144": 1.0 },
"log": { "exists": false, "source": "disabled", "...": "..." },
"health": { "rpc": { "online": true, "...": "..." }, "cadence": { "fast": { "configuredMs": 4000,
"effectiveMs": 4000, "stretched": false, "lastRunMs": 120 } }, "quality": [] },
"series": { "mempool": { "hour": [ { "t": 1789154820000, "v": 18780 } ], "...": "..." } },
"app": { "version": "0.1.0", "build": "0.1.0-fd620adc52", "scheme": "http", "uptimeSec": 1536,
"sseClients": 5, "self": { "rssMb": 175.6, "heapMb": 42.8, "cpuPct": 2.71 }, "serverTime": 1789155688322 },
"user": { "username": "anonymous", "role": "viewer", "id": "anonymous", "disabled": false, "lastLoginAt": null },
"seq": 406
}Notes:
syncalways carries the node's own identity (node,nodeLabel,endpoint), andstripis the ordered list of facts the sync bar draws.sync.stateis one ofunknown,ibd,catching_up,synced,stalledorreorg(seeserver/collect/sync.js).stalledis claimed only when the connected peers report a tip above the node's (computeSynctakespeerBestHeight, the highestsynced_headers—startingheightas a fallback — overgetpeerinfo); a long gap with the peers agreeing issyncedwith a caveat, and with no peer heights at all the state becomesstalledafter 7200 s without a block.utxois filled only from a node whosegetindexinforeports a syncedcoinstatsindex; the slow tier asksgetindexinfofirst, on its own, and sendsgettxoutsetinfoonly to such a node. Otherwiseutxoisnullandhealth.qualitycarriesutxo-unindexed.health.qualityis the list of named gaps the Overview shows. While the server is building the address index it holdsaddress-index-building(phase, done of total, rows, time left,paused while the node's RPC is slow); a failed build leavesaddress-index-build-failed.rpc-slowandrpc-timeoutsdescribe the measurement and name the index build when one is running rather than asserting a cause.hashrateEstEhis the network hash rate in EH/s, estimated as difficulty x 2^32 divided by the observed mean block gap -- a difficulty-1 target expects 2^32 hashes, so leaving that factor out understates the rate by 4.29 billion (it did until 2026-09-12, and the figure reached the page as0.0 EH/s). The estimate agrees with the node's owngetnetworkhashpsto about one per cent. During IBD, or more than 6 blocks behind headers, it isnullandhashrateNotesays why.mempool.distomits the scatter points (scatterPointsis their count). Fetch them from/api/mempool.blocks.recentholds the newest 40 blocks, newest first.serieshas the chart windows listed under /api/series.
Only the sync bar's data: small enough to poll hard from a status widget.
| Query | Meaning |
|---|---|
node |
Node id. |
{
"node": "main",
"sync": { "state": "synced", "chain": "main", "height": 966546, "headers": 966546, "behind": 0,
"pct": 100, "verificationProgress": 100, "headersMayLag": false, "tipAgeSec": 1408,
"etaSec": null, "etaBest": null, "etaWorst": null, "blocksPerMin": null,
"avgBlockGapSec": 636.7, "sizeOnDisk": 767996069971, "peers": 11, "reorgs": 0,
"warnings": [], "reason": null, "caveats": [], "node": "main", "strip": [] },
"tip": { "height": 966546, "headers": 966546, "hash": "0000...a4aa", "ageSec": 1408, "behindHeaders": 0 },
"chain": "main",
"ibd": false,
"health": { "rpc": { "online": true, "lastError": null } }
}Every configured node with its sync state -- except a Bitcoin Machine Code node (known by its
/BitcoinMachineCode: user agent) that is not yet synced, which is left out of nodes and
attention until it is. /api/state?node=<id> still answers for it.
{
"nodes": [
{ "id": "main", "label": "Bitcoin Core", "rpcUrl": "http://127.0.0.1:8332", "chain": "main",
"online": true, "optional": false, "syncState": "synced", "pct": 100,
"height": 966546, "headers": 966546, "syncing": false }
],
"primary": "main",
"attention": []
}attention lists node ids whose sync state is not synced (including unknown). primary is the node that answers when ?node= is omitted.
Bucketed time series from the in-memory history rings. The rings are persisted to disk and kept for store.retentionHours.
| Query | Type | Default | Meaning |
|---|---|---|---|
node |
string | primary | Node id. |
name |
csv | mempool |
One or more series names (table below). |
field |
csv | all fields of each series | Fields to return. Every field must exist in every named series. |
range (alias since) |
duration | 1h |
How far back. Clamped to 1 s through 31 d. |
points |
int | 240 |
Target number of buckets, 20–2000. bucketMs = round(range / points) to whole seconds, at least 1 s. |
agg |
enum | last |
Per-bucket aggregate: last, first, avg, min, max, sum, delta (the sum of positive steps). |
An unknown name or field is a 400 that lists the valid ones.
{
"node": "main",
"rangeMs": 900000,
"bucketMs": 45000,
"series": {
"mempool": {
"count": [ { "t": 1789154820000, "v": 18780 }, { "t": 1789154865000, "v": 18927 } ]
}
}
}Each point is { t, v }. t is the end of the bucket, and empty buckets are left out.
| Series | Fields |
|---|---|
node |
blocks headers progress difficulty sizeOnDisk ibd connections peersIn peersOut uptimeMs txRate chainTxCount txouts totalAmount muhash |
mempool |
count bytes usage maxUsage totalFee minFee minRelayFee unbroadcast ingestRate acceptedDelta rejectMissing rejectPolicy rejectInvalid confirmedDrain pendingAncestors replaceable avgFee avgVsize |
net |
inBps outBps diskWriteBps inTotal outTotal diskTotal avgRecvBps avgWriteBps floorBps poolMedianBps |
fees |
f1 f2 f6 f24 f144 mempoolmin priority estimatorOk |
peers |
connections in out relayPeers servedBlocks txRelayPeers wanted banned rankingLive rankingAnswered rankingMedianKbps |
blocks |
height time mediantime totalfee txs size weight medianTxSize avgTxSize swtotalSize swtxs avgFeerate subsidy utxoIncrease ins outs avgfee medianfee maxfee p0 p1 p2 p3 p4 gapSec viaPeer source |
txflow |
accepted relayAccepted rejectMissing rejectPolicy rejectInvalid alreadyConfirmed orphansHeld orphansParked orphansResolved orphansDropped inFlight oneP1C oneP1CFailed windowSec |
rpc |
latencyMs avgLatencyMs ratePerSec queued errors breakerTrips busyMsPerSec |
self |
rssMb heapMb sseClients usersActive cpuPct eventRate |
Many fields only fill from a node log source. That source does not currently support Bitcoin Core (the parsers target an experimental node's log grammar), so against Core these stay empty -- empty, never zero. RPC-only is the default and supported mode.
The series object inside /api/state, the snapshot event and the series event uses fixed windows, keyed series.<name>.<window>:
| Series | Windows (field, range) |
|---|---|
mempool |
hour/hours6/day (count over range / range6 / 24 h), bytesHour, usageHour, feeHour, ingestHour |
net |
inHour, outHour, diskHour, inDay |
fees |
f1, f2, f6, f24, f144, min (24 h) |
blocks |
fee, size, txs, gap, p1, p2, p3 (24 h, 300 points) |
peers |
connections, in, out, relay (24 h) |
node |
tip, difficulty (7 d avg), txRate, txouts (7 d avg), disk |
txflow |
accepted, rejectPolicy (1 h avg), orphansHeld, orphansParked, inFlight |
rpc |
latency, rate (1 h) |
The mempool panel's data, including the scatter points and treemap cells that the snapshot leaves out. The data comes from getrawmempool (verbose) on the 20 s pool tier: it is a poll, not a stream, and feed says so.
{
"node": "main",
"feed": { "kind": "poll", "cadenceSec": 60, "streamAvailable": false,
"why": "the node refuses zmqpubsequence: ...",
"source": "getrawmempool verbose on the 20 s pool tier + [tx_accept]/[txrelay] log lines" },
"info": { "loaded": true, "count": 21398, "bytes": 8123456, "usage": 41234567, "maxUsage": 300000000,
"usagePct": 13.7, "totalFee": 0.123, "minFee": 0.00001, "minRelayFee": 0.00001,
"incrementalRelayFee": 0.00001, "unbroadcast": 0, "maxDataCarrier": 83,
"permitBareMultisig": true, "ingestRate": null, "acceptWindow": null, "rejects": null,
"lastDrain": null, "dist": { "...": "as in /api/state" } },
"dist": {
"count": 21398, "totalVsize": 5200000, "totalFeeSat": 12345678,
"avgFeerate": 2.4, "p50Feerate": 1.2, "p90Feerate": 4.1, "maxFeerate": 350.5,
"avgVsize": 243, "oldestSec": 86000, "ageUnknown": 0,
"hist": [ "40 log-spaced feerate buckets, 0.5-2000 sat/vB" ],
"ageHist": [ "30 linear age buckets" ],
"scatter": [ [ 120, 1.25, 141 ] ],
"cells": [ { "vbytes": 141, "rate": 120.0, "txid": "9891f72b..." } ],
"cellCount": 21398,
"pendingAncestors": null, "replaceable": null,
"projected": {
"blockVsize": 1000000,
"skipped": { "n": 4107, "vsize": 999930, "feeSat": 1100000, "maxRate": 350.5, "minRate": 1.01, "medianRate": 1.5 },
"blocks": [ { "n": 5200, "vsize": 999800, "feeSat": 1000000, "maxRate": 1.01, "minRate": 1.0, "medianRate": 1.0 } ],
"rest": { "n": 900, "vsize": 200000, "feeSat": 20000, "maxRate": 1.0, "minRate": 0.1, "blocks": 1 }
}
},
"notReported": [ "pendingancestors", "replaceable (BIP125) flag", "fees.prioritiserved", "modifiedfees",
"ancestorcount/ancestorfees", "withdrawreason", "replaced-by" ],
"log": { "lastDrain": null, "orphans": null, "orphanDetail": null, "accept": null, "relayRate": null },
"fees": { "f1": 3.1, "f2": 2.0, "f6": 1.1, "f24": 1.0, "f144": 1.0 },
"history": { "count": [ { "t": 1789155600000, "v": 21398 } ], "usage": [ { "t": 1789155600000, "v": 41234567 } ] }
}scatterrows are[ageSec, feerate sat/vB, vsize], sampled to at most 1500 points. Entries with an unknown age (time0) or no fee are left out, andageUnknowncounts the unknown ages.cellsholds the richest (up to) 400 transactions, then one aggregate cell for the rest. The aggregate carriesstrata: the tail grouped by feerate, richest first, at most 32 groups of{ "vbytes", "rate", "n" }; the 3D board colours the tail's pieces from them.projectedcuts the pool, richest feerate first, into 1,000,000 vB blocks.skippedis the first block's worth, which the node is assembling itself (see/api/nextblock).blocksholds the next six, andrestsums everything beyond. This is an estimate: the node reports no ancestor data, so a CPFP child is placed at its own feerate.historyholds the last 60 raw ring rows for count and usage.
Every transaction in the next block's worth of the pool, richest feerate first: roughly 3–5k entries and about 100 KB. This feeds the dense block view and is kept out of the snapshot. It is refreshed on the pool tier.
{
"node": "main",
"at": 1789155648684,
"blockVsize": 1000000,
"n": 4107,
"vsize": 999930,
"poolCount": 21398,
"unranked": 0,
"v": [ 189, 189, 110 ],
"r": [ 350.5, 120.0, 88.2 ],
"id": [ "9891f72bea4bc27b", "..." ]
}v, r and id are parallel arrays: vsize, feerate (sat/vB, 2 dp), and a 16-hex txid prefix. unranked counts entries without a reported fee, which cannot be ranked. Before the first pool poll the answer is { "at": null, "n": 0, "v": [], "r": [], "id": [] }.
These go through the same RPC lane as the collectors (up to rpc.maxInFlight at once, starts spaced). They never request getblock verbosity 2, and never return transaction hex. For richer pages use the explorer (section 8).
Recent blocks with per-block statistics from getblockstats.
| Query | Type | Default | Meaning |
|---|---|---|---|
node |
string | primary | Node id. |
limit |
int | 90 |
1–400. The source list holds at most the newest 40 blocks, so larger values return 40. |
{
"node": "main",
"blocks": [
{ "t": 1789154285000, "height": 966546,
"hash": "0000000000000000000019f6fb9d8e1e43e9d72916b2ed8aef080c778685a4aa",
"time": 1789154285, "mediantime": 1789151883, "totalfee": 1929031, "txs": 3441,
"size": 2224872, "weight": 3991722,
"sizeBasis": "sum of transaction sizes (getblockstats total_size); excludes the 80-byte header and the txid-count varint",
"sizeMissing": null, "medianTxSize": 222, "avgTxSize": 646, "avgFeerate": 1,
"swtotalSize": 2121556, "swtxs": 2833, "subsidy": 312500000, "utxoIncrease": 1857,
"ins": 6038, "outs": 7895, "avgfee": 560, "medianfee": 178, "maxfee": 51899,
"p": [0, 1, 1, 2, 3], "viaPeer": null, "source": "getblockstats", "gapSec": 438 }
],
"stats": { "count": 31, "spanSec": 17766, "totalFeesSat": 103184764, "avgFeesSat": 3328541,
"avgSize": 1773700, "maxSize": 3142799, "avgTxs": 3585, "avgGapSec": 636.7,
"medGapSec": 438, "etaCadence": "00:00:10:37" }
}p holds the feerate percentiles (10/25/50/75/90) in sat/vB. gapSec is the time since the previous block. Gaps of 7200 s or more are left out of stats.
The blocks the monitor holds, sampled evenly by height. The Chain page draws these for a node in
initial block download. That node has no last 24 hours of blocks, and it applies blocks faster than
the monitor asks about them (at most the newest 24 per poll), so the blocks the monitor holds are a
real but gappy sample of the sync so far. Every point is a block the node reported; nothing is
interpolated. gapSec is set only when the previous height was also fetched.
| Query | Type | Default | Meaning |
|---|---|---|---|
node |
string | primary | Node id. |
points |
int | 300 |
2–600. At most this many, spread evenly from the oldest held height to the newest (both included). |
{
"node": "bmc-run27", "held": 11136, "from": 647125, "to": 777400,
"points": [ { "height": 647125, "time": 1597000000, "size": 1234567, "totalfee": 23456789, "txs": 2345, "gapSec": null } ]
}One block: header, statistics and the first 50 txids.
| Query | Type | Meaning |
|---|---|---|
hash |
64 hex | Block hash. |
height |
int | Block height (up to 12 digits). |
node |
string | Node id. |
With neither hash nor height, the current tip is returned. A malformed hash or height is a 400. If the node has not answered getblockchaininfo yet, the answer is 503.
{
"ok": true,
"node": "main",
"requested": { "hash": null, "height": "966000",
"resolvedHash": "0000000000000000000013b8a367391f68a9891808c636ced0a399cdc1e0d5ab",
"resolvedHeight": 966000 },
"ms": null,
"header": { "hash": "0000000000000000000013b8a367391f68a9891808c636ced0a399cdc1e0d5ab",
"confirmations": 547, "height": 966000, "version": 551559168, "size": 1661742,
"weight": 3993924, "time": 1788831647, "mediantime": 1788829927,
"merkleRoot": "7d9d386ed407ee2c8bca86fef5c5c28a21163a5220f060447f1e5a17dbd8f140",
"txCount": 5997, "nTx": 5997,
"previousblockhash": "00000000000000000000dbb4d1e55ad22ed5b5a7d81d4c0fe992fceb8a5302d0",
"nextblockhash": "00000000000000000000032a2840c7dfc576808660ddff94773d6db984ff870c",
"bits": "1702355e", "difficulty": 127450789715843.1,
"chainTrust": "000000000000000000000000000000000000000145ab4333c85f0902722fea5e" },
"stats": { "totalfee": 0, "txs": 5997, "feerate_percentiles": [1, 1, 2, 3, 5], "...": "getblockstats reply" },
"statsError": null,
"txids": [ "9445363a2e1715ba61f24d068e7a3aec62a88aad458dee12284d9f89c33d883a",
"9891f72bea4bc27b8dd3990295be331e5e9ae69bc152c5ad31862efbb624ae69" ],
"txidsShown": 50,
"txidsTotal": 5997,
"truncated": true,
"notes": [ "header + txids only: ...", "5947 further txid(s) not listed; ..." ]
}When the node refuses (for example, a pruned block), the answer is 200 with:
{ "ok": false, "node": "main", "query": { "hash": null, "height": "12" },
"error": { "message": "Block not available (pruned data)", "code": -1, "kind": "rpc" },
"hint": "the node does not have this block stored (pruned, ...)" }One transaction, decoded by the node (getrawtransaction verbosity 1).
| Query | Type | Meaning |
|---|---|---|
txid |
64 hex | Required. |
block |
64 hex | Optional block hash. Some nodes need it to find a confirmed transaction. |
node |
string | Node id. |
{
"ok": true,
"node": "main",
"requested": { "txid": "9891f72bea4bc27b8dd3990295be331e5e9ae69bc152c5ad31862efbb624ae69", "block": null },
"txid": "9891f72bea4bc27b8dd3990295be331e5e9ae69bc152c5ad31862efbb624ae69",
"size": 222, "vsize": 141, "weight": 561, "version": 2, "locktime": 0,
"inMempool": false,
"blockHash": "0000000000000000000013b8a367391f68a9891808c636ced0a399cdc1e0d5ab",
"blockHeight": null, "confirmations": 547, "blockTime": 1788831647, "time": 1788831647,
"inputs": [ { "txid": "e990507273c34ad15db3c8491158b976940dfedfa9c42fc0a9adc2204c51376b", "vout": 1,
"sequence": 4294967295, "scriptSigAsm": "", "scriptSigType": null,
"witness": [ "304402205160e150b09b2632eab91797…" ], "value": null, "address": null } ],
"inputsTotal": 1,
"outputs": [ { "n": 0, "value": 0.0022176, "scriptPubKeyType": "witness_v0_keyhash",
"address": "bc1qqe2mj05z2q4zrqly789r59q5k53rhtgn8hznl0",
"scriptPubKeyAsm": "0 0655b93e82502a2183e4f1ca3a1414b5223bad13", "spent": null } ],
"outputsTotal": 2,
"totalOutSat": 12116383,
"notReported": [ "fee / feerate (needs every input's prevout, ...)" ]
}- At most 40 inputs and 40 outputs are listed. Scripts are truncated to 96 characters and witness items to 32.
- Output
valueis in BTC, as the node reports it.totalOutSatis in satoshis. - If the node answers raw hex instead, the result is
{ ok: true, decoded: false, sizeHex, notes }. - A malformed
txidis a400. An unknown transaction isok: falsewith ahint.
Block, transaction and address pages, built on this node's own RPC. Each page is one batched RPC request (sometimes two). It is queued at priority 3 on the RPC lane and may wait up to 45 s. Decoded confirmed transactions are cached in memory (LRU, 3000 entries).
Conventions for all four:
- Bad input is not an HTTP error. A malformed or unknown query answers
200with{ "ok": false, "error": { "message": "..." }, "hint": "..." | null }. - Amounts are in satoshis.
feerateis sat/vB, to 2 dp. usd:/api/x/tx,/api/x/blockand/api/x/addressadd"usd": <number|null>to everyok: trueanswer when market data is enabled. The price is the median of fresh exchange tickers if the Markets tab is polling. Otherwise it is one Coinbase and one Kraken ticker read, cached for 60 s. The server waits at most 1.5 s for a price; if none arrives,usdisnull. The field is absent when markets are disabled./api/x/searchnever carries it.
| Query | Meaning |
|---|---|
q |
A height, block hash, txid or address. |
Classification:
- 1–9 digits: a block (no node call).
- 64 hex:
getblockheaderdecides betweenblockandtx. - 14–100 alphanumerics:
validateaddress.
{ "ok": true, "type": "block", "id": "966000" }{ "ok": false, "error": { "message": "nothing on this node matches \"zzz\"" },
"hint": "a height is digits; a block hash or txid is 64 hex characters; an address starts 1, 3 or bc1" }type is block, tx or address. A hex id is returned lowercased.
| Query | Meaning |
|---|---|
txid |
64 hex. |
Uses getrawtransaction verbosity 2, which carries the fee and every prevout. Then, in a second batch, getblockheader (for the height) and gettxspendingprevout (spent-by, for up to 500 outputs).
{
"ok": true,
"node": "main",
"tx": {
"txid": "9891f72bea4bc27b8dd3990295be331e5e9ae69bc152c5ad31862efbb624ae69",
"hash": "00267bf1d6de9f1a811665fe9cbe3f8f551ead73703e41e4b7f0ecb097669c8e",
"version": 2, "locktime": 0, "size": 222, "vsize": 141, "weight": 561,
"fee": 16920, "feerate": 120, "coinbase": false,
"inSat": 12133303, "outSat": 12116383,
"vin": [ { "txid": "e990507273c34ad15db3c8491158b976940dfedfa9c42fc0a9adc2204c51376b", "vout": 1,
"sequence": 4294967295, "value": 12133303,
"address": "bc1qgwmsxwp6wj0fla53kwtx3h7eeyh3hsuamynp9z",
"type": "witness_v0_keyhash", "height": 965976, "witness": true } ],
"vout": [
{ "n": 0, "value": 221760, "address": "bc1qqe2mj05z2q4zrqly789r59q5k53rhtgn8hznl0",
"type": "witness_v0_keyhash", "spentBy": null },
{ "n": 1, "value": 11894623, "address": "bc1qgwmsxwp6wj0fla53kwtx3h7eeyh3hsuamynp9z",
"type": "witness_v0_keyhash",
"spentBy": { "txid": "6e556dd8ec13181e7f92ecdc6e2172ffa2f9c98ebece6f4245d5cc46eab88ff7",
"blockhash": "0000000000000000000125c3df20202df66378286a3bcd84142877bfae84e466" } }
],
"features": [ "segwit" ],
"blockhash": "0000000000000000000013b8a367391f68a9891808c636ced0a399cdc1e0d5ab",
"confirmations": 547, "time": 1788831647, "height": 966000
},
"tip": 966546,
"outputsShown": 2,
"usd": 77267.47
}- A coinbase input is
{ "coinbase": true, "sequence": ... }, and a coinbase transaction hasfee: 0. featurescan containsegwit,taproot,rbf(some input sequence below0xfffffffe),consolidation(5 or more inputs, 2 or fewer outputs) andop_return.confirmations: 0means the transaction is in the mempool.
| Query | Type | Meaning |
|---|---|---|
id |
height or 64 hex | Required. |
page |
int ≥ 0 | 25 transactions per page. Default 0. |
{
"ok": true,
"node": "main",
"block": { "hash": "0000000000000000000013b8a367391f68a9891808c636ced0a399cdc1e0d5ab",
"height": 966000, "confirmations": 547, "time": 1788831647, "mediantime": 1788829927,
"size": 1661742, "strippedsize": 777394, "weight": 3993924, "version": 551559168,
"versionHex": "20e02000",
"merkleroot": "7d9d386ed407ee2c8bca86fef5c5c28a21163a5220f060447f1e5a17dbd8f140",
"bits": "1702355e", "nonce": 545538887, "difficulty": 127450789715843.1,
"chainwork": "000000000000000000000000000000000000000145ab4333c85f0902722fea5e",
"nTx": 5997,
"previousblockhash": "00000000000000000000dbb4d1e55ad22ed5b5a7d81d4c0fe992fceb8a5302d0",
"nextblockhash": "00000000000000000000032a2840c7dfc576808660ddff94773d6db984ff870c" },
"stats": { "avgfee": 0, "feerate_percentiles": [1, 1, 2, 3, 5], "total_size": 1661600, "...": "full getblockstats reply" },
"pool": { "label": "ViaBTC", "tag": "/ViaBTC/" },
"page": 0,
"pages": 240,
"txs": [
{ "txid": "9445363a2e1715ba61f24d068e7a3aec62a88aad458dee12284d9f89c33d883a", "fee": 0, "feerate": 0,
"vsize": 419, "outSat": 313349306, "coinbase": true, "inCount": 1, "outCount": 6,
"features": [ "op_return" ], "height": 966000, "time": 1788831647, "confirmations": 547 },
{ "txid": "9891f72bea4bc27b8dd3990295be331e5e9ae69bc152c5ad31862efbb624ae69", "fee": 16920, "feerate": 120,
"vsize": 141, "outSat": 12116383, "coinbase": false, "inCount": 1, "outCount": 2,
"features": [ "segwit" ], "height": 966000, "time": 1788831647, "confirmations": 547 }
],
"tip": 966546,
"usd": 77280.04
}pool is set only when mining attribution has a row for that height. A transaction the node could not return appears as { "txid": "...", "missing": true }.
| Query | Type | Meaning |
|---|---|---|
addr |
string | 14–100 alphanumerics. |
page |
int ≥ 0 | 25 transactions per page, newest first. |
Bitcoin Core has no address index — getaddressbalance and getaddresstxids are insight-style
extensions, and a stock Core node answers Method not found (measured 2026-09-13 against both
configured nodes, both Bitcoin Core). So a node configured with addressIndex answers
from BlockYard's own index, built from the node's block files and kept current by a follower
(see Building the address index); the reply says so in
source and describes the index in index.
{
"ok": true,
"node": "main",
"dataNode": "main",
"address": "bc1qqe2mj05z2q4zrqly789r59q5k53rhtgn8hznl0",
"type": "witness v0",
"scriptType": null,
"indexed": true,
"source": "local-index",
"balance": { "balance": 221760, "received": 221760, "utxos": 1 },
"utxos": [ { "txid": "9891f72b...", "n": 0, "value": 221760, "height": 966000 } ],
"utxoNote": null,
"txCount": 1,
"index": { "tip": 966963, "behind": 0, "builtAt": "2026-09-14T06:54:06.609Z", "following": true, "stale": null, "postTip": 0 },
"page": 0,
"pages": 1,
"txs": [ { "txid": "9891f72b...", "fee": 16920, "feerate": 120, "vsize": 141, "outSat": 12116383,
"coinbase": false, "inCount": 1, "outCount": 2, "features": [ "segwit" ],
"height": 966000, "time": 1788831647, "confirmations": 547, "delta": 221760 } ],
"tip": 966546,
"usd": 77280.04
}Without an index the page still answers, but it answers honestly: indexed is false,
txCount is null rather than 0, balance is null, txs is empty and the page says the
node keeps no address index. A count nobody can answer is not zero. validateaddress works
everywhere (script parsing, no index), so the address is still confirmed and typed. Transaction and
block lookups are unaffected — those use txindex.
While the server is building the index (a configured addressIndex directory with no finished
index in it), the answer is the same indexed: false shape plus indexBuilding, so the page can say
how far the build has got:
{
"ok": true, "node": "main", "address": "bc1qqe2mj05z2q4zrqly789r59q5k53rhtgn8hznl0", "type": "witness v0",
"indexed": false, "txCount": null, "balance": null, "txs": [], "page": 0, "pages": 1,
"indexError": "Method not found", "balanceError": "Method not found", "localIndexError": null,
"indexBuilding": { "dir": "/var/lib/blockyard/index", "node": "main", "phase": "scan",
"done": 1812, "total": 4870, "rows": 2190345120, "eta": "19 min",
"startedAt": 1789154285000, "error": null, "paused": false },
"tip": 966546, "usd": 77280.04
}indexBuilding is null whenever no build is running. phase is heights (block hashes over
RPC; done/total are heights), scan (block files on the worker pool; done/total are files)
or sort (buckets); rows is the count so far, eta a short duration string or null, and
paused is true while the build is held because the node's RPC is failing or slow. On finish the
follower starts and the next request answers indexed: true.
typeiswitness v<N>,scriptorlegacy.indexedsays whether this node can answer address history at all. Whenfalse,txCountisnull,balanceisnull, and nothing on the page is derived from the refusal;indexErrorandbalanceErrorcarry the node's own message (Method not foundon Core), andlocalIndexErrorsays why a configuredaddressIndexcould not be opened, so a broken index does not look like an unconfigured one.sourceislocal-indexwhen the answer comes from BlockYard's index;dataNodeis the node whose RPC the follower reads (one index serves every node on the same chain).balance.balanceandbalance.receivedare sums of each transaction's net for the address (a transaction that both paid and spent it counts once, by its net).utxoslists the address's unspent outputs — every transaction the index says touched it, each output paying it checked withgettxout(the UTXO set, less what the mempool already spends),heightfrom the index's own row — andbalance.utxosis their count. The walk is the whole history, so it is made for an address with at most 100 transactions; a longer one getsutxos: nulland autxoNotesaying so.index.tipis the highest block the index covers (base, folded layers and the follower's live tail together);behindis how many blocks the node is ahead of it;builtAtis the manifest's build time;followingsays a follower is running;staleis a message when it has stopped (a reorganisation deeper than its tail, asking for a rebuild);postTipcounts rows above the node's own tip, which are excluded from the balance and the history rather than shown.deltais the net change to this address in satoshis: outputs to it minus inputs from it. A transaction the node could not return is{ "txid": ..., "missing": true, "height": ..., "delta": ... }— the height and amount are the index's own.- Pages are 25 rows, newest first, and a deep page costs no more than the first: the index keeps
only
skip + limitrows in a ring while it sums the balance.
Pool attribution over the observed window of recent blocks, plus the cached block template. Attribution costs two cheap reads per block and can be turned off with BLOCKYARD_MINING=0.
{
"node": "main",
"recent": [
{ "height": 966546, "hash": "0000...a4aa", "at": 1789154285000, "poolKey": "viabtc",
"tagText": "/ViaBTC/", "tagSource": "push", "tagParseable": true, "tagHeightMatch": true,
"weight": 3993366, "size": 2225310, "strippedSize": 589352, "txs": 3441, "totalfee": 1929031,
"avgFeerate": 1, "p50": 1, "p75": 1, "p99": 3, "extraNonce": null, "commitment": "fabe6d6d",
"rawCoinbase": "0392bf0e...", "poolLabel": "ViaBTC", "poolLabelKey": "viabtc",
"matchedTag": "/ViaBTC/", "seenAt": 1789154316094, "poolName": null }
],
"pools": [
{ "poolKey": "viabtc", "label": "ViaBTC", "tagText": "/ViaBTC/", "tags": [ "/ViaBTC/" ],
"blocks": 7, "sharePct": 22.6, "txs": 21361, "avgWeight": 3993511, "medianSize": 1791590,
"avgFeeRate": 2.14, "medianFeeRate": 1, "totalFeesSat": 20814877,
"firstHeight": 966519, "lastHeight": 966546, "name": null }
],
"byPool": [ { "poolKey": "foundry usa", "label": "Foundry USA", "blocks": 8, "sharePct": 25.8,
"name": "Foundry USA", "labelled": true, "...": "same fields as pools" } ],
"labelSource": null,
"nextBlock": { "...": "the last /api/nextblock result, or null" },
"nextBlockAgeMs": 12000,
"windowBlocks": 31,
"windowHeights": { "from": 966516, "to": 966546 },
"fetched": 31, "at": 1789154316094, "lastError": null, "skippedIbd": 0,
"aliasesLoaded": false, "enabled": true,
"note": "shares are of the observed window only, ..."
}recentholds the newest 40 attributed blocks.poolsgroups blocks by coinbase tag.byPoolgroups by pool label, when a pool map is loaded (labelSourcethen names its source and hash).poolName/namecome fromdata/pool-aliases.json. When a block has no alias, the coinbase text is shown as the pool wrote it.
The block being built right now, assembled by this server from the mempool — it makes no RPC call of its own. Core publishes depends, the ancestor sizes and fees, and fees.chunk/chunkweight in getrawmempool(true), which the monitor already reads every 20 s for the mempool view; server/collect/gbt.js selects greedily over the node's own chunk feerate, taking each transaction with its unselected ancestors. Assembly costs this process ~50–70 ms and your node nothing. The reply carries assembledLocally: true, source: "getrawmempool", poolSize and poolAgeMs so a caller can see how fresh its input was. Measured against the node's own getblocktemplate on the same pool: 0.03% apart on total fees. It is a reconstruction of what a miner would choose, not the node's template — sigop limits and policy the mempool does not publish are not modelled.
| Query | Type | Default | Meaning |
|---|---|---|---|
node |
string | primary | Node id. |
stale |
ms | 15000 |
Serve the cached template if it is younger than this. |
refresh |
1 |
— | Force a new call. |
{
"node": "main",
"height": 966547,
"previous": "0000000000000000000019f6fb9d8e1e43e9d72916b2ed8aef080c778685a4aa",
"txCount": 1186,
"weight": 2027641, "weightLimit": 4000000, "weightPct": 50.7,
"totalFeesSat": 1087392, "coinbaseSat": 313587392,
"feeRate": { "...": "summary" },
"feeRateHistogram": [ { "lo": 0, "hi": 0.1, "n": 57, "weight": 30528 } ],
"mintime": 1789151884, "bits": "1702355e", "sigopLimit": 80000, "sizeLimit": 4000000,
"version": 536870912, "signal": null,
"packages": { "total": 1100, "multiTx": 40, "txsInPackages": 126, "largest": 5,
"cpfpCandidates": 12, "sizeHistogram": [], "top": [] },
"economy": { "remainingWeight": 1972359, "remainingPct": 49.3, "marginal": null, "spillCount": 0,
"spillWeight": 0, "poolBytes": 8123456, "poolFitsNextPct": 100, "backlogBlocks": 0,
"blockBytesEstimate": 1661742, "ahead": [], "note": "..." },
"visual": { "cells": [], "totalVbytes": 506910, "tailCount": 786 },
"at": 1789155693548,
"ms": 72,
"note": "the block being built is assembled here from getrawmempool(true) ...",
"assembledLocally": true,
"source": "getrawmempool",
"poolSize": 30768,
"poolAgeMs": 2276
}When no template is available, the answer is { "node": "...", "unavailable": "<reason>" }. The reasons are: disabled with BLOCKYARD_MINING_TEMPLATE=0, the node is in initial download, or the node's error. If a refresh fails after an earlier success, the last template is returned with a lastError field.
{
"node": "main",
"counts": { "connections": 11, "in": 0, "out": 11, "wanted": null, "budget": null,
"banned": null, "bannedOf": null },
"ranking": null,
"identitySource": "getpeerinfo",
"identity": null,
"rpcRows": 11,
"rpcUpdatedAt": 1789155718231,
"rpcPeers": [ { "id": 3, "addr": "192.0.2.10:8333", "subver": "/Satoshi:27.0.0/", "...": "getpeerinfo row, verbatim" } ],
"activity": [],
"recentEvents": [],
"network": { "version": 10000, "subversion": "/Satoshi:29.0.0/", "protocol": 70016,
"services": "0000000000000409", "servicesNames": [ "NETWORK", "WITNESS" ],
"networkactive": true, "relayfee": 0.00001, "incrementalfee": 0.00001,
"networks": [], "localaddresses": [] }
}rpcPeersis the node'sgetpeerinforeply, passed through as-is.activity,identity,ranking,wanted,budgetandbannedcome from the node log, and stay empty ornullunlessBLOCKYARD_LOG_SOURCE=1.identitySourcenames the source in use.
Bandwidth and disk, with every absent figure listed in words.
{
"node": "main",
"measured": { "inBps": null, "diskWriteBps": null, "netTotalLog": null, "diskTotal": null,
"avgRecv": null, "avgWrite": null, "floor": null, "poolMedian": null,
"source": "getnettotals delta rate (RPC) — reported only while that counter moves; ...",
"downloadMeasured": false },
"rpc": { "totalRecv": 0, "totalSent": 0, "uploadtarget": { "...": "getnettotals uploadtarget" }, "uploadMeasured": false },
"unavailable": [ "outbound bytes / upload rate: ...", "inbound bytes / download rate: ...", "log-only figures (...)" ],
"peers": { "connections": 11, "in": 0, "out": 11, "wanted": null },
"series": null,
"formatted": { "inBps": null, "disk": null }
}inBps / outBps are null (never 0) while the node's byte counter reads zero.
The monitor's own event feed: collector errors, quality flags, new tips, reorgs and so on. It is newest first and shared by all nodes; each row names its node. Rows of kind index mark the start, the finish and a failure of an address index build the server runs; the browser shows those three as notifications.
| Query | Type | Default | Meaning |
|---|---|---|---|
since |
int | 0 |
Return only rows with seq greater than this. |
limit |
int | 200 |
1–1000. |
source |
csv | monitor |
monitor, log, …, or all. Node-log lines are excluded unless asked for. |
severity |
csv | — | For example warn,error. |
kind |
csv | — | For example collector_error. |
q |
string | — | Case-insensitive substring over text, tag and kind. |
{
"events": [
{ "seq": 194639, "source": "monitor", "kind": "collector_error", "severity": "warn",
"tag": "collector", "ts": 1789107616900,
"text": "getrawmempool verbose failed: RPC circuit breaker open; retry in 22s (...)",
"node": "main" }
],
"maxSeq": 194639,
"count": 8
}To poll incrementally, keep maxSeq and pass it back as since. The filters are applied to a window of limit × 4 rows, and count is the number of matches in that window.
Exchange prices from the public REST APIs of Coinbase, Kraken, Bitstamp, Bitfinex, Gemini and OKX. This is the only outbound connection BlockYard makes that is not to the node. It runs server-side, because the page's CSP allows connect-src 'self' only.
- Off by default. Polling runs only while the Display setting Markets & Price → Enable market polling is on (
markets.pollingin the shared settings file, read per request). With it off, both endpoints answer{ "ok": true, "enabled": false, "polling": false, "note": "market polling is off ..." }, the call parks the feed, and the monitor makes no outbound connection but to the node. BLOCKYARD_MARKETS=0(ormarkets.enabled=false) removes the feed altogether; both endpoints then answer{ "ok": true, "enabled": false, "note": "market data is off on this server ..." }whatever the setting.- On demand. With polling on, nothing is fetched until someone calls
/api/marketsor/api/markets/depth. Each call "touches" the feed. The dashboard's Overview makes that call by default (Display settings → Markets & Price → Price line on Overview), so a monitor with anyone on its landing page is polling. While touched, it polls tickers every 15 s, hourly candles every 5 min and order books every 30 s. It stops 10 minutes after the last touch. - An exchange that fails keeps its last data and reports
error. It is never dropped or zero-filled.
Optional ?tf=1m|5m|15m: also fetch and return that grain's candles (the latest 120) as bars on every
exchange -- { grain, sec, candles, at, error } -- which is what the 1 h, 3 h and 12 h charts draw. Asking is what
keeps a grain being fetched; it is dropped ten minutes after the last ask. The first reply after a grain is first
asked for may carry empty bars (they are fetched after the ask). Any other value is the hourly view alone. The
reply's grain says which was honoured, and grains lists what may be asked for.
{
"ok": true, "enabled": true, "at": 1789155671855, "running": true,
"interval": "1h", "tickerMs": 15000, "warming": false,
"summary": { "median": 77282.55, "spread": 14.17, "vol24": 23204.09, "reporting": 4 },
"exchanges": [
{ "id": "coinbase", "name": "Coinbase", "pair": "BTC-USD", "quote": "USD",
"last": 77286.14, "bid": 77286.13, "ask": 77286.14, "spread": 0.01, "vol24": 7289.43,
"change24": 0.00205, "high24": 79852.22, "low24": 76030,
"at": 1789155664470, "stale": false, "error": null,
"candles": [ { "t": 1788552000000, "o": 79808.55, "h": 79890, "l": 79680.01, "c": 79718.63, "v": 181.98 } ],
"candlesAt": 1789155409559 }
]
}summary.medianandsummary.spreaduse USD-quoted exchanges only (OKX quotes USDT). Stale tickers are excluded.reportingis how many exchanges contributed.staleistruewhen the ticker is older than 3 ×tickerMs.change24is a fraction, relative to the open of the first hourly candle within the last 24 h.candlesare hourly, oldest first, up to 168 (7 days).vis in BTC.warmingistrueuntil any exchange has reported a price. The first call after an idle period usually returnswarming: true.
The order books as cumulative depth on a fixed price grid, plus the snapshot from ago seconds earlier for comparison.
| Query | Type | Default | Meaning |
|---|---|---|---|
ago |
seconds | 600 |
One of 60, 300, 600, 1800, 3600. Other values become 600. |
{
"ok": true, "enabled": true, "running": true, "bookMs": 30000,
"at": 1789155680845, "thenAt": 1789155380703, "firstAt": 1789154211190, "ago": 300,
"mid": 77276.995, "p0": 68000, "n": 372, "step": 50,
"exchanges": [
{ "id": "coinbase", "name": "Coinbase", "quote": "USD",
"bids": [ 1127.511, 1087.61, 1087.313, "..." ],
"asks": [ null, null, null, "..." ],
"thenBids": [ 1137.445, 1097.559, 1097.261, "..." ],
"thenAsks": [ null, null, null, "..." ],
"error": null, "bookAt": 1789155680841 }
]
}- The grid is
price(i) = p0 + i × stepforifrom 0 ton - 1.stepis $50, and the grid spans ±12 % around the median mid across exchanges. bids[i]is the total BTC bid at or aboveprice(i).asks[i]is the total BTC asked at or below it.- A value is
nullwhere that side has nothing to say: above the best bid, below the best ask, or past the end of a book the exchange cut short. thenBids/thenAsksare the older snapshot, re-aligned onto the current grid. They arenulluntil an old enough snapshot exists. History is kept for about an hour.- Before the first book poll completes, the answer is
{ ok, enabled, running, bookMs, "warming": true, "exchanges": [ { "id", "name", "error" } ] }.
Auth any, with CSRF when accounts are on. Runs one read-only RPC method against the node, through the same lane as the collectors.
| Body field | Type | Meaning |
|---|---|---|
method |
string | RPC method name. |
params |
array | Positional parameters. Anything that is not an array is replaced with []. |
| Query | Meaning |
|---|---|
node |
Node id. |
Success:
{ "ok": true, "node": "main", "method": "getblockcount", "ms": 314, "result": 966546, "note": null }The node returned an error (still HTTP 200):
{ "ok": false, "node": "main", "method": "getblock", "ms": 12,
"error": { "message": "Block not found", "code": -5, "kind": "rpc" } }The allowlist refused the method (HTTP 403):
{ "error": { "message": "stop is not callable from the web UI: this method mutates state or monopolises the node's single-threaded RPC server",
"kind": "api", "code": "rpc_denied" } }note is set for methods the node itself documents as refused or worker-owned, so an error there is expected: loadtxoutset, getopenrpcinfo, rpc.discover, exportasmap, getmemoryinfo, getblockfrompeer, preciousblock, pruneblockchain, submitheader.
Every call, allowed or denied, is written to the audit log (method name, node, duration, and the error if any). Parameters are not recorded.
The default is deny. The rules are applied in this order, and the first match wins:
-
Not a non-empty string: denied.
-
Exact deny list: denied, even when the name looks like a read. The list:
Group Methods Wallet key material getnewaddress,getrawchangeaddress,keypoolrefill,addhdkeySpends and broadcasts sendtoaddress,sendmany,send,sendall,sendrawtransaction,submitpackage,walletcreatefundedpsbt,fundrawtransaction,bumpfee,psbtbumpfee,signrawtransactionwithwalletWallet state walletpassphrase,walletlock,walletpassphrasechange,encryptwallet,setlabel,importprivkey,importaddress,importpublickey,importdescriptors,importmempool,importmulti,importprunedfunds,removeprunedfunds,createwallet,loadwallet,unloadwallet,restorewallet,migratewallet,setwalletflag,backupwallet,exportwatchonlywallet,abandontransaction,lockunspent,sethdseed,settxfee,signmessage,signrawtransactionwithkey,walletprocesspsbt,descriptorprocesspsbtPeer / network control addnode,removeaddednode,disconnectnode,setban,clearbanned,setnetworkactive,pingChain / storage invalidateblock,reconsiderblock,preciousblock,pruneblockchain,submitblock,submitheader,stop,savemempool,dumptxoutset,loadtxoutset,simulateutxoHeavy reads that monopolise the single-threaded RPC server rescanblockchain,scanblocks,scantxoutset,getdescriptoractivity,verifychainOther logging -
Deny prefixes: denied. The prefixes are
generate,invalidate,reconsider,import,send,set,unload,load,sign. A node that adds vendor-prefixed commands of its own beyond Core's RPC set has the mutating shapes of those denied too, by the same rule. -
helpanduptimeare allowed.stopis denied. -
Allow prefixes: allowed. The prefixes are
get,list,estimate,verify,help,uptime,decoderaw,decodescript,createraw,analyzepsbt,decodepsbt,convertbits,getrpcinfo. Where a node adds vendor-prefixed commands, its read verbs are admitted by name (itsget/list/estimate/verifyforms) and the bare vendor prefix is not, so a future vendor-prefixedsetbancannot be pre-authorised. -
Anything else: denied, with the message "not recognised as a read-only method; add it to server/rpc/allowlist.js if this is wrong".
Consequences worth knowing:
- The list is fixed in code, and no setting widens it.
- Wallet RPCs are refused by name, reads included (
getbalance,listunspent,listdescriptors,gethdkeys, …): some of them return private keys, and the monitor has no use for a wallet. createrawtransactionis allowed: it only builds an unsigned transaction and changes nothing./api/config→allowlistpublishes a summary: the prefixes, the size of the deny list, the number of wallet methods refused, and the default decision.
Node writes never go through this endpoint. They are separate, opt-in actions (next section).
A small, fixed set of node writes. They are off by default, each one must be listed explicitly, each is role-gated, needs a typed confirmation, and is audited.
| Action | RPC method | Minimum role | Args (body args keys) |
|---|---|---|---|
broadcast |
sendrawtransaction |
operator | hexstring, maxfeerate? |
savemempool |
savemempool |
operator | — |
testmempoolaccept |
testmempoolaccept |
viewer | rawtxs, maxfeerate? |
verifychain_l1 |
verifychain |
admin | fixed to [2, 6] (checklevel 2, depth 6) |
To enable them: BLOCKYARD_ENABLE_ACTIONS=1 and BLOCKYARD_ACTIONS=broadcast,testmempoolaccept (or actions.enabled / actions.allow). With accounts off they also need BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH=1. Enabling actions in open mode without that setting is a fatal configuration error at startup.
{
"enabled": false,
"allowed": [],
"actions": [
{ "name": "broadcast", "label": "Broadcast a raw transaction", "note": "Pushes a signed transaction ...",
"args": [ "hexstring", "maxfeerate?" ], "requiredRole": "operator",
"enabled": false, "permittedForYou": false, "method": "sendrawtransaction" }
]
}Auth any + CSRF.
| Body field | Type | Meaning |
|---|---|---|
action |
string | Action name. |
confirm |
string | Must equal action exactly. |
args |
object | Named arguments from the table, for example { "hexstring": "0200..." }. Names ending in ? are optional. |
| Query | Meaning |
|---|---|
node |
Node id. |
The checks run in this order, each a 403 action_denied unless noted:
- unknown action;
- actions disabled;
- accounts off without the writes override;
- action not in the allow list;
- caller's role below the action's role;
confirmmissing or different:400 confirm_required.
{ "ok": true, "action": "testmempoolaccept", "method": "testmempoolaccept",
"result": [ { "txid": "aaaa...", "allowed": true } ] }If the node refuses, the answer is 200 with { "ok": false, "action", "method", "error": { "message", "code" } }.
These routes are meaningful only with accounts on (the default). In open mode they answer as noted.
The user object returned by these routes (publicUser):
{ "username": "alice", "role": "operator", "id": "3f9a1c0b7d2e4a61", "disabled": false, "lastLoginAt": 1789150000000 }Auth none, no CSRF. Body: { "username": "...", "password": "..." } (JSON or form-encoded).
{ "ok": true, "user": { "username": "alice", "role": "operator", "id": "3f9a1c0b7d2e4a61", "disabled": false, "lastLoginAt": 1789150000000 },
"csrf": "Zk2c...base64url" }It also sets the blockyard_sid and blockyard_csrf cookies. Errors:
| Status | code |
Meaning |
|---|---|---|
| 400 | — | Username or password missing. |
| 401 | bad_credentials |
Wrong password, unknown user or disabled account. The message is the same in all three cases. |
| 403 | accounts_disabled |
Open mode. |
| 429 | throttled |
The per-IP login bucket is empty. |
| 429 | locked |
Too many failures for this username or address. |
Auth any + CSRF. Ends the current session and clears both cookies: { "ok": true }. In open mode it answers { "ok": true, "accounts": false, "note": "accounts are off, so there is no session to end" }.
Auth any + CSRF. Revokes every session of the calling user: { "ok": true, "revoked": 3 }. In open mode: 403.
Auth any. Returns who you are, your sessions, and what you may do. With accounts on:
{
"user": { "username": "alice", "role": "operator", "id": "3f9a1c0b7d2e4a61", "disabled": false, "lastLoginAt": 1789150000000 },
"accounts": true,
"sessions": [ { "createdAt": 1789150000000, "lastSeenAt": 1789155000000, "ip": "192.0.2.20",
"userAgent": "Mozilla/5.0 ...", "current": true } ],
"actions": [ "... as in /api/actions ..." ],
"capabilities": { "canCallRpc": true, "canAct": false, "actionsEnabled": false,
"allowedActions": [], "ceiling": "operator" }
}In open mode: "accounts": false, "sessions": [], a note, and capabilities with "ceiling": "viewer", "canAct": false and "writesRequireAccounts": true|false.
Auth any + CSRF.
| Body field | Meaning |
|---|---|
current |
Your current password. Required when changing your own. |
password |
The new password. |
username |
Admins only: change another user's password (no current needed). |
Password rules: at least 12 characters (auth.minPasswordChars), must not contain the username, must not start with a common breach-corpus word, and must not be a single repeated character.
On success, every session of the target user is revoked:
{ "ok": true, "signedOut": true, "note": "sessions revoked; sign in again with the new password" }A wrong current is 403. A rule violation is 400. In open mode: 403.
Auth admin.
{
"users": [ { "id": "3f9a1c0b7d2e4a61", "username": "alice", "role": "operator",
"createdAt": 1789000000000, "updatedAt": 1789000000000, "lastLoginAt": 1789150000000,
"disabled": false, "scheme": "scrypt", "kdf": { "N": 16384, "r": 8, "p": 1, "keylen": 32 } } ],
"roles": [ "viewer", "operator", "admin" ]
}Auth admin + CSRF. Body: { "username", "password", "role"? }. role defaults to viewer. Usernames are 2–31 characters of lowercase letters, digits, ., - and _.
{ "ok": true, "user": { "id": "3f9a1c0b7d2e4a61", "username": "alice", "role": "operator" } }A validation failure is a 400 with the reason.
Auth admin + CSRF. Body: { "username", "role"? }. Creates the user with a generated 18-character password that is shown exactly once:
{ "ok": true, "user": { "id": "...", "username": "bob", "role": "viewer" },
"password": "generated-once", "warning": "this password is shown once and is not stored in recoverable form" }Auth admin + CSRF. Body: { "role": "viewer" | "operator" | "admin" }. Revokes the user's sessions.
{ "ok": true, "user": { "id": "...", "username": "bob", "role": "operator" } }Demoting the last enabled admin is refused with 400.
Auth admin + CSRF. Body: { "disabled": true | false }. Disabling revokes the user's sessions.
{ "ok": true, "user": { "id": "...", "username": "bob", "disabled": true } }Disabling the last enabled admin is refused with 400.
Auth admin.
| Query | Default | Meaning |
|---|---|---|
limit |
100 |
1–500. |
{
"entries": [ { "at": 1789155000000, "type": "rpc", "ok": true, "username": "alice", "node": "main",
"method": "getblockcount", "ms": 12, "ip": "192.0.2.20" } ],
"limit": 100,
"log": { "file": "audit.jsonl", "files": 1, "totalBytes": 20480, "currentBytes": 20480,
"maxBytes": 8388608, "keep": 5, "rotations": 0, "rotationError": null, "headroomBytes": 8368128 }
}The file rotates at 8 MB and keeps 5 files (store.auditMaxBytes, store.auditKeep). Entry type values: login, login-throttled, logout, logout-all, kdf-upgrade, rpc, rpc-denied, action, action-denied, user-create, user-role, user-disabled, password-change, csrf-rejected, config-node. Passwords are never recorded.
The monitor's own health: process, RPC client, log tail, stream clients and the audit file.
{
"self": { "t": 1789155700000, "rssMb": 175.6, "heapMb": 42.8, "sseClients": 5, "usersActive": 0,
"cpuPct": 2.71, "eventRate": 0, "build": "0.1.0-fd620adc52" },
"nodes": [
{ "id": "main",
"rpc": { "nodeId": "main", "url": "http://127.0.0.1:8332", "cookieSource": "...", "online": true,
"lastGoodAt": 1789155699000, "lastError": null, "breakerOpen": false, "breaker": {},
"queued": 0, "inFlight": 0, "maxInFlight": 4, "peakInFlight": 2, "recent": [ "..." ],
"calls": 12345, "batches": 2345, "methods": {}, "errors": 3, "timeouts": 0,
"authRetries": 0, "breakerTrips": 1, "lastLatencyMs": 8, "avgLatencyMs": 40,
"maxLatencyMs": 4200, "ratePerSec": 1.8, "busyMsPerSec": 90, "staleDropped": 0 },
"log": { "exists": false },
"tiers": { "fast": 1789155699000 },
"lastTier": {},
"history": { "mempool": { "points": 5000, "firstAt": 1789000000000, "lastAt": 1789155699000,
"unattributed": 0, "nodes": [ "main" ] } } }
],
"sse": { "clients": 1, "perClient": [ { "id": 1, "user": "anonymous", "seconds": 1666, "kb": 243531,
"dropped": { "snapshot": 0, "series": 0, "events": 0 }, "node": "main" } ] },
"audit": { "file": "audit.jsonl", "files": 1, "totalBytes": 20480, "...": "as in /api/audit log" }
}In nodes[].rpc, inFlight is calls outstanding now, maxInFlight the lane's limit, and peakInFlight the most seen at once (the Node & RPC page shows N of M (peak P)).
The effective, non-secret configuration and the access posture.
{
"poll": { "fastMs": 4000, "midMs": 15000, "poolMs": 20000, "slowMs": 60000, "rareMs": 900000, "blockBackfill": 30 },
"rpc": { "maxInFlight": 4, "minIntervalMs": 250, "maxRatePerSec": 4, "timeoutMs": 90000 },
"allowlist": { "denyExactCount": 68, "walletDenied": 73, "allowPrefixes": [ "analyzepsbt", "convertbits", "..." ],
"denyPrefixes": [ "generate", "..." ], "defaultDecision": "deny" },
"actions": { "enabled": false, "allow": [] },
"retention": { "hours": 72, "ringCapacity": 20000, "events": 5000 },
"access": { "mode": "accounts", "anonymous": false },
"log": { "enabled": false },
"sources": [ { "panel": "sync bar", "source": "getblockchaininfo blocks/headers + verificationprogress",
"note": "kept as two separate figures (rule 9)" } ]
}In open mode (BLOCKYARD_AUTH=0), access is { "mode": "open", "anonymous": true, "role": "viewer", "writesAllowed": false }. sources states which data source backs each dashboard panel in the current mode (log tail on or off). The numbers above are the shipped defaults.
The Node connection form on Node & RPC. Auth any + CSRF; with accounts on, both need the admin role. Both take the same body and accept only four fields — rpcUrl (required, http(s)://host:port), datadir, chainHint (default: the current node's, else main) and label. rpcUser, rpcPassword and cookieFile are refused: authentication is the datadir cookie, and a password is not taken over an endpoint that can be run open. Without a datadir (given or already configured) and with no cookieFile configured, the answer is 400 need_datadir; a bad URL is 400 bad_rpc_url.
/test writes nothing. It probes getblockchaininfo with a throwaway client on its own lane (timeout at most 8 s). Credentials go to one endpoint only: the cookie is sent when rpcUrl equals the endpoint this monitor is already configured for, and to any other address the probe carries no Authorization header at all — a 401 from a new endpoint is reported as ok: true, reachable: true, authenticated: false with a note, since it proves an RPC server answered.
{ "ok": true, "ms": 41, "authenticated": true, "chain": "main", "blocks": 966546, "ibd": false }A failed probe is 200 with { "ok": false, "ms", "authenticated", "error": { "message", "kind", "code" } }.
/api/config/node needs "confirm": "save" in the body (400 confirm_required otherwise) and merges the four fields onto nodes[0] of the configuration file, leaving every other key as it was; the file is written 0600 by temporary file, fsync and rename. A process started with BLOCKYARD_CONFIG=none has nowhere to save and answers 409 no_config_file. The write is audited as config-node.
{ "ok": true, "file": "/path/to/config/local.json", "restartRequired": true,
"envOverrides": [ "BLOCKYARD_NODE_URL" ],
"note": "saved, but this process takes its node from BLOCKYARD_NODE_URL, which the environment sets and which beats the file — ..." }envOverrides lists the node environment variables currently set, because the environment is applied after the file and a restart would keep the old endpoint.
The Display settings, kept on the server in config/blockyard.json (beside the configuration file) so every browser sees the same choices. The server stores the blob and nothing else: it has no schema, and public/js/settings.js clamps every value on the way in.
GET is auth any. Nothing saved yet is the normal first-run answer, not an error:
{ "settings": { "version": 3, "space": { "...": "..." }, "...": "..." }, "file": "/path/to/config/blockyard.json", "stored": true }With no file: { "settings": null, "file": "...", "stored": false }; an unreadable file adds "error": "unreadable: ...".
POST is auth any + CSRF, admin with accounts on. Body: { "settings": { ... } } (an object; anything else is 400 bad_settings). The serialised text is capped at 256 KB (413 too_large), and the file is written 0600 by temporary file, fsync and rename. Reply: { "ok": true, "file": "...", "bytes": 2431 }.
Server-Sent Events over plain HTTP: one-way, node to browser, with no WebSocket and no dependency. A browser EventSource reconnects on its own.
GET /api/stream?node=main
Accept: text/event-stream
- Auth. Open mode: anyone. Accounts on: a valid
blockyard_sidcookie, otherwise401{ "error": { "message": "authentication required", "kind": "auth" } }. - Limit. Each connection attempt spends one token from the stream bucket (section 1). An empty bucket answers
429{ "error": { "message": "too many streams", "kind": "ratelimited" } }. - Node.
?node=<id>scopes the stream to one node. An unknown id is refused with404(kind: "unknown_node"), so a client pointed at a removed node gets a visible failure instead of a silent stream. Onlynodeworks here, notnodeId. - Headers.
Content-Type: text/event-stream; charset=utf-8,Cache-Control: no-cache, no-store, must-revalidate,Connection: keep-alive,X-Accel-Buffering: no(so nginx does not buffer the stream).
Always pass ?node=. series and events frames are delivered only to streams scoped to the node they belong to. A stream opened without ?node= receives snapshot frames for every node, but no series or events frames.
The stream opens with the comment line : stream open. After that:
| Event | id: |
Cadence | data |
|---|---|---|---|
snapshot |
the global seq |
at most 1/s per node, sent when a collector tier finishes | The same object as GET /api/state without app and user, and with the default series windows included. It is large: hundreds of KB with series. |
series |
— | every 20 s while any client is connected | { "node": "main", "series": { "mempool": { "hour": [ {"t","v"} ], "...": "..." }, "...": "..." } } (the windows in section 5) |
events |
— | batched as they occur | An array of event rows, same shape as /api/events rows. Routine raw/info chatter is filtered out. |
comment : ping |
— | every 15 s | Heartbeat. There is no payload. |
Example wire format:
: stream open
id: 406
event: snapshot
data: {"id":"main","label":"Bitcoin Core","online":true,"chain":"main","tip":{"height":966546,...},"seq":406,...}
event: events
data: [{"seq":194640,"source":"monitor","kind":"tip_jump","severity":"info","ts":1789155700000,"text":"...","node":"main"}]
: ping
- Backpressure. Each client has at most one pending
snapshotand one pendingseries, and the newest replaces an unsent older one. The event batch is capped: past 800 queued rows, the oldest 400 are dropped./api/telemetry→sse.perClient[].droppedcounts what was skipped. A slow client sees a coarser stream, not a growing queue. - Reconnects. The server sends no
retry:field, so clients use their default delay (about 3 s in browsers).Last-Event-IDis not replayed. After a reconnect, the nextsnapshotis the complete current state, and/api/events?since=<last seq>fills any gap in the event feed. - Dead connections. A client socket that has closed is removed on the next write or heartbeat.
Get the current state of the primary node, without chart series:
curl -s 'http://127.0.0.1:21000/api/state?series=none' | jq '{height: .tip.height, sync: .sync.state, mempool: .mempool.count}'Look up a transaction, first through search, then as a full page:
curl -s 'http://127.0.0.1:21000/api/x/search?q=9891f72bea4bc27b8dd3990295be331e5e9ae69bc152c5ad31862efbb624ae69'
# {"ok":true,"type":"tx","id":"9891f72b..."}
curl -s 'http://127.0.0.1:21000/api/x/tx?txid=9891f72bea4bc27b8dd3990295be331e5e9ae69bc152c5ad31862efbb624ae69' \
| jq '{ok, fee: .tx.fee, feerate: .tx.feerate, height: .tx.height, usd}'Stream live updates (-N turns off curl's buffering):
curl -sN 'http://127.0.0.1:21000/api/stream?node=main' | grep --line-buffered '^event:'Query market depth. The first call starts exchange polling, and books appear within about 30 s:
curl -s 'http://127.0.0.1:21000/api/markets' > /dev/null
sleep 35
curl -s 'http://127.0.0.1:21000/api/markets/depth?ago=300' \
| jq '{mid, p0, step, n, coinbase: (.exchanges[] | select(.id=="coinbase") | {bestBidDepth: (.bids | map(select(. != null)) | last), error})}'Call a read-only RPC method (open mode):
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"method":"getblockchaininfo"}' http://127.0.0.1:21000/api/rpc | jq '.result.blocks'With accounts on: sign in, keep the cookie, and send the CSRF token on mutating calls:
B=http://127.0.0.1:21000
CSRF=$(curl -s -c jar.txt -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"a long passphrase here"}' $B/api/login | jq -r .csrf)
curl -s -b jar.txt "$B/api/me" | jq .user
curl -s -b jar.txt -X POST -H 'Content-Type: application/json' -H "X-CSRF-Token: $CSRF" \
-d '{"method":"getmempoolinfo"}' "$B/api/rpc" | jq .result.size
curl -sN -b jar.txt "$B/api/stream?node=main"Poll the event feed incrementally:
SEQ=0
while sleep 10; do
R=$(curl -s "http://127.0.0.1:21000/api/events?since=$SEQ&severity=warn,error")
echo "$R" | jq -r '.events[] | "\(.ts) \(.severity) \(.text)"'
SEQ=$(echo "$R" | jq .maxSeq)
done