Keelchain
Docs / Read API

Keel explorer — indexer API contract (v1)

The explorer web app (apps/explorer) talks only to the indexer (chain/crates/keel-indexer), never to a validator directly. One indexer process serves one network; the explorer is configured with one base URL per network and shows a switcher:

VITE_NETWORKS='[{"id":"testnet","name":"Testnet","api":"https://testnet.keelchain.com/api"},{"id":"mainnet","name":"Mainnet","api":"https://api.keelchain.com"}]'

The indexer keeps full history in Postgres. It backfills from the node's GET /v1/blocks/{h} and GET /v1/blocks/{h}/receipts (served from the node's journals for any height) and then follows /v1/ws. It re-verifies the chain of state_hashes it receives against /v1/status and flags a mismatch on /v1/health.

The indexer listens on 127.0.0.1:6100 by default (--listen); a mainnet instance conventionally uses 6101. Do not use 6000–6063 or 6665–6669: browsers refuse fetch to those ports (WHATWG "bad ports"), so the explorer would silently fail.

All amounts are decimal strings in smallest units plus a decimals where an asset is involved. Addresses and hashes are hex. Paging: ?limit=&cursor=, responses carry next_cursor when more exist. Every list is newest first.

Method Path Returns
GET /v1/health {network, chain_id, indexed_height, node_height, lag, state_hash_ok, started_at}
GET /v1/stats {height, block_time_ms_avg, tps_1h, actions_24h, accounts, validators, tvl_usd, assets:[{asset, supply, holders}], fees_24h:[{asset, amount}]}
GET /v1/blocks?limit=&cursor= {blocks:[Block], next_cursor}
GET /v1/blocks/{height} Block & {receipts:[Tx], events:[Event]}
GET /v1/txs?limit=&cursor=&signer=&module=&ok= {txs:[Tx], next_cursor}
GET /v1/txs/{tx_id} Tx & {action (decoded JSON), block: Block}
GET /v1/accounts/{addr} {address, nonce, tier, budget, balances:[{asset, account_type, balance, decimals}], first_seen_height, tx_count, deposit_addresses:[{chain, index, address}], validator?:{...}, offers_count, trades_count}
GET /v1/accounts/{addr}/txs like /v1/txs filtered by signer or counterparty
GET /v1/accounts/{addr}/orders, /offers, /trades `{orders
GET /v1/accounts/{addr}/transfers {transfers:[{tx_id, height, timestamp, asset, amount, from, to, kind}]} (Transferred, DepositCredited, OutboundConfirmed, OrderFilled legs, TradeReleased)
GET /v1/assets [{asset, decimals, kind, supply, holders, reserves?, chain?}]
GET /v1/assets/{asset} asset + {holders_top:[{address, balance}], transfers_24h}
GET /v1/markets [{pair, base, quote, last_price, volume_24h_base, volume_24h_quote, trades_24h, best_bid, best_ask}]
GET /v1/markets/{pair} market + {book:{bids:[[price,size]], asks:[[price,size]]}, house_quote} (book fetched live from the node)
GET /v1/markets/{pair}/fills?limit=&cursor= {fills:[{tx_id, height, timestamp, price, quantity, quote, taker, maker_order_id, fee}]}
GET `/v1/markets/{pair}/candles?interval=1m 5m
GET /v1/orders/{id} order record + fills
GET /v1/offers?asset=&side=&status=&owner=&limit=&cursor= {offers:[Offer], next_cursor} (public fields only; status is `active
GET /v1/offers/{id} Offer + trades
GET /v1/trades/{id} Trade (status history, dispute if any)
GET /v1/validators [{address, consensus_key, self_bond, delegated, power, jailed, blocks_proposed_24h?, uptime?}]
GET /v1/epochs [{epoch, start_height, validators}]
GET /v1/vaults [{chain, epoch, address_count, reserves:[{asset, amount}], liabilities, fee_rate, halted}]
GET /v1/vaults/{chain}/deposits?status=&owner=&limit=&cursor= {deposits:[...]} (pending, credited, held, rejected; with the external tx hash and depth)
GET /v1/vaults/outbounds?status=&owner=&limit=&cursor= {outbounds:[...], batches:[...]}
GET /v1/governance/proposals [Proposal] with tallies and status
GET /v1/governance/proposals/{id} Proposal + votes
GET /v1/governance/params {params:{key: value}, history:[{height, key, from, to, tx_id}], live}; nested node params are flattened with dots (budget.base) and values are strings
GET /v1/search?q= `{kind: block
WS /v1/ws {type:"block", block: Block & {tx_count}} per block; {type:"tx", tx: Tx} optional

Types:

Block: {height, timestamp, state_hash, tx_count, ok_count, event_count, proposer?}
Tx:    {tx_id, height, index, timestamp, signer, module, kind, ok, error?:{code, message}, events:[Event]}
Event: {type, ...fields}  (serde of keel_vm::Event, addresses hex)
Offer: {id, owner, side, asset, fiat_currency, payment_method, margin_bps, min_amount, max_amount, payment_window_secs, country, min_tier, status, created_height}
Trade: {id, offer_id, buyer, seller, asset, amount, fee, fiat_amount, fiat_currency, status, started_height, deadline, paid_at?, closed_height?, dispute?}

Errors: {error:{code, message}} with 400/404/500.

Live fallbacks: the order book, best bid/ask, balances, vault state and governance records are read from the node on request (and the DB copy refreshed) so objects older than the indexer's first block still resolve; when the node is unreachable the last snapshot is served and /v1/health reports node_reachable: false.

WebSocket

/v1/ws uses the same envelope as the node's socket (chain/crates/keel-rpc/API-WS.md): channels blocks, txs and account:<hex>, a per-connection seq, a gap frame on lag and a heartbeat. A socket that never subscribes receives blocks and txs.