Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, plus rpc:submit to broadcast), see Authentication & Authorization.

Configuration

FlagDefaultNotes
--esplora=<bool>1Disable with --esplora=0. Disabling stops the listener; address-index data is still maintained for RPC consumers.
--esplorabind=<addr:port>127.0.0.1:3000Bind 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>noneOne 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>30Per-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>256Cap 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 --esploramaxconnsHard 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>1024Hard 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

MethodURLReturns
GET/blocks/tip/hashtext/plain: current best-chain tip hash (display hex, 64 chars).
GET/blocks/tip/heighttext/plain: current tip height.
GET/blocksJSON array of up to 10 most-recent block summaries, descending.
GET/blocks/:start_heightJSON array of up to 10 summaries ending at start_height inclusive, descending.
GET/block-height/:heighttext/plain: block hash at the active-chain height, or 404.

Block

MethodURLReturns
GET/block/:hashJSON: {id, height, version, timestamp, mediantime, tx_count, size, weight, merkle_root, previousblockhash, nonce, bits, difficulty}.
GET/block/:hash/headertext/plain: 80-byte serialized header, hex-encoded.
GET/block/:hash/rawapplication/octet-stream: raw block bytes.
GET/block/:hash/statusJSON: {in_best_chain, height?, next_best?}.
GET/block/:hash/txsJSON: first 25 txs in full Esplora shape ({txid, version, locktime, vin, vout, size, weight, fee, status}).
GET/block/:hash/txs/:start_indexJSON: 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/:indextext/plain: txid at the given block-tx index.
GET/block/:hash/txidsJSON: array of every txid in the block.

Transaction

MethodURLReturns
GET/tx/:txidJSON: full tx (vin/vout/status/fee). 404 if unknown.
GET/tx/:txid/statusJSON: {confirmed, block_height?, block_hash?, block_time?}.
GET/tx/:txid/hextext/plain: hex-encoded serialized tx.
GET/tx/:txid/rawapplication/octet-stream: raw tx bytes.
POST/txBody: 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/:voutJSON: {spent, txid?, vin?, status?}.
GET/tx/:txid/outspendsJSON: array of outspends, one per output, vout-ordered.
GET/tx/:txid/merkle-proofJSON: {block_height, merkle: [hex...], pos}.
GET/tx/:txid/merkleblock-prooftext/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.

MethodURLReturns
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

MethodURLReturns
GET/mempoolJSON: {count, vsize, total_fee, fee_histogram}. fee_histogram is [[feerate_sat_vb, vsize], …] descending by feerate.
GET/mempool/txidsJSON: array of every mempool txid.
GET/mempool/recentJSON: up to 10 newest mempool txs by admission timestamp; each {txid, fee, vsize, value}.
GET/fee-estimatesJSON: 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

MethodURLReturns
GET/JSON: {chain_tip: {hash, height}, mempool_count}. Small summary for status pings.

Live updates (Server-Sent Events)

MethodURLStream
GET/blocks/sseOne block event per BlockConnected. Body: {hash, height}.
GET/address/:addr/sseOne status event per status-hash change for the address. Body: {address, status_hash}.
GET/scripthash/:hash/sseParallel 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 getblockhash and getrawtransaction. Scripthash hex is the natural byte order of sha256(scriptPubKey), not reversed; this differs from Electrum's wire format.
  • Pagination cursors. /address/:addr/txs/chain/:last_seen_txid starts 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 /txs and ?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 422 after_txid not found, a malformed one 400, and an empty one is the first page. mempool.space also takes max_txs; satd does not.
  • Block pages. /block/:hash/txs/:start_index answers 404 start index out of range at or past the end of the block, and 400 start index must be a multiple of 25 for an unaligned start inside it, as mempool.space does. blockstream.info answers 400 for both.
  • witness on vin. Present only when the input has a witness. A legacy (non-segwit) input carries no witness key, as upstream.
  • /fee-estimates without 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.
  • fee field on tx JSON. null when at least one prevout cannot be resolved (for example, txindex disabled or the previous tx pruned). Some(0) for coinbase. Otherwise sum_inputs - sum_outputs.
  • Mempool UTXOs in /utxo. Outputs created by mempool transactions appear with status.confirmed: false and no block fields. Outputs spent in the mempool are excluded.
  • Confirmation status on outspends. Confirmed spends carry a full status with block_height, block_hash, and block_time. Mempool spends carry status: { confirmed: false }.

OP_RETURN-pruned blocks

On a node running with pruneopreturn (OP_RETURN data pruning):

  • A stripped transaction (in /tx/:txid, /block/:hash/txs and the address histories) carries "opreturn_stripped": true. Each stripped output reads as the bare OP_RETURN it keeps: scriptpubkey "6a", scriptpubkey_asm "OP_RETURN", scriptpubkey_type "op_return", plus "scriptpubkey_stripped_size", the original length. The txid, sizes, values and fee are the original's.
  • Raw bytes are refused with 404: /block/:hash/raw with block data not available (OP_RETURN data stripped), and /tx/:txid/hex and /tx/:txid/raw with transaction 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:

HeaderValue
X-Bitcoin-Chain-TipThe best-chain tip's block hash (display hex) the answer was read at.
X-Bitcoin-Chain-Tip-HeightThat 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: false from /tx/:txid and /tx/:txid/status, in the unconfirmed part of /address/:address/txs, in mempool_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/:height and / report the tip, and /block/:hash/status reports 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..."
RouteAs of the block
/address/:address
/scripthash/:hash
chain_stats over blocks up to and including it; mempool_stats all zero.
.../txsThe confirmed transactions only, newest first; ?after_txid= pages them.
.../txs/chain[/:last_seen_txid]The confirmed transactions up to it.
.../utxoOutputs funded up to it and not spent up to it, all confirmed.
/tx/:txid/statusconfirmed: 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 as 0.0.0.0:3000. POST /tx is a broadcast endpoint, and an unauthenticated public listener accepts any transaction submission.

Three auth modes are available via --esploraauth=<mode>:

  1. none (default): no authentication. The listener accepts every request.

  2. cookie: reuses the same .cookie file 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
    
  3. 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_type strings cover p2pk, p2pkh, p2sh, v0_p2wpkh, v0_p2wsh, v1_p2tr, op_return, multisig, and unknown, matching upstream. Non-standard scripts serialize with scriptpubkey_address: null.
  • Mempool ordering. /address/:addr/txs/mempool returns 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/:vout and /tx/:txid/outspends return exactly the objects they did before, txid and status fields 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/utxo read the script's whole history, so they are capped: a script whose confirmed history holds more than 200,000 transactions gets 400 Too many history entries (more than 200000 transactions), as mempool.space's electrs answers above its history limit. Opening /address/:addr/sse or /scripthash/:hash/sse for 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; 0 for 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 --esplorarequesttimeout answers 503 on time; reads still running after a timeout count against --esploramaxconns until 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.