fullnode HTTP API
Two surfaces. /api/v1/ is the request/response REST API and
has been stable since 2023. /api/v3/ is the streaming
protocol, which is on its third revision because the framing changed
twice.
Base URL
Self-hosted installs serve the same paths under whatever prefix you mount
fullnode.urls at. Every example below uses the public
gateway; substitute your own host and the responses are identical.
Versioning
The two surfaces are versioned independently, which surprises people often enough that it is worth stating plainly:
| Prefix | Surface | Status | Notes |
|---|---|---|---|
| /api/v1/ | REST | Stable | Additive changes only. No breaking change since 1.0. |
| /api/v2/ | Stream | Removed 3.0 | Length-prefixed JSON. Gone; do not build against it. |
| /api/v3/ | Stream | Current | Binary framing, per-node cursors, resumable. |
The project version (3.2.1) tracks the stream protocol, not
the REST API. A gateway on 3.x still serves /api/v1/.
Authentication
Reads on /api/v1/ are open and need no credential —
the data is public chain state and rate limiting is by address. Two things
do need a key:
POST /api/v1/<chain>/rpc, because it consumes node time.- Everything under
/api/v3/, because a stream holds a connection open.
Keys are issued per project in the dashboard of a self-hosted install
(manage.py issuekey on the command line). On REST they go in a
header; on streams they are part of the path — see
endpoint shape for why.
curl -H "X-Api-Key: 7f2b91c4…" \ -d '{"method":"getblockcount","params":[]}' \ https://full-node.online/api/v1/bitcoin/rpc
Conventions
- Amounts are strings of integer base units, with a sibling
decimals. Never a float — 8 decimals of BTC and 18 of BNB do not both survive a double. - Times are RFC 3339 with an explicit UTC offset — either
Zor+00:00, depending on the emitter. Parse the offset; do not assume it. - Chain ids are lowercase slugs:
bitcoin,solana,ton,tron,bsc. - Lists are cursor-paginated with
?cursor=and?limit=(max 200). The cursor is opaque; do not parse it. - Heights are integers. Solana slots and TON seqnos are exposed
as
heighttoo, so one client works everywhere.
Errors
Errors are Django REST Framework's own shape — a detail
string, plus a code the gateway adds.
{ "detail": "Not found." }
| Status | code | Means |
|---|---|---|
| 400 | invalid_chain | Unknown chain slug. |
| 401 | not_authenticated | Key missing on an endpoint that needs one. |
| 404 | not_found | No such block, transaction or route. |
| 429 | throttled | Rate limit; Retry-After is set. |
| 502 | upstream_unavailable | The adapter could not reach its node. |
| 503 | not_synced | Node is behind; lag gives the gap in blocks. |
Rate limits
60 requests/minute per IP unauthenticated, 1,200/minute with a key, and
four concurrent streams per key. Every response carries
X-RateLimit-Remaining.
Chains
Every chain this gateway is configured for, with its adapter state.
{
"count": 5,
"results": [
{
"chain": "bitcoin",
"name": "Bitcoin",
"network": "mainnet",
"decimals": 8,
"symbol": "BTC",
"block_time": 600,
"features": ["blocks", "mempool", "utxo", "streams"]
}
]
}
Nodes
The gateway's own fleet. The id here is what goes into a
stream URL, and what status reports as node.
{
"count": 4,
"results": [
{ "id": "s1", "region": "eu-central", "city": "Frankfurt",
"chains": ["solana", "ton"], "streams": false },
{ "id": "s2", "region": "eu-west", "city": "Strasbourg",
"chains": ["bitcoin", "bsc", "tron"], "streams": true },
{ "id": "s3", "region": "eu-north", "city": "Stockholm",
"chains": ["solana"], "streams": false },
{ "id": "s4", "region": "eu-west", "city": "Gravelines",
"chains": ["bitcoin", "bsc", "ton", "tron"], "streams": true }
]
}
Chain status
Sync state of the node currently serving that chain. Returns
503 not_synced when height trails
header_height by more than the chain's tolerance.
{
"chain": "solana",
"network": "mainnet-beta",
"synced": true,
"height": 428914300,
"header_height": 428914300,
"epoch": 993,
"peers": 2841,
"client": "Agave 2.3.4",
"node": "s1"
}
Blocks
confirmations is depth below the tip and is the field to
branch on — it goes to 0 on a block that has been reverted,
rather than the block disappearing.
{
"chain": "bitcoin",
"height": 912480,
"hash": "00000000000000000001b4f8c0a37e2d9155c8e3f60b7a248d91e0c4f7a2b3d6",
"parent": "000000000000000000021c7ea5f39b0d84c6e17b2f9a05d3c8e14b60a97f2e85",
"time": "2026-08-12T09:41:22Z",
"confirmations": 1,
"tx_count": 3184,
"size": 1483920,
"raw": { "…" }
}
Transactions
GET normalises into transfers[] as shown on the
overview. POST broadcasts a signed
raw transaction — the gateway never signs and never holds a key, so the
body is the already-signed hex or base64 blob the chain expects.
Addresses
Balance returns the native asset plus every token the address holds, in
the same {amount, decimals, symbol} shape.
utxos is Bitcoin-only and 404s elsewhere —
check features on /chains first.
Mempool
Fee estimates come back as targets in blocks, with the unit the chain actually charges in — sat/vB for Bitcoin, gwei for BSC, energy and bandwidth for TRON.
Raw RPC passthrough
The escape hatch. The body is forwarded to the node as-is and its reply is
returned untouched — no normalisation, no error mapping beyond transport
failures. Requires a key, and a per-chain method allowlist applies
(FULLNODE_RPC_ALLOW in settings). State-changing and
wallet methods are rejected by default.
Stream protocol · /api/v3
Polling /blocks/latest in a loop is how most people start and
it is wrong on every chain with sub-second blocks. The stream protocol
pushes instead: you open one connection, it stays open, and events arrive
as they happen.
Streams are node-affine. The resume cursor lives on the node that issued it, so the node id is chosen when the connection is opened and does not move for the life of that stream. Reconnect to the same node id and the cursor resumes; reconnect elsewhere and you get a fresh one.
Endpoint shape
WebSocket
constructor cannot set request headers — there is no argument for it and
there never has been. Every browser-side stream API therefore puts its
credential in the URL, and doing it only for browsers would mean two
code paths for one protocol. So the node id and key are path segments on
both transports, and the whole exchange is inside TLS.
<node> is an id from /api/v1/nodes
with "streams": true. <key> is your project
key. A request with a good shape and a bad key is
401 not_authenticated; a request to a node that does not carry
streams is 404.
const ws = new WebSocket( "wss://full-node.online/api/v3/ws-s2-7f2b91c4" ); ws.onopen = () => ws.send(JSON.stringify({ op: "subscribe", chain: "bsc", topics: ["block", "mempool"], cursor: localStorage.getItem("fn.cursor") })); ws.onmessage = (e) => { const ev = JSON.parse(e.data); if (ev.type === "block") console.log(ev.height); localStorage.setItem("fn.cursor", ev.cursor); };
Events
| type | Fired when | Carries |
|---|---|---|
| ready | Subscription accepted | node, chains, cursor |
| block | New block at the tip | The block object |
| block.reverted | A block left the main chain | height, hash — always before its replacement |
| tx | A watched address moved | The transaction object |
| mempool | Entry admitted or dropped | hash, fee, action |
| heartbeat | Every 20s of silence | server time, so idle proxies do not reap the socket |
HTTP fallback
Some corporate proxies strip the Upgrade header and a
WebSocket simply never completes. stream-<node>-<key>
is the same protocol over plain HTTP with chunked transfer encoding:
identical events, one JSON document per chunk, and the subscription is
sent as the request body instead of the first frame. It is slower to
start and cannot be used from a browser, but it goes through anything that
passes ordinary HTTPS.
curl -N https://full-node.online/api/v3/stream-s4-7f2b91c4 \ -d '{"op":"subscribe","chain":"bitcoin","topics":["block"]}'
Header archive · /archive
Streaming gives you the tip. The archive gives you the recent chain
behind it: a rolling window of Bitcoin headers in fixed segments of 400,
as flat binary. It is what an SPV client or a fresh indexer should
bootstrap from — pulling fifty thousand headers one REST call at a
time is unkind to everybody, and it is the single most common way people
get rate limited here. Read index.json for the range the
window currently covers; older segments are pruned as it advances.
No key is needed and no rate limit applies. Segments are addressed by the
block range they contain and can never change, so they are served
immutable with a one-year lifetime and cache anywhere —
your CDN, your proxy, your disk. Fetch a segment once and never ask for
it again.
Manifest
Read this first; it tells you how far the archive goes and how to build a segment URL. It is the only part that changes — one new segment roughly every 66 hours, which is what 400 blocks of Bitcoin comes to — so it is cached for five minutes and everything under it is cached for a year.
{
"chain": "bitcoin",
"format": "fnha/1",
"record_size": 96,
"records_per_segment": 400,
"segment_bytes": 38432,
"first_height": 861200,
"last_height": 912399,
"segments": 128,
"path": "/archive/bitcoin/headers-{start:09d}-{end:09d}.bin"
}
Segment format
A 32-byte little-endian file header identifying the range, then the packed record block. Read the header to know what you have; decode the body with the client library rather than by hand — the record encoding is an implementation detail and has changed once already.
| Offset | Size | field | Notes |
|---|---|---|---|
| 0 | 4 | magic | FNHA |
| 4 | 2 | version | Format revision, currently 1. |
| 6 | 2 | chain | 1 = Bitcoin mainnet. |
| 8 | 8 | first_height | Height of record 0. |
| 16 | 4 | count | Records that follow. |
| 20 | 2 | record_size | Bytes per record. |
| 32 | rest | records | Packed record block. |
curl -sO https://full-node.online/archive/bitcoin/headers-000912000-000912399.bin