REST API Documentation

Base URL: /api/v1

The API is anonymous by default — no authentication is required for read endpoints. Optionally send an API key to raise your rate limit. CORS is open. All JSON responses are UTF-8.

curl -s https://bananablocks.com/api/v1/block/tip
  • Limits: 240 requests/min per IP; HTTP 429 when exceeded. Details
  • Errors: JSON {"error": "..."} with a matching HTTP status.
  • Unconfirmed data: native address balances, UTXOs and history are confirmed-only; mempool-aware address reads live on the WhatsonChain-compatible API. Transaction lookups can return unconfirmed transactions, so check confirmed.

Each endpoint has a Try button that opens a live console in place; longer contracts are folded into expandable notes under the endpoint.

Try it

Send a live request to this API from your browser. Anonymous requests work within the per-IP limits; paste an API key to use your tier's higher limit. The key is used only by your browser for these requests and is never sent anywhere else.

Rate Limits

Per-IP request limits are enforced per surface. Static assets, /health, /favicon.ico, /robots.txt, and /sitemap.xml are exempt.

Every limit on this page is for the whole deployment. When it runs on several servers, each server counts an even share of each limit, so together they admit about the limit shown; because your requests may not split evenly between them, you can be refused before reaching it (by up to a third while a server is out for maintenance).

  • API (/api/v1/*, incl. the WhatsonChain-compatible mirror): 240 requests per minute per IP.
  • Interactive site (HTML pages and HTMX partials): 900 requests per minute per IP. A single page view fans out into several requests, so this surface has a higher limit than the API.
  • Transaction broadcast (POST /tx/broadcast, POST /tx/broadcast/multi, and the WhatsonChain-compatible POST /tx/raw): a separate, stricter cap of 10 transactions per minute per IP, layered on top of the API limit because these routes proxy to external ARC infrastructure. It counts transactions, not requests: a POST /tx/broadcast/multi batch of n transactions spends n, and that route also limits how big a batch can be (see its entry below). Only these broadcast POSTs are charged; status reads do not count against it.
  • Transaction status (GET /tx/<txid>/status, and the WhatsonChain-compatible GET /tx/<hash>/status and POST /txs/status): a cap of 120 lookups per minute per IP, layered on top of the API limit because each lookup asks external ARC. A single-transaction request is one lookup; POST /txs/status is one lookup per distinct valid txid in the body, and a batch that does not fit in what is left of the minute is refused whole. This cap is separate from the broadcast cap, so polling a status never uses up broadcast allowance. Refusals are HTTP 429 with body {"error": "tx status rate limit exceeded"}.

Successful responses include:

  • X-RateLimit-Limit: requests allowed per window by the server that answered (its share of the limit).
  • X-RateLimit-Remaining: requests left in the current window on that server.

When you exceed the limit you get HTTP 429 Too Many Requests with body {"error": "rate limit exceeded"}. Wait at least one minute and retry.

Requests carrying a valid API key are metered at their key's tier instead of the per-IP bucket. The allowance belongs to the account: every key of an account draws on one shared bucket at the account's tier, so adding keys does not raise it. A key issued without an account has a bucket of its own.

Authentication

Authentication is optional. Anonymous requests work within the per-IP limits above; supplying an API key meters your requests against your key's tier and raises your ceiling. Send the key one of two ways:

  • Authorization: Bearer <key>
  • X-API-Key: <key>

Keys look like bb_live_… (mainnet) or bb_test_… (testnet) and are issued by the operator. An invalid or revoked key returns HTTP 401; omit the header to fall back to anonymous access. Keyed responses include an X-API-Tier header. Tier limits (for this deployment):

TierRequests per minuteBroadcast (tx/min)
Anonymous (per IP)24010 (3 per server)
free60010 (3 per server)
pro300060 (20 per server)
enterprise12000120 (40 per server)

The broadcast column is each row's transaction broadcast cap (see Rate Limits). The number per server is the share each server meters, and it is also the largest POST /tx/broadcast/multi batch that tier can send (at most 100). Only pro or enterprise keys get a broadcast cap above the anonymous one. A free key does not raise the broadcast cap: it is metered in the same per-IP bucket as anonymous callers.

GET /key/usage

Returns the calling key's tier, per-minute limit, and today's request total. The limit is shared by every key of the account; the total counts this key's requests only. Requires a key (401 otherwise). Response: {"key_prefix", "tier", "limit_per_min", "used_today", "reset_at"}.

POST /key/upgrade

Buys a higher tier for the calling key's account over x402, so every key of the account gets it, including keys created later; a key issued without an account gets it itself. Requires a key (401 otherwise).

  1. Send it without an X402-Proof header. It answers 402 Payment Required, carrying the challenge both in an X402-Challenge header and in the JSON body.
  2. Pay the challenge once.
  3. Resend with X402-Proof: <proof> to get 200 and {"tier", "tier_expires_at", "txid", "amount_sats"}.

The target tier comes from ?tier= or a JSON body {"tier": "..."}, defaulting to the next purchasable tier above the key's current one. An open challenge is re-served rather than replaced. This route is exempt from the rate limiter, so a caller who has just exhausted their bucket can still upgrade. Other endpoints answer a rate-limited request with a 402 challenge only when the caller sends X-Payment-Accept: x402; otherwise they answer a plain 429.

Answers to a proof submit

Rules that hold for every answer:

  • Do not pay the challenge again, including when a 402 re-serves the same challenge id: only one payment settles a challenge, and a second one is not credited.
  • Resubmit the same X402-Proof; never make another payment to retry.
  • Check the transaction's status before you reuse its inputs, even if it was broadcast elsewhere.
  • The 200 body confirms the tier grant; a 202 grants nothing.
  • Refunds and manual credits are handled by the operator.

Stop automatic retries on a 409 and check the error below.

AnswerMeaningWhat to do
202 Accepted with Retry-After and {"status": "pending", "error": "..."}The payment tx was broadcast but the network has not accepted it yet. The challenge stays open.Wait Retry-After seconds and resubmit the same proof until it answers 200, 409, 410 or 422. A payment settles the challenge only up to 24 hours after its expires_at.
422A 422 with {"error": "transaction rejected by broadcast"} means the rebroadcast of the payment tx failed: ARC rejected it or could not be reached. Nothing is recorded.A 422 is not always final, and before expires_at resubmitting the same proof is safe.
410 {"error": "challenge expired; request a new one"}A resubmit later than 24 hours after expires_at, once the network has accepted the payment, is refused: the payment is recorded for a refund or a manual credit. A 410 without a verified accepted payment records nothing.Request a new challenge. After that refusal is recorded, an accepted payment resubmitted with the same proof answers the payment request closed 409 below.
409 {"error": "challenge already consumed"} or {"error": "payment txid already used"}The challenge was consumed or the payment transaction was already used. If this is a resubmit of your own proof, it may already have settled, even if you never received its 200.These two responses are terminal for this proof: do not retry it. Check /key/usage before paying again; neither 409 proves that this proof granted the tier.
409 {"error": "key is already on a tier at or above this challenge"}The key or its account has a live tier with a higher per-minute request cap, or a different tier name with the same cap as the challenge's tier. A renewal of the same tier name is allowed by this check. This response grants no tier and does not by itself mean the challenge is permanently closed. If the server verifies that the network has accepted the payment in this proof, an uncredited payment is recorded for a refund or a manual credit unless the challenge was consumed by a concurrent settlement. A payment already credited or a challenge consumed by a concurrent settlement creates no new refund record. Without verified network acceptance, nothing is recorded for this proof.Check /key/usage and ask the operator to check payment and refusal records before expecting another credit or refund. Check the transaction's status before you reuse its inputs, even if it was broadcast elsewhere. If it later reaches an accepted status and the operator finds no payment or refusal record for it, resubmit the same X402-Proof to reconcile the outcome; do not make another payment. That later submit can record a refusal, or settle if the blocking tier has lapsed and the challenge is still eligible to settle.
409 {"error": "payment request closed: a payment to it was refused and is recorded for a refund or a manual credit; request a new one"}An earlier payment to this challenge was refused, which closed it; that earlier payment is what is recorded for a refund or a manual credit. The transaction in this proof is recorded too only if the network had already accepted it; otherwise nothing is recorded for it. That does not mean it was never broadcast: if an earlier submit of this proof answered 202, this server broadcast it then, and it can still be mined.Resubmitting the same proof can never settle it: do not retry it. Request a new challenge if you still want the upgrade. Check the transaction's status before you reuse its inputs, and contact the operator if it is mined.
409 {"error": "payment already refused and recorded for a refund or a manual credit"}This payment transaction was refused before and is recorded for a refund or a manual credit by the operator.Resubmitting the same proof can never settle it: do not retry it. Request a new challenge if you still want the upgrade.

Pagination

Cursor endpoints page through ordered series (blocks, address history, UTXOs); offset endpoints page through indexed snapshots (rich list, tokens, holders) with a zero-indexed page. Where a resource offers both, prefer the cursor for deep pages.

EndpointStyleParametersDefault / Max
/blocksCursorfrom, limit20 / 100
/address/<addr>/txsCursor (ascending)cursor or from_height, limit20 / 1000
/api/v1/bsv/main/address/<addr>/history and /confirmed/historyCursor (descending)token or cursor (same value), or from_height, limit100 / 1000
/api/v1/bsv/main/address/<addr>/unspent/all and /confirmed/unspent (and the /script/<scriptHash>/ forms)Cursor (height descending)token, limit100 / 1000 (1000 / 1000 on /script/<scriptHash>/confirmed/unspent); larger limits are clamped to 1000
/address/<addr>/utxosOffset (legacy) or Cursorpage, limit, sort, or cursor20 / 1000 with cursor; 20 / 100 with page, where page above 1000 / limit is a 400 (use cursor)
/stats/richlistOffsetpage, limit, sort, dir20 / 100
/tokensOffsetpage, limit, sort, dir20 / 100
/token/<id>/holdersOffsetpage, limit20 / 100
/token/<id>/historyOffsetpage, limit20 / 100
  • Continue with the token you were given. Native /address/<addr>/txs returns has_more and, when more rows exist, next_cursor; pass it back as ?cursor=. The WoC history envelopes carry nextPageToken; pass it back as ?token= (or ?cursor=, which takes the same value on history routes). The WoC unspent routes also return nextPageToken; continue them with ?token= only. The bare-array WoC /history has no place for a token, so every WoC history page also sends X-Next-Cursor and X-Has-More headers, exposed to cross-origin browser clients. Stop when has_more (or X-Has-More) is false or the envelope has no nextPageToken.
  • A 409 means restart. When history changes under the walk (a reorg, a removed anchor, or a backend switch), a continuation returns 409 with restart guidance. Restart without a cursor and deduplicate what you already collected. Pages reflect current canonical history, not a snapshot spanning requests.
  • Check the page size you got. /blocks, /address/<addr>/txs, /address/<addr>/utxos and the WoC unspent routes clamp an oversized limit to the maximum. /tokens, /token/<id>/holders, /token/<id>/history and /stats/richlist accept only 10, 20, 50 or 100: any other value silently falls back to the default. Where the response echoes its page size (limit, or page_size on the rich list), read that back rather than assuming the value you sent was honoured.
Directions, cursors and ordering

Address transaction continuation. Native /address/<addr>/txs pages oldest→newest; WoC /address/<addr>/history and /confirmed/history page newest→oldest, as /blocks does from from. Cursors are opaque: pass them back exactly. Malformed, empty, wrong-address or wrong-direction cursors return 400. Supply either cursor or from_height, never both.

Legacy from_height is an inclusive lower height bound on the native endpoint and an exclusive upper height bound on WoC. An omitted WoC height starts at the newest block; explicit from_height=0 is empty. Height bounds cannot enumerate a page that ends inside a block, so use the continuation instead of advancing the last height. WoC rejects an explicit limit<=0; native nonpositive limits use the default.

Order within a block is stable, and first pages and continuations use the same order, but on Pebble it is not block-position order. Pebble uses internal transaction-ID bytes (descending on native, ascending on WoC); JungleBus uses its stored page/pointer order (ascending on native, descending on WoC). A cursor binds the address, direction, backend and last transaction, so a traversal cannot switch backends; an unavailable continuation backend also answers the restart 409.

WoC script-hash history. /script/<scriptHash>/history and /confirmed/history also default to 100 entries and continue with nextPageToken. One block can hold more script transactions than a page can reach; a page cut there sets X-Has-More: true with no X-Next-Cursor, and on /confirmed/history the envelope's error says the history was truncated. More history exists, but it cannot be continued from that point.

Sorted UTXO limits

On /address/<addr>/utxos, any sort other than the default height:desc scans the address's whole UTXO index, so two bounds apply, both answering 400: a page beyond the first 1,000,000 entries of the sort order, and any sort of an address holding more than 5,000,000 UTXOs. The default height:desc sort with cursor pagination is subject to neither and reaches every UTXO of any address.

Blocks

GET /block/tip

Returns the current chain tip block.

GET /block/latest

Returns the latest block (alias for tip).

GET /block/<id>

Returns a block by height or hash.

GET /block/<id>/protocols

Returns the protocol tag distribution for a block. Response: array of {"protocol", "name", "short", "color", "tx_count"} covering detected on-chain protocols (ORD, BSV21, MAP, B, etc.) within the block.

GET /blocks?from=&limit=

Returns a paginated list of blocks. Cursor-style: from is the upper-bound height (exclusive when paging back), limit defaults to 20, max 100.

GET /block/header/height/<height>/raw

Returns the raw 80-byte block header as binary (application/octet-stream) for a given height. Use /block/headers/latest for a hex-encoded variant.

GET /block/headers/latest

Returns the 80-byte block header of the current chain tip as a hex string.

GET /block/headers/resources

Returns JSON array of downloadable header chunk URLs (2016-block difficulty periods). Each entry has url and label fields.

GET /block/headers/<from>-<to>

Downloads concatenated raw 80-byte headers for a height range as binary. Max 2016 blocks per request. Useful for SPV header chain sync.

GET /block/count?minerId=<miner>

Returns the total number of blocks mined by a specific miner. Response: {"miner": "TAAL", "blocks": 12345}

Transactions

GET /tx/<txid>

Returns a parsed transaction with inputs, outputs, and spend status. Use the always-present confirmed boolean (or block_height) to test confirmation: confirmations is omitted while a transaction is unconfirmed and only appears once it is mined.

Unknown values, oversized transactions and WhatsonChain bulk routes

confirmation_unknown: true (with partial: true) means the block read failed, so confirmed: false is not a verdict that the transaction is unmined; an output's spend_unknown: true likewise means its spent: false is unknown. Retry such an answer rather than act on it. spend_index_unavailable: true (every output spend_unknown, response header X-BB-Spend-Index: unavailable) means the transaction was served while the indexer was processing a chain reorganization: the body is complete and no spend index was consulted; poll until the flag clears if you need spend status. A BSV-21 output's parsed.bsv21.valid is the stored lineage verdict: false for an op with no valid token lineage behind it, such as a forged transfer, and absent when no verdict was read (unconfirmed, not indexed yet, or the lookup failed), so read a missing valid as unknown.

Transactions too large to decode. A transaction whose inputs plus outputs exceed 300,000 is never decoded. This route answers it 413 with {"error", "summary", "bin_url", "hex_url"}. summary carries what was read from the raw bytes: txid, size, version, locktime, inputs, outputs, total_output_value, max_entries, confirmed, and the block fields once it is mined. summary.confirmed: false is not proof that the transaction is unmined: it is also false when the block lookup failed. Follow bin_url (/tx/<txid>/bin), which serves the raw bytes without decoding them; hex_url works too but costs the server several times the raw size in memory to answer. /txo/<txid>/<vout> and the /download/tx/<txid>/output/<index>, /download/tx/<txid>/input/<index> (and their /hex forms) and /download/tx/<txid>/receipt downloads answer such a transaction 413 with an error only, as do the WhatsonChain-compatible /api/v1/bsv/main/tx/hash/<hash>, /block/height/<height>/txindex/<index>, /tx/<hash>/out/<index>/hex and /tx/<hash>/opreturn; that error names the counts and points at the /bin route. The raw /hex and /bin routes and the WhatsonChain-compatible status routes still answer for it.

The WhatsonChain-compatible bulk POST /txs and /txs/vouts/hex answer 200 and mark such an element {"txid": "...", "error": "too large"}. An element there is also "too large" when the 64 MiB response budget is used up, and on /txs/vouts/hex when the requested output script is over 20 MiB of hex. POST /txs/hex never decodes, so it serves such a transaction's hex as /hex and /bin do: it marks an element "too large" only when its raw transaction is over 10 MiB or the 64 MiB response budget is used up. Of the other per-element errors, only "not found" is an answer about the transaction; "temporarily unavailable", "timeout" and "lookup failed" mean ask again, and a response carrying any of them has a Retry-After header.

Unknown confirmation on the WhatsonChain-compatible routes. When the block read for a transaction under the decode ceiling fails, these routes never report it as unmined. /tx/hash/<hash>, /block/height/<height>/txindex/<index> and /tx/<hash>/status answer 503 with Retry-After and {"error": "Transaction confirmation status temporarily unavailable"}; POST /txs marks the element "temporarily unavailable", and POST /txs/status gives it "txStatus": "UNKNOWN" with "error": "temporarily unavailable", and the response a Retry-After. A transaction over the 300,000-entry ceiling is the exception: /tx/<hash>/status and POST /txs/status build its status from the size summary, where a failed block lookup is not flagged, so they can answer "txStatus": "SEEN_ON_NETWORK" for a mined transaction. SEEN_ON_NETWORK for such a transaction is not proof that it is unmined.

GET /tx/<txid>/hex

Returns the raw transaction as a hex string.

GET /tx/<txid>/bin

Returns the raw transaction as binary.

GET /tx/<txid>/proof

Returns the merkle proof for a transaction in TSC format. Optional ?targetType= selects what target carries: merkleRoot (the default) or hash for the block hash. header returns 501 — the 80-byte header cannot be built here. Any other value returns 400.

GET /tx/<txid>/inscription/<vout>

Returns the inscription content embedded in the specified output. Served with the original MIME type (e.g. image/png, text/html). Returns 404 if no inscription exists at that output.

GET /tx/<txid>/nft/<vout>

Renders the NFT at the specified output, resolving the seed-inscription chain (re-inscriptions follow the prior tx's inscription content). Served with the original MIME type. Returns 404 if no inscription is reachable from that output.

GET /tx/<txid>/bfile

Returns the first B:// file in the transaction without the caller naming an output index — this is what a bare b://<txid> reference resolves to, since the file is not always at output 0. Returns 404 if the transaction carries no B:// data.

GET /tx/<txid>/bfile/<vout>

Returns the B:// file content embedded in the specified output. Served with the original MIME type. Returns 404 if no B:// data exists at that output.

GET /tx/<txid>/media/<vout>

Returns embedded media from the output, auto-detecting the format (inscription, B://, or other on-chain media). Served with the detected MIME type. Returns 404 if no media is found.

GET /tx/<txid>/beef

Returns a BRC-95 Atomic BEEF bundle (transaction + ancestor chain + merkle proofs) as binary. Used for SPV verification.

GET /tx/<txid>/beef/hex

Returns a BRC-95 Atomic BEEF bundle as a hex string.

GET /tx/<txid>/status

Returns the confirmation status of a transaction.

GET /tx/<txid>/propagation

Returns propagation status across BSV nodes — shows which nodes have seen the transaction and whether it is in mempool or confirmed.

GET /tx/<txid>/graph

Returns the transaction ancestry graph — a tree of input transactions and their ancestors. Useful for visualizing transaction lineage.

Completeness flags and depth

Two flags qualify the result. truncated means the graph is not the whole picture: the node cap was reached and the traversal stopped early, or at least one node's edges were cut. The nodes and edges that are present are correct. partial means a backend lookup of the ROOT transaction's spends or input values failed, so the graph was built from incomplete data — outgoing edges may be missing entirely, or input values and fees absent. The request still answers 200, so a client that cares about completeness must check partial rather than read an empty fan-out as fact.

Each node can carry flags of its own. nodes[].truncated means that node's edges were cut: it has more spenders, sources or spent outputs than one node may add to the graph, so some of its edges may be missing; the edges present are correct. nodes[].too_large means the transaction is over a per-node cap, so it was never parsed and has no edges of its own (it is also truncated). Its total_in and fee are unknown and protocols is absent. Over the size cap it was not read at all, so total_out, input_count and output_count are unknown too: both counts are 0. size is present only for a confirmed transaction. Over the input-plus-output cap, the bytes were walked: at least one count is positive, both counts and total_out are exact, and size is known. An exact total_out of 0 is a genuine zero. Unknown numeric fields retain the API's 0 sentinel; all too_large nodes have unknown total_in and fee, regardless of their counts.

nodes[].input_total_known is always a boolean. It is true only when every non-coinbase input value was resolved; a measured total_in of 0 remains a genuine zero. It is false for every too_large node and when input enrichment is incomplete, including a missing source or a source count or byte limit. An incomplete total_in can be 0 or a positive lower bound; clients must require input_total_known: true before displaying total_in or fee as measured. This qualification is independent of the ROOT-only partial flag: deliberate limits and genuine misses are not backend failures. Older cached responses without input_total_known must be treated as having unknown input totals until their normal cache expiry. Output totals and counts retain the availability rules above.

depth counts traversal levels: depth=1 includes only the root, while depth=2 can include its immediate neighbors in the requested direction. A scan cut can set truncated even at the depth boundary; it reports a discovery limit, not a backend failure. A child refused by a per-node limit may be admitted when expanded under the larger root limits. The graph panel therefore says “Too large in this view: connections not loaded.”

POST /tx/broadcast

Broadcasts a raw transaction. Body: {"rawtx": "hex"}. A 200 carries ARC's txStatus and accepted; check accepted, since a pending status (e.g. QUEUED) is still a 200. When ARC refuses this server's request rather than your transaction (authentication, rate limiting, a body cap), the answer is 502, or 503 with Retry-After when ARC is rate-limiting or timed out, never a 400: the transaction was not judged, so do not drop it.

POST /tx/broadcast/multi

Broadcasts up to 100 raw transactions in one request, as a JSON array of {"rawtx": "hex"} objects or one hex transaction per line. Each transaction spends one unit of your broadcast cap. The response holds one entry per transaction: check each entry's accepted, not the HTTP status.

Body format, batch limits and per-transaction results

Broadcasts several raw transactions in one request: up to 100 raw transactions, and no more than your per-server share of the broadcast cap (see below). The body is either a JSON array of objects, [{"rawtx": "hex"}, ...], sent with Content-Type: application/json (txhex and rawTx are accepted as aliases of rawtx; unknown fields are rejected), or, with any other content type, plain text with one hex transaction per line (blank lines are skipped). A bare JSON array of hex strings is not accepted. A malformed body, an invalid transaction, or more than 100 transactions fails the whole request with 400, and nothing is broadcast. The request body is capped at 32 MiB in total, counted in encoded bytes (the hex characters, plus the JSON framing on the JSON form), which is about 16 MiB of raw transaction bytes across all entries; split a larger batch. A body over the cap is answered 400, or 413 on the plain-text form when its first line alone reaches 32 MiB.

The transaction broadcast cap (see Rate Limits) counts transactions, so a batch of n transactions spends n of it. Each server enforces its own share of the cap: the cap divided by the number of servers, rounded down, at least 1. The largest batch you can send is 100 or that per-server share, whichever is smaller. The next two answers apply while at least one unit of your share is left this minute on the server that answered. A batch over your share is refused whole with a non-retryable 413 and {"error": "...", "max_batch_txs": M}, where M is your share on the server that answered: split it into batches of at most M, because resending it unchanged gets the same 413. A batch within your share but larger than what is left of it this minute is refused whole with 429 and a Retry-After sized for the whole batch. Nothing is broadcast on either answer, and each of these two answers spends one unit of the cap. When nothing is left of your share this minute, every broadcast POST, including a batch of any size, is refused before its body is read, with 429 and {"error": "broadcast rate limit exceeded"}: that answer has no Retry-After and spends nothing, so wait at least one minute and retry. The anonymous share is small, so anonymous callers can send only small batches: a cap of 10 split over 3 servers admits batches of at most 3. To send more, use an API key on a tier with a higher broadcast cap (pro or enterprise; see the broadcast column of the tier table), or broadcast directly to ARC or Arcade instead of through BananaBlocks. A free key does not raise the broadcast cap: it is metered in the same per-IP bucket as anonymous callers.

Once ARC returns a readable per-transaction answer, the response is HTTP 200 with one entry per submitted transaction, in submission order, even when some of them were refused, so never infer success from the HTTP status alone: check each entry's accepted. It is true only when the entry's txStatus proves acceptance; a pending status (e.g. QUEUED or STORED) still reads false. A refused transaction's entry has accepted: false, an error message and a status code: ARC's own (e.g. 465), 400 / 409 for a REJECTED / DOUBLE_SPEND_ATTEMPTED txStatus (with txStatus, extraInfo and competingTxs when ARC sends them), or 502 when ARC's response had no entry for it, in which case it may or may not have been broadcast, or 502 / 503 when ARC refused this server's request for it. When the batch as a whole fails upstream, the request answers 502 / 503 (including when ARC's answer is unreadable or empty, or when ARC refuses this server's request rather than the transactions), or a 4xx when ARC rejects the whole batch as invalid. A 502 / 503 does not mean nothing was sent: ARC may already have broadcast some or all of the transactions, so resubmit the same transactions or check each txid's status, and do not rebuild them spending the same inputs.

Browser origin policy. The broadcast routes (including the WoC-compatible /tx/raw) refuse requests that carry an Origin header from a site other than this one, with 403. This stops a third-party page from having a visitor's browser submit a pre-signed transaction using the visitor's IP and our upstream quota. Server-side clients (curl, backends, scripts) send no Origin header and are unaffected on every accepted content type.

Addresses

GET /address/<addr>

Returns address info with balance and UTXO count.

GET /address/<addr>/txs?limit=20

Returns confirmed transaction history oldest→newest. Follow next_cursor with ?cursor=... to continue within a block; see Pagination above.

GET /address/<addr>/utxos?page=

Returns paginated UTXOs for an address. Confirmed UTXOs only — unconfirmed change outputs still in the mempool are not included. Clients that spend unconfirmed change (e.g. faucets) should use the mempool-aware WoC-compatible endpoint /api/v1/bsv/main/address/<addr>/unconfirmed/unspent (bulk: POST /api/v1/bsv/main/addresses/unconfirmed/unspent). That list holds at most 1000 entries, the most recently added ones, and its error reads "truncated" when it was cut, unless the mempool tracker cannot vouch for the list, which carries a different error (see the WhatsonChain section below). The bulk form also stops at 5000 entries per request.

GET /address/<addr>/balance

Returns just the balance for an address as a single confirmed balance field. Confirmed only — for a WoC-style {confirmed, unconfirmed} split, use the WoC-compatible /api/v1/bsv/main/address/<addr>/balance (or /confirmed/balance and the mempool-aware /unconfirmed/balance).

GET /address/<addr>/scripts

Returns the locking scripts associated with an address.

GET /address/<addr>/tokens

Returns BSV-21 token holdings for an address.

GET /address/<addr>/token/<tokenId>/history

Returns token transfer history for a specific token at an address.

TXO

GET /txo/<txid>/<vout>

Returns a specific transaction output. A spent output carries spent_txid and spend_scope ("confirmed" or "unconfirmed"), the same mempool-aware signal as /txo/<txid>/<vout>/spend below. spend_scope is present only when the spender's block status was checked: it is omitted when that check failed or is backed off after a failure, while spent and spent_txid are still reported. Read a spent output without spend_scope as scope unknown, not as confirmed.

GET /txo/<txid>/<vout>/spend

Returns the spending transaction for an output, if spent. Spend status is mempool-aware: a spent output also carries spendScope, which is "confirmed" when the spending transaction is in a block and "unconfirmed" when it is not in a block yet. An unconfirmed spend can still be dropped or double-spent, so wait for "confirmed" before treating it as final. spendScope is omitted when the output is spent but the spending transaction's block status could not be checked: spent and spentTxid are still reported, so read a spent output without spendScope as "scope unknown", not as confirmed. The outputs of /tx/<txid> carry the same signal as spend_scope, which is left out when an output is unspent or its scope could not be checked.

Downloads

GET /download/tx/<txid>

Downloads the raw transaction as a binary file.

GET /download/tx/<txid>/hex

Downloads the raw transaction as a hex text file.

GET /download/tx/<txid>/output/<index>

Downloads a specific output script as binary.

GET /download/tx/<txid>/output/<index>/hex

Downloads a specific output script as hex.

GET /download/tx/<txid>/input/<index>

Downloads a specific input script as binary.

GET /download/tx/<txid>/input/<index>/hex

Downloads a specific input script as hex.

GET /download/tx/<txid>/receipt

Downloads a PDF receipt for a transaction. When the transaction's block or spend status could not be read it answers 503 with Retry-After and {"error": "Transaction status temporarily unavailable"} rather than print a guess.

GET /download/address/<addr>/statement

Downloads a PDF statement of recent transactions for an address.

GET /download/address/<addr>/history.csv

Downloads an address's recent transaction history as CSV, with the columns txid, block_height, block_hash, block_time, delta_satoshis, delta_bsv. Capped at the 100 most recent transactions, so treat it as a recent-activity export rather than a complete history.

Truncated and partial files

When the address has more transactions than that, the response carries X-Truncated: true and the file ends with a row whose txid column starts with # truncated (its other five columns are empty). When a row's transaction or block lookup failed or timed out, the row is still written with a blank block_time (and, when the transaction or its input values could not be read, blank delta_satoshis and delta_bsv); the response then carries X-Partial: true and the file ends with a # partial: N of M rows ... row, after the truncated row when both apply. Skip rows whose first column starts with # when you parse the file.

GET /search?q=

Auto-detects query type (txid, block hash/height, address) and returns a redirect.

Stats

GET /stats/summary

Returns chain summary (height, hashrate, difficulty, price, market cap).

GET /stats/network

Returns daily network statistics (blocks, txs, fees, hashrate).

GET /stats/mining

Returns mining pool distribution.

GET /stats/mining/histogram?period=<24h|7d|30d>

Returns time-bucketed mining data. Bucket sizes: 24h→1h, 7d→6h, 30d→1d. Response: array of {"bucket": "...", "miner": "TAAL", "blocks": 5}

GET /stats/props/histogram?period=<24h|7d|30d>

Returns time-bucketed block property averages (size, tx count, fees). Bucket sizes: 24h→1h, 7d→6h, 30d→1d.

GET /stats/protocols?days=<7>

Returns protocol tag counts from recent transactions. Response: array of {"protocol": "ord", "tx_count": 5000}

GET /stats/protocols/histogram?period=<24h|7d|30d>

Returns time-bucketed protocol tag counts. Bucket sizes: 24h→1h, 7d→6h, 30d→1d. Response: array of {"bucket": "...", "protocol": "ord", "tx_count": 5000}

GET /protocol/{key}/txs?page=&limit=

Returns the paginated transaction list for a single protocol (same data as the /protocol/{key} HTML page), including the daily activity chart series. 404s if key is not a known protocol tag.

GET /protocol/{key}/history?days=<90>

Returns daily tx-count history for a single protocol. days is clamped to 1–365 (default 90). Response: array of {"bucket": "...", "protocol": "ord", "tx_count": 5000}. 404s if key is not a known protocol tag.

GET /stats/richlist?page=

Returns paginated rich list.

GET /stats/price-history?days=

Returns historical BSV/USD prices, oldest first: [{"timestamp": ms, "price": usd}]. days defaults to 7, max 10000; history starts 2018-11-10 (the first daily close). Points are 5-minute for 1 day, hourly up to 90 days, daily beyond.

GET /stats/chart/<metric>

Returns time-series data for a given stat metric. Valid metrics: tx_count, total_fees, avg_fee, avg_fee_rate, avg_tx_size, median_fee, median_tx_size, total_output_value, coin_days_destroyed, hashrate, difficulty, avg_block_size, total_size, avg_tx_rate, miners_revenue, circulating_supply, utxo_count, blockchain_size, total_tx_count, exchange_rate, mining. Supports ?days= query parameter (default 30, max 10000 — clamped to available history from genesis; long windows are downsampled).

Exchange Rate

GET /exchangerate

Returns the current BSV exchange rate in USD.

GET /exchangerates

Returns BSV exchange rates in multiple currencies.

Mempool

GET /mempool

Returns mempool summary (tx count, size, usage, min fee).

GET /mempool/stats

Returns mempool fee rate and size distribution histograms. Response includes tx_count, total_fee_bsv, avg_fee_bsv, fee_rate_buckets (sat/byte ranges), and size_buckets (byte ranges). Cached for 30 seconds.

GET /mempool/protocols

Returns detected protocol tags in mempool transactions with counts. Each entry has protocol, short, color, and count. Parses every mempool tx for protocol detection (ORD, BSV21, MAP, B, etc.). Cached for 60 seconds.

Mining

GET /miner/fees

Returns current mining fee statistics.

GET /policy

Returns the current fee policy.

Tools

POST /tools/decode

Decodes a raw transaction. Body: {"rawtx": "hex"}

GET /tools/script/decode?hex=

Decodes a script hex string.

GET /tools/address/<addr>

Converts an address between formats. Returns the base58, scripthash, and script hex for a given address.

GET /tools/peers

Returns the connected node peers (proxies the BSV node's getpeerinfo). Each entry includes addr, services, conntime, version, subver, and traffic counters.

BSV-21 Tokens

GET /tokens?page=

Returns a paginated list of all BSV-21 tokens.

GET /token/<tokenId>

Returns details for a specific token: id, symbol, decimals, icon, total_supply, circulating_supply, remaining_to_mint, holder_count, deploy_height, verified, suppressed and holders_refreshed_at. Every amount is a decimal string in the token's smallest unit — divide by 10decimals for the display value. Amounts routinely exceed 263, so decode them as strings or big integers, never as a 64-bit number.

Supply fields explained
FieldMeaning
total_supplyThe maximum declared in the deploy+mint. Fixed for the life of the token.
circulating_supplyThe sum of every holder balance — what has actually reached addresses.
remaining_to_minttotal_supply minus circulating_supply, floored at 0.

The last two are what you would otherwise get by paging the whole holder list and adding it up. They diverge from total_supply on tokens that distribute over time: a lock-to-mint or POW20 deploy declares its entire supply on day one and releases it in lim-sized pieces, and supply still held by the mint contract has no holder address, so it counts as remaining rather than circulating. On a token that minted everything at deploy the first two are equal and remaining_to_mint is 0.

There is no separate /circulating or /remaining route: both are fields on this response, so one request answers the whole question.

Both fields are omitted entirely when this token's balances have never been computed — a discovered token the periodic sweep has not reached yet. An absent field means unknown, not zero: coercing it to 0 would report “nothing has been minted” about a token nobody has looked at. A maintained token that genuinely has no holders reports a real "0".

GET /token/<tokenId>/holders?page=

Returns paginated list of token holders with balances.

GET /token/<tokenId>/history?page=

Returns paginated token transfer history.

GET /token/<tokenId>/icon

Returns the token's icon inscription as an image, served with its original MIME type under a strict sandboxing CSP and cached immutably. Returns 404 when the token declares no icon or the icon inscription cannot be read.

GET /token/<tokenId>/funding

Returns the token's deterministic 1sat fee address and live funding status: {"token_id", "fee_address", "fee_per_output", "status_available", "balance", "credits", "debits", "output_count", "is_active", "is_whitelisted", "is_blacklisted", "solicitable"}. The address is always present; the accounting fields are meaningful only when status_available is true. Gate any offer to pay on solicitable, which already folds in “status known and not blacklisted” — a bare "is_blacklisted": false is indistinguishable from “unknown”. A token this node does not have returns 404 rather than an address, so a mistyped id can never yield a valid-looking address whose deposits fund nothing.

POST /address/tokens/balance

Multi-address batch token balances. Body: {"addresses": ["addr1", "addr2", ...]} (max 20 addresses per request). Returns an array keyed per address: {"address", "tokens": [{"token_id", "symbol", "decimals", "balance"}]}. Invalid or failed addresses return a per-item "error" with "tokens": [] rather than failing the whole batch.

POST /address/tokens/unspent

Multi-address batch unspent token outputs. Body: {"addresses": ["addr1", "addr2", ...]} (max 20 addresses per request). Returns an array keyed per address: {"address", "utxos": [{"txid", "vout", "token_id", "symbol", "decimals", "amount", "block_height"}]}. Each address returns up to 100 most-recent unspent token UTXOs. When the returned list is not guaranteed complete — the 100 cap was reached, or (in Pebble mode) the spent-status scan hit its work guard on an address with a very large spent-token history — the entry includes "incomplete": true; treat such a list as partial. Invalid or failed addresses return a per-item "error" with "utxos": [].

Health

GET /health

Returns "ok" if the server is running. (Not under /api/v1 prefix.)

Data freshness

GET /status/indexing

Returns node-local observations cached in this process up to five seconds. It describes history relative to this node's committed data, not the network tip. This endpoint does not replace /health or existing read safety checks.

{"observed_at":"2026-09-08T19:00:00Z","history_state":"unknown","indexed_height":null,"committed_height":null,"history_reasons":["history_unavailable"],"token_maintenance_state":"unknown","token_maintenance_reasons":["maintenance_unavailable"]}
Fields

history_state: ready, catching_up, reorganizing, uninitialized or unknown. indexed_height and committed_height are nullable; they are never balance snapshot heights. observed_at dates this separate observation.

token_maintenance_state: pending, blocked, no_known_backlog or unknown. This describes token balance maintenance, not the BSV balance read. The history and maintenance reason arrays carry stable codes. A failed source returns partial unknown fields with HTTP 200; no_known_backlog only means no work observed in the checked indicators.

The freshness object. Native address detail/balance and token detail add an optional freshness object:

{"sampled_at":null,"last_full_refresh_at":null,"cached":true,"source":"pebble_balance"}
Fields

Value requests do not wait for node observations: an absent or expired cached status returns unknown while a single refresh runs. Its sampled_at dates a completed balance read and survives cache hits. Legacy cached results have null sample time. A PostgreSQL sample dates its aggregate query, not the age of the aggregate. source and cached describe provenance. indexing contains the independent node observation.

Token last_full_refresh_at dates whole-token recomputation; it is null when unknown or invalidated. It does not date incremental updates or a separately cached holder list. Token state and reasons describe its own queue observation; node-wide conditions stay under indexing. Existing holders_refreshed_at and WoC envelopes are unchanged.

WebSocket

Connect to /ws for real-time updates. Subscribe by sending subscribe:<channel> (and unsubscribe:<channel>) as a text message. Valid channels:

  • blocks — new block notifications.
  • mempool — mempool transaction feed.
  • lock:address:<addr> — outputs paying to an address (also lock:scripthash:<64-hex>).
  • spend:address:<addr> — inputs spending from an address (also spend:scripthash:<64-hex>).
  • tx:<64-hex txid> — events for a specific transaction (currently: seen when it enters the mempool; lowercase hex).

Example: subscribe:lock:address:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa to watch payments to an address.

Webhooks

Get an HMAC-signed HTTP POST when a selector fires, instead of polling or holding a WebSocket connection open. Requires a self-serve account (see /account); the subscription cap depends on your active keys' tier — the free tier includes none.

POST /account/webhooks

Registers a subscription. Form fields: selector, callback_url. Session-authenticated (dashboard form), not part of the /api/v1 surface.

Selectors

Same grammar as the WebSocket channels below, EXCLUDING the bare blocks/mempool feeds — those stay public, no-auth, WebSocket-only:

  • lock:address:<addr> / lock:scripthash:<64-hex> — outputs paying to an address.
  • spend:address:<addr> / spend:scripthash:<64-hex> — inputs spending from an address.
  • tx:<64-hex txid> — events for a specific transaction (currently: seen).

Callback URL requirements

Must be https://. Every IP the hostname resolves to must be a public, globally-routable address — loopback, private (RFC1918), link-local (including the 169.254.169.254 cloud-metadata address), and unspecified addresses are all rejected, both at registration time and again at delivery time. The delivery request never follows redirects.

Signature verification

Each delivery carries:

  • X-Webhook-Signature: sha256=<hex HMAC-SHA256 of the raw request body, keyed by your subscription's secret>
  • X-Webhook-Event: <selector>
  • Content-Type: application/json

The secret is generated server-side and shown exactly once when you create the subscription — store it; it cannot be recovered, only rotated by deleting and re-creating the subscription. Recompute the HMAC over the raw body and compare using a constant-time comparison before trusting a delivery.

Delivery console and test events

Open Delivery console beside a subscription on your account dashboard to see recorded outcomes, attempt counts, final HTTP status and safe failure reasons. The console keeps the latest 100 summaries within seven days. History starts with this feature and can have gaps during admission drops, failover, interruption or persistence failure; it is not a complete event ledger. No request or response bodies are retained. Deleting a subscription deletes its history.

Send test event queues one signed synthetic event to that subscription's saved callback. One test can be outstanding per account, with at most one accepted request per minute. Queued means not yet sent; queued requests expire after five minutes. A running request interrupted before its result is recorded becomes unknown and is never automatically replayed.

A synthetic test uses X-Webhook-Event: webhook.test, X-Webhook-Test: true, the normal HMAC signature and a JSON body with type: "webhook.test", test: true, request_id, subscription_id and selector. It is not a chain transaction. It makes one attempt of at most five seconds. Tests use the same HTTPS/public-IP/redirect protections and do not change the subscription's failure streak, enabled status or legacy last-delivery timestamp. A disabled subscription within your current plan can be tested before you explicitly re-enable it.

Retry, disable, and delivery semantics

A failed delivery (anything other than a 2xx response, including a timeout) is retried up to 3 times with 1s/5s/25s backoff. A subscription that fails 10 consecutive deliveries is automatically disabled (visible on the dashboard); use Re-enable on your account dashboard to keep the same signing secret. Local sender-network failures may be left out of the auto-disable count.

Webhooks are best-effort and at-least-once in normal operation, the same idempotency posture as most webhook providers — key on txid (plus status for tx: events) to de-duplicate on your end. Delivery is owned by exactly one backend node at a time; during the brief window around a failover (the prior owner stops, the new one takes over), events observed in that window are not delivered — webhooks are therefore at-most-once across a failover. Treat webhooks as a low-latency convenience and reconcile against the REST API for anything that must not be missed.

WhatsonChain-Compatible API

A WhatsonChain-compatible API is available at /api/v1/bsv/main. This allows existing applications built for the WoC API to work with BananaBlocks as a drop-in replacement. See the WhatsonChain API docs for endpoint details. BSV-21 token endpoints (/token/bsv21/...) are included — see the BSV-21 (Beta) contract for the covered subset.

This mirror is also where mempool-aware (unconfirmed) address reads live. The native address balance, UTXO and history endpoints above are confirmed-only; native spend status is mempool-aware and labelled with its scope (see TXO), and a native transaction lookup can return an unconfirmed transaction. The mempool-aware address routes are /address/<addr>/unconfirmed/unspent, /address/<addr>/unconfirmed/balance, and /address/<addr>/unconfirmed/history (plus their POST /addresses/... bulk forms).

Broadcast and bulk status. POST /tx/raw answers an accepted broadcast with the txid as a bare JSON string, as WhatsonChain does. A transaction ARC has taken but not yet accepted (e.g. QUEUED or SEEN_IN_ORPHAN_MEMPOOL) is answered 503 with Retry-After and {"error", "txid", "txStatus", "accepted": false}; resubmitting it is safe, but each resubmit is a broadcast and counts against the broadcast rate limit, so follow a pending transaction with GET /tx/<txid>/status or POST /txs/status instead. POST /txs/status carries WhatsonChain's blockhash, blockheight and confirmations, blocktime when the block's time is known (only for a transaction mined in the active chain: a MINED_IN_STALE_BLOCK entry keeps blockHash but none of these), and "error": "unknown" when neither ARC nor the chain index has seen the txid, alongside this server's txStatus, blockHash, blockHeight and blockTime. If the ARC lookup fails and the chain index has not seen the txid, the entry's error is "temporarily unavailable" and the response carries Retry-After; ask again rather than treating the transaction as dropped.

Unconfirmed list caps (1000 per list, 5000 per bulk request)

Each unconfirmed unspent or history list, per address or script hash, holds at most 1000 entries, the ones the mempool tracker added most recently. Past that the list's error is "truncated" while result still holds the kept entries, so on these routes a non-empty error does not always mean the lookup failed. When the mempool tracker cannot vouch for the list (e.g. right after a restart), the error is instead "unconfirmed balance unavailable: mempool tracker initializing", which outranks "truncated": result is still served and may be both cut and incomplete. This applies to /address/<addr>/unconfirmed/{unspent,history}, /script/<scriptHash>/unconfirmed/{unspent,history}, their POST /addresses/... and POST /scripts/... bulk forms, and the unconfirmed half of POST /addresses/history/all. The bulk /addresses and /scripts unconfirmed unspent and history forms also stop at 5000 entries per request across all elements, a repeated element counted each time: an element that does not fit in what is left of the budget gets a cut or empty result (keeping its most recent entries) and "truncated", unless its error already says something else.

The network segment follows the deployment, exactly as it does on WhatsonChain itself: mainnet serves /api/v1/bsv/main and testnet serves /api/v1/bsv/test, so an unmodified WoC client pointed at either host resolves without a path rewrite. This host serves /api/v1/bsv/main.

Known divergences from WhatsonChain

Known divergence — bulk confirmed balance

POST /addresses/confirmed/balance returns one extra key per entry: associatedScripts, the [{script,type}] list WhatsonChain ships only on the single-address /address/<addr>/confirmed/balance. Everything WhatsonChain does return is present and unchanged, so a client decoding its entry shape is unaffected; one decoding strictly (rejecting unknown keys) needs to allow it. The singular route, and both unconfirmed forms, match exactly.

Known divergence — per-block tag counts

GET /block/tagcount/height/<height>/stats matches WhatsonChain's route, response shape ({count, results:[{name,count}]}), count-descending order and status contract, but the tag names and counts are ours, not WhatsonChain's. Both sides count per (transaction, tag) and agree exactly on many tags, but WhatsonChain collapses a protocol and the app using it into a single hierarchical tag (run#haste) where we report both separately, and its unclassified OP_RETURN bucket is much broader than ours. Totals therefore differ. A client that reads count and iterates results works unmodified; one that switches on specific WhatsonChain tag names needs a mapping.

Two further notes: there is no by-hash variant of this endpoint (WhatsonChain has none either), and an unmined height returns 200 with zeros rather than a 404. Because zeros also mean “this block has no tagged transactions” — and, within a few blocks of the tip, “not indexed here yet” — treat an empty histogram near the tip as unknown rather than as none.