satd Esplora REST API
satd ships a native Esplora-compatible REST server, enabled by default and
listening on 127.0.0.1:3000. Wire shapes match upstream
blockstream/esplora and
mempool.space byte-for-byte within the
endpoint set listed below.
Like the Electrum server, the Esplora server is a query layer over satd's own
chainstate and shared address-history index. There is no separate indexer
process (electrs, esplora-electrs, Fulcrum) beside the node with its own copy
of the data. One RocksDB store backs the node and every API surface, updated
atomically inside the node's connect_block and disconnect_block path. A
read can never observe an index out of sync with the tip. The combined index is
larger on disk than a standalone electrs or Fulcrum index: the trade is disk
for consistency and single-process operation. See
Disk Footprint & Indices for the accounting.
This chapter covers what is implemented today. The implementation lives in the
esplora-handlers/ workspace crate. Routes are registered in
esplora-handlers/src/router.rs, and shape parity is locked behind the canary
CI requirement in STABILITY_POLICY.md.
Last verified against routes: 2026-05-05.
Note. The Esplora surface defaults to unauthenticated loopback. For Basic auth (
--esploraauth) or capability-scoped bearer tokens (--esploraauthbearer,esplora:read, plusrpc:submitto broadcast), see Authentication & Authorization.
Configuration
| Flag | Default | Notes |
|---|---|---|
--esplora=<bool> | 1 | Disable with --esplora=0. Disabling stops the listener; address-index data is still maintained for RPC consumers. |
--esplorabind=<addr:port> | 127.0.0.1:3000 | Bind address. Read the Auth section before binding to a non-loopback address such as 0.0.0.0:3000. |
--esploraprefix=<path> | / | Mount under a path (for example /api) for blockstream.info-style deployments. Must start with /. |
--esploraauth=<scheme> | none | One of none, cookie, userpass. none runs the listener unauthenticated. cookie reuses the JSON-RPC cookie file. userpass requires --esplorauserpass=user:pass. |
--esplorauserpass=<user:pass> | (none) | Static credentials, used only when --esploraauth=userpass. |
--esploracookiefile=<path> | (auto) | Override the cookie-file path when --esploraauth=cookie. The default is the same .cookie file the JSON-RPC server uses. |
--esploracors=<origin> | (none) | Repeat for multiple origins. Use * for any origin. |
--esplorarequesttimeout=<seconds> | 30 | Per-request timeout, answered with 503; also the TLS handshake budget and how long a keep-alive connection may sit idle between requests before it is closed. |
--esploramaxconns=<n> | 256 | Cap on concurrent in-flight requests. 0 disables the cap. The address and scripthash routes do their reads on a blocking thread; a read still running after its request timed out keeps counting against this cap until it ends. Does not bound long-lived SSE streams; see Live updates. |
--esplorasseconns=<n> | same as --esploramaxconns | Hard cap on simultaneously open SSE streams (/blocks/sse, /address/:addr/sse, /scripthash/:hash/sse). Each open stream holds a permit until the client disconnects; over-cap connections receive 503. 0 disables the cap. |
--esploramaxsockets=<n> | 1024 | Hard cap on open sockets across both Esplora listeners, counted at accept, idle keep-alive connections included. Over the cap a socket is closed at accept. --esploramaxconns bounds requests in flight and never sees a socket that sends nothing; this does. 0 disables. A keep-alive connection idle for longer than --esplorarequesttimeout is closed. |
POST /tx has a fixed 1 MiB body limit at the route layer. A witness-heavy
400 KB raw transaction hex-encodes to about 800 KB, so 1 MiB leaves margin and
stays well under any consensus block limit. There is no flag to change this.
Esplora requires --addressindex=1 (auto-enabled if not set; see the
address-index docs) and --txindex=1 (auto-enabled by the reconciliation in
satd/src/config.rs). Both flags are on by default.
Endpoints
Chain
| Method | URL | Returns |
|---|---|---|
| GET | /blocks/tip/hash | text/plain: current best-chain tip hash (display hex, 64 chars). |
| GET | /blocks/tip/height | text/plain: current tip height. |
| GET | /blocks | JSON array of up to 10 most-recent block summaries, descending. |
| GET | /blocks/:start_height | JSON array of up to 10 summaries ending at start_height inclusive, descending. |
| GET | /block-height/:height | text/plain: block hash at the active-chain height, or 404. |
Block
| Method | URL | Returns |
|---|---|---|
| GET | /block/:hash | JSON: {id, height, version, timestamp, mediantime, tx_count, size, weight, merkle_root, previousblockhash, nonce, bits, difficulty}. |
| GET | /block/:hash/header | text/plain: 80-byte serialized header, hex-encoded. |
| GET | /block/:hash/raw | application/octet-stream: raw block bytes. |
| GET | /block/:hash/status | JSON: {in_best_chain, height?, next_best?}. |
| GET | /block/:hash/txs | JSON: first 25 txs in full Esplora shape ({txid, version, locktime, vin, vout, size, weight, fee, status}). |
| GET | /block/:hash/txs/:start_index | JSON: 25 txs starting at start_index, which must be a multiple of 25 (400 otherwise). At or past the end of the block: 404 start index out of range. |
| GET | /block/:hash/txid/:index | text/plain: txid at the given block-tx index. |
| GET | /block/:hash/txids | JSON: array of every txid in the block. |
Transaction
| Method | URL | Returns |
|---|---|---|
| GET | /tx/:txid | JSON: full tx (vin/vout/status/fee). 404 if unknown. |
| GET | /tx/:txid/status | JSON: {confirmed, block_height?, block_hash?, block_time?}. |
| GET | /tx/:txid/hex | text/plain: hex-encoded serialized tx. |
| GET | /tx/:txid/raw | application/octet-stream: raw tx bytes. |
| POST | /tx | Body: hex-encoded tx. Returns the txid as plain text on accept. Bad hex or a mempool reject returns 400. With bearer auth, the token must hold rpc:submit (or rpc:write) as well as esplora:read; a read-only token gets 403. |
| GET | /tx/:txid/outspend/:vout | JSON: {spent, txid?, vin?, status?}. |
| GET | /tx/:txid/outspends | JSON: array of outspends, one per output, vout-ordered. |
| GET | /tx/:txid/merkle-proof | JSON: {block_height, merkle: [hex...], pos}. |
| GET | /tx/:txid/merkleblock-proof | text/plain: hex-encoded P2P MerkleBlock for the given tx. |
Address & Scripthash
The address-string and scripthash endpoint families share handlers; only the parser differs. A scripthash is the 32-byte sha256 of the scriptPubKey, hex-encoded in natural byte order. It is not reversed; Esplora's scripthash format differs from Electrum's.
| Method | URL | Returns |
|---|---|---|
| GET | /address/:address /scripthash/:hash | JSON: {address, chain_stats, mempool_stats}. Each *_stats block: {tx_count, funded_txo_count, funded_txo_sum, spent_txo_count, spent_txo_sum}. |
| GET | /address/:address/txs /scripthash/:hash/txs | JSON: up to 50 mempool txs followed by first 25 confirmed (newest first). With ?after_txid=<txid>, the history after that tx: the next 25 confirmed when it is confirmed, or the mempool txs after it followed by the first 25 confirmed when it is unconfirmed. 422 after_txid not found when it is in neither. |
| GET | /address/:address/txs/chain /scripthash/:hash/txs/chain | JSON: 25 confirmed txs, newest first. |
| GET | /address/:address/txs/chain/:last_seen_txid /scripthash/:hash/txs/chain/:last_seen_txid | JSON: next 25 confirmed txs strictly older than last_seen_txid. Unknown cursor returns an empty array, not 404. |
| GET | /address/:address/txs/mempool /scripthash/:hash/txs/mempool | JSON: up to 50 mempool txs, in the order this node admitted them. No paging. |
| GET | /address/:address/utxo /scripthash/:hash/utxo | JSON: live UTXOs (confirmed + mempool funding) with {txid, vout, value, status}. |
Wrong-network addresses return 400, as do malformed addresses and bad
scripthash hex (non-hex characters or wrong length). The stats, utxo and
sse routes also return 400 Too many history entries for a script with
more than 200,000 confirmed transactions; see
High-history scripts.
Mempool & Fee
| Method | URL | Returns |
|---|---|---|
| GET | /mempool | JSON: {count, vsize, total_fee, fee_histogram}. fee_histogram is [[feerate_sat_vb, vsize], …] descending by feerate. |
| GET | /mempool/txids | JSON: array of every mempool txid. |
| GET | /mempool/recent | JSON: up to 10 newest mempool txs by admission timestamp; each {txid, fee, vsize, value}. |
| GET | /fee-estimates | JSON: object mapping confirmation target (string) to feerate (sat/vB, float). Standard targets: 1..25, 144, 504, 1008. A target the node has no estimate for is left out, and the object is {} when there is none (see below). Never below the minimum relay feerate. |
Root
| Method | URL | Returns |
|---|---|---|
| GET | / | JSON: {chain_tip: {hash, height}, mempool_count}. Small summary for status pings. |
Live updates (Server-Sent Events)
| Method | URL | Stream |
|---|---|---|
| GET | /blocks/sse | One block event per BlockConnected. Body: {hash, height}. |
| GET | /address/:addr/sse | One status event per status-hash change for the address. Body: {address, status_hash}. |
| GET | /scripthash/:hash/sse | Parallel scripthash variant. The address field carries the scripthash hex. |
Connections receive a :keep-alive heartbeat every 25 seconds, so idle streams
survive intermediate proxy timeouts (Caddy defaults to 30 s, nginx to 60 s).
Per-scripthash subscriptions consume from the registry capped by
--addrindexsubscriptions=N (default 50000). Over-cap subscribe attempts
return 503.
Total open SSE streams across all three endpoints are capped by
--esplorasseconns=N, which defaults to the --esploramaxconns value. Each
stream holds a permit until the client disconnects. This cap is distinct from
the request-handling cap, which does not bound long-lived streaming bodies.
Over-cap connections receive 503 immediately at the SSE entry point.
A subscriber that lags the broadcast channel skips ahead: the broadcast never
panics but can drop intermediate events. On reconnect, refresh state through
the standard endpoints (/address/:addr or /blocks/tip/{hash,height}).
Wire-shape gotchas
- Hex byte order. Block hashes, txids, and merkle siblings are hex-encoded
in display (reversed) byte order, the same as Bitcoin Core's
getblockhashandgetrawtransaction. Scripthash hex is the natural byte order ofsha256(scriptPubKey), not reversed; this differs from Electrum's wire format. - Pagination cursors.
/address/:addr/txs/chain/:last_seen_txidstarts the next page strictly after the cursor in the descending list. An unknown cursor returns an empty array, so clients with stale state get[]rather than 404, as blockstream.info does. - Combined
/txsand?after_txid=. Returns up to 50 mempool transactions followed by the first 25 confirmed, in that order. Mempool entries are in the order this node admitted them (oldest first, then by txid), as upstream lists them; the confirmed ones are newest first.?after_txid=<txid>is mempool.space's paging cursor ("load more"): it continues after a transaction from an earlier page. A confirmed cursor gives the next 25 confirmed transactions, the same page as/txs/chain/:last_seen_txid; a mempool cursor gives the mempool transactions after it and then the first 25 confirmed. A cursor in neither list is 422after_txid not found, a malformed one 400, and an empty one is the first page. mempool.space also takesmax_txs; satd does not. - Block pages.
/block/:hash/txs/:start_indexanswers 404start index out of rangeat or past the end of the block, and 400start index must be a multiple of 25for an unaligned start inside it, as mempool.space does. blockstream.info answers 400 for both. witnessonvin. Present only when the input has a witness. A legacy (non-segwit) input carries nowitnesskey, as upstream./fee-estimateswithout a fee signal. A target is left out when the node has no estimate for it: no mempool signal at that depth and no confirmed-block fee history, as during IBD or right after startup. With no estimate for any target the answer is{}. Upstream Esplora leaves out the targets bitcoind cannot estimate in the same way; clients such as BDK pick the nearest listed target at or below the one they want.feefield on tx JSON.nullwhen at least one prevout cannot be resolved (for example, txindex disabled or the previous tx pruned).Some(0)for coinbase. Otherwisesum_inputs - sum_outputs.- Mempool UTXOs in
/utxo. Outputs created by mempool transactions appear withstatus.confirmed: falseand no block fields. Outputs spent in the mempool are excluded. - Confirmation status on outspends. Confirmed spends carry a full
statuswithblock_height,block_hash, andblock_time. Mempool spends carrystatus: { confirmed: false }.
OP_RETURN-pruned blocks
On a node running with pruneopreturn
(OP_RETURN data pruning):
- A stripped transaction (in
/tx/:txid,/block/:hash/txsand the address histories) carries"opreturn_stripped": true. Each stripped output reads as the bareOP_RETURNit keeps:scriptpubkey"6a",scriptpubkey_asm"OP_RETURN",scriptpubkey_type"op_return", plus"scriptpubkey_stripped_size", the original length. Thetxid, sizes, values and fee are the original's. - Raw bytes are refused with 404:
/block/:hash/rawwithblock data not available (OP_RETURN data stripped), and/tx/:txid/hexand/tx/:txid/rawwithtransaction data not available (OP_RETURN data stripped). - Block summaries, txids, merkle proofs (both kinds), outspends and statuses are unchanged.
Chain-tip headers
Every route that reads the chain answers with two extra headers:
| Header | Value |
|---|---|
X-Bitcoin-Chain-Tip | The best-chain tip's block hash (display hex) the answer was read at. |
X-Bitcoin-Chain-Tip-Height | That tip's height. |
They let a client that makes several requests check that the answers describe one chain: if the hash differs between two responses, a block connected or a reorg happened in between, and the client should fetch the batch again. Compare the hash, not the height; a block replaced at the same height changes only the hash. The headers follow the proposal in mempool/mempool#6584, and rbitcoin's Esplora serves the same two.
Each request answers for the tip it started at, and the headers name that tip. Nothing above it counts as confirmed, however many blocks connect while the request runs:
- A transaction that confirms in a later block is served as unconfirmed:
status.confirmed: falsefrom/tx/:txidand/tx/:txid/status, in the unconfirmed part of/address/:address/txs, inmempool_stats, and as a mempool spend on the outspend routes. Its outputs are unconfirmed UTXOs. It has no merkle proof yet (404). - Confirmed history, chain stats and confirmed UTXOs stop at the tip's
height.
/blocks/tip/hash,/blocks/tip/height,/blocks,/block-height/:heightand/report the tip, and/block/:hash/statusreports a later block as not in the best chain. /block/:hash/...routes still serve any block the node holds by hash, as before.
A disconnect while a request runs (a reorg, or invalidateblock) can change
the blocks the answer covers, so the request is run again at the new tip. One
that a disconnect interrupts eight times in a row answers 503
chain view moved with no tip headers.
The mempool part of an answer (unconfirmed transactions, mempool stats and UTXOs, mempool spends) is read as it is when the request reads it.
As of a block: ?asof=<blockhash>
Some routes answer as of an earlier block on the best chain, given its hash
(display hex, as /block/:hash takes it):
curl "http://127.0.0.1:3000/address/bc1q.../utxo?asof=00000000000000000002a7c4..."
| Route | As of the block |
|---|---|
/address/:address /scripthash/:hash | chain_stats over blocks up to and including it; mempool_stats all zero. |
.../txs | The confirmed transactions only, newest first; ?after_txid= pages them. |
.../txs/chain[/:last_seen_txid] | The confirmed transactions up to it. |
.../utxo | Outputs funded up to it and not spent up to it, all confirmed. |
/tx/:txid/status | confirmed: true if it confirmed up to it, confirmed: false if later; 404 if only in the mempool. |
/tx/:txid/outspend/:vout /tx/:txid/outspends | Spent only by a spend confirmed up to it; a later or mempool spend leaves the output unspent. 404 for a transaction only in the mempool. |
The mempool is never part of the answer. The chain-tip headers name the asof block and its height.
A hash that is not a block on the best chain is 404, and so is one that leaves
the best chain while the request runs: satd never answers from another block
at that height. A malformed or empty asof, or asof on any route not in
the table, is 404 too. /utxo?asof= is worked out from the address's
history, not the live coin set, so it is refused above the same history cap
as the stats. rbitcoin's Esplora takes the same parameter with the same
rules.
Every route carries the headers except POST /tx, /mempool,
/mempool/txids, /mempool/recent, /fee-estimates, the
/txs/mempool lists and the SSE streams, which do not read the chain.
Error answers from a chain route (a 404 for an unknown transaction, say)
carry them too. With CORS enabled both are listed in
Access-Control-Expose-Headers, so browser clients can read them.
Auth
Warning. The default auth mode is
none. Loopback-only deployments (--esplorabind=127.0.0.1:3000) are usually fine. Set an auth mode explicitly before binding to a non-loopback address such as0.0.0.0:3000.POST /txis a broadcast endpoint, and an unauthenticated public listener accepts any transaction submission.
Three auth modes are available via --esploraauth=<mode>:
-
none(default): no authentication. The listener accepts every request. -
cookie: reuses the same.cookiefile the JSON-RPC server creates. Clients pass it via HTTP Basic Auth as__cookie__:<token>, the form Bitcoin Core-compatible tooling generates. Use--esploracookiefile=<path>to override the cookie path.satd --esplora=1 --esploraauth=cookie -
userpass: static credentials supplied via--esplorauserpass=<user>:<pass>. The comparison is constant-time, and the HTTP scheme is case-insensitive.satd --esplora=1 --esploraauth=userpass --esplorauserpass=admin:hunter2
In cookie and userpass modes, satd refuses to start if the auth source
cannot be established (unreadable cookie file, or --esplorauserpass missing).
CORS
--esploracors=<origin> enables CORS for the listed origins; * allows any
origin. Allowed methods: GET and POST. Allowed headers: Content-Type and
Authorization. Exposed headers: the two
chain-tip headers. CORS does not bypass authentication; it only admits
cross-origin browser requests.
Bench harness
scripts/run-esplora-bench.sh starts a regtest node, mines warmup blocks, then
drives ESPLORA_BENCH_REQS requests (default 200) against each implemented
endpoint. It reports p50, p90, and p99 latency per endpoint. See the script
header for the environment variables. It is not a CI gate; use it as a local
regression check.
Compatibility statement
The implemented endpoints aim for byte-for-byte parity with upstream blockstream.info and mempool.space within these constraints:
- Standard scripts only.
scriptpubkey_typestrings coverp2pk,p2pkh,p2sh,v0_p2wpkh,v0_p2wsh,v1_p2tr,op_return,multisig, andunknown, matching upstream. Non-standard scripts serialize withscriptpubkey_address: null. - Mempool ordering.
/address/:addr/txs/mempoolreturns entries in the order this node admitted them (oldest first, then by txid), the order upstream Esplora keeps them in. Upstream's contract is "up to 50". - Fee histogram bucketing uses fixed boundaries spanning realistic mainnet fee regimes: 1, 2, 3, 5, 8, 10, 15, 20, 30, 50, 75, 100, 150, 200, 300, 500, 1000 sat/vB.
- History order is unchanged by the ordinal-keyed chainstate. The
address and spend indexes store rows keyed on a dense transaction
ordinal rather than a height and txid (see
Disk Footprint), but the store resolves and orders
them before they leave it.
/address/:addr,/address/:addr/txs/chain,/address/:addr/utxo,/tx/:txid/outspend/:voutand/tx/:txid/outspendsreturn exactly the objects they did before,txidandstatusfields included. Nothing on the Esplora wire carries an ordinal. - WebSocket subscriptions are not implemented; SSE is satd's
live-updates transport. BDK's Esplora client polls over HTTP and needs
neither. Clients built on the mempool.space WebSocket (
/api/v1/ws), such as the mempool.space JS client's live updates, need adapting to SSE: it is not a drop-in replacement for them. - High-history scripts.
/address/:addr/txs/chain[/:cursor]and the confirmed part of/address/:addr/txs(with or without?after_txid=) read the history from the cursor down and stop once the page is complete, so a page costs about the same however long the history is./address/:addr(the stats) and/address/:addr/utxoread the script's whole history, so they are capped: a script whose confirmed history holds more than 200,000 transactions gets 400Too many history entries (more than 200000 transactions), as mempool.space's electrs answers above its history limit. Opening/address/:addr/sseor/scripthash/:hash/ssefor such a script gets the same 400: its status is not recomputed above the cap, so the stream would carry no events, and a stream already open for a script that grows past the cap stops receiving them. The refusal counts the history's transactions and stops at the first one past the cap, so it costs a bounded read however long the history is; below the cap the answers are unchanged. Set the cap with-addrindexmaxhistory=<n>(shared with Electrum;0for none, which serves every script again at the cost of reading its whole history), or page such a script's history through/txs/chain. Under the cap, a high-activity script (exchange hot wallet, mining pool, popular donation address) can still cost multi-MB allocations and sub-second latency per request. Every address and scripthash route runs its reads on a blocking thread rather than on the API runtime's workers, so a slow read does not hold up other requests and--esplorarequesttimeoutanswers 503 on time; reads still running after a timeout count against--esploramaxconnsuntil they end. For a public deployment that serves such scripts, put the listener behind a per-IP rate limiter at the reverse proxy. - Address prefix search (
/address-prefix/:prefix) is not implemented; it would require a separate prefix index.