UI-facing REST. Stateless Fastify service (backend/src/api.ts). Reads from Postgres only.
The public CoinGecko-compliant endpoints (/cg/*) are documented separately in CoinGecko.md.
The human-readable version of this file is served at
/api, generated bybackend/scripts/gen-api-docs.mjs.
- Transport: JSON, UTF-8,
application/json; charset=utf-8. OnlyGETis exposed. - Numbers: amounts in groths are returned as strings (
NUMERIC(40, 0)doesn't fit in JSnumber). The frontend divides by10^decimalsto display. USD numbers are JSON numbers (USD never overflows). Timestamps are unix seconds (number); heights are integers. - Errors:
{ "error": { "code": "PAIR_NOT_FOUND", "message": "no pair 0-31-1" } }codeis stable;messageis human-readable and may change. - Booleans in query strings:
1,true,yes,onare true;0,false,no,offand an empty value are false (case-insensitive). Anything else is a 400 rather than a guess — a bool param that silently ignored?flag=0would be write-only. - Auth: none.
- CORS: open (
Access-Control-Allow-Origin: *). The in-wallet origin works the same way. - Rate limit: per-IP via
@fastify/rate-limit,RATE_LIMIT_PER_MIN(default 600/min). Set to 0 to disable. Excess responses are{ "error": { "code": "RATE_LIMITED", "message": "…" } }. - Cache-Control: per-route, typically
public, max-age=15or30./api/healthisno-store. - API base in production:
https://beamterminal.0xmx.net/api. The frontend hard-codes this infrontend/src/app/containers/Screener/api/client.ts.
Liveness. Returns 200 with the lag between now() and cursor.updated_at. 503 if the cursor row is missing.
{
"status": "ok",
"last_indexed_height": 3863512,
"lag_seconds": 14
}Cache-Control: no-store. The indexer rate-limit applies but /api/health is logged at debug level only (so it doesn't drown the request log if a probe hits it every second).
Header strip data: BEAM/USD, supply and market cap, DEX and bridge TVL, 24h volume, lifetime volume, all-time high and low, pair/trade counts.
Example below uses real figures from 2026-08-23, so the arithmetic checks out:
192,100,675 × 0.00764726 = 1,468,921.51.
{
"beam_usd": 0.00764726,
"circulating_supply": 192100675.0,
"market_cap_usd": 1468921.51,
"total_tvl_usd": 42157.68,
"bridge_tvl_usd": 38210.55,
"volume_24h_usd": 49.47,
"total_volume_usd": 1337274.88,
"ath_usd": 4.28,
"ath_ts": 1546617600,
"atl_usd": 0.00616752,
"atl_ts": 1783082120,
"total_pairs": 94,
"total_trades": 60237,
"last_indexed_height": 4004167,
"block_ts": 1787438794
}-
circulating_supply— whole BEAM, from the aid-0 asset row (assets.emission), whichservices/beamSupply.tssyncs from the explorer's/status?exp_am=1Current Circulation (emission plus released treasury).nulluntil that sync has run.Note this is not the figure CoinGecko publishes: it lists 201,543,500 against the explorer's 192,100,675. The chain is the authority here; do not "correct" ours to match theirs.
-
market_cap_usd—circulating_supply × beam_usd.nullif either input is missing; a market cap computed from half its inputs is worse than no answer. -
total_tvl_usd— sumsreserve1_usd + reserve2_usdper active pool. For pools where only one side has a USD rate (via the USD valuation routing), the known side is doubled (AMMs hold equal value on both sides at equilibrium). Pools where neither side is priceable are skipped. -
bridge_tvl_usd— escrowed collateral across the bridges, valued off the Beam-side asset exactly as/api/bridge/healthdoes. Bridges that cannot be priced are absent from the total rather than counted as zero, so this isnulluntil at least one is priceable. Only thebridge_escrowslice is read here — the rest of/bridge/healthgroups overbridge_messagesand is far heavier. -
volume_24h_usd— sums one priced side per pool over the 24h trade window. -
total_volume_usd— point-in-time-valued lifetime volume, served from the precomputeddex_statscache (refreshed by the indexer every 5 minutes).nulluntil the first refresh after a fresh deploy. -
ath_usd/ath_ts— highest BEAM/USD ever, and when. Not simplymax(oracle_snapshots): this indexer starts 2022-08-13 and the highest price it has ever seen is ~$0.29, whereas the real high is $4.28 on 2019-01-04, the day after genesis. The served value is the greater of that known constant and the oracle maximum, so a genuine new high supersedes it without a code change. -
atl_usd/atl_ts— lowest BEAM/USD ever. This one is justmin(oracle_snapshots)and needs no known-value floor: the all-time low is $0.00616752 on 2026-07-03, inside the indexed window. The asymmetry with the high is only that the price has fallen over the years, putting the high before this indexer existed and the low after.
Both extremes are cached in dex_stats because the queries behind them scan a hypertable with no index on beam_usd, and this endpoint is on the hot path.
block_ts— timestamp of the latest oracle snapshot (proxy for last-tick wall-clock time).
Cache-Control: public, max-age=15.
List of all pairs with 24h stats baked in.
Query params:
| Name | Type | Default | Notes |
|---|---|---|---|
sort_by |
tvl_usd | volume_24h_usd | price_change_24h | trades_24h | aid2 |
tvl_usd |
|
order |
asc | desc |
desc |
|
limit |
int, 1..500 | 100 | |
offset |
int | 0 | |
search |
string | — | Substring match on short_name or exact AID. Splits on / so BEAM/USDT narrows to both sides. |
kind |
0 | 1 | 2 |
— | Filter to one volatility tier. |
include_imposters |
bool | false | If true, includes pools where either side has is_imposter = TRUE. |
group |
tier | pair |
tier |
pair collapses fee tiers into one combined row per (aid1, aid2). |
The screener lists each pair once instead of once per fee tier. Reserves,
volume_24h_*, trades_24h/buys_24h/sells_24h and tvl_usd are summed
across tiers; price_native/price_usd/price_change_24h/sparkline_7d and
the identity fields (pair_id, kind, lp_token) come from the reference
(deepest) tier — the pool with the largest reserve1, matching the
USD-valuation convention. Each grouped row adds a tiers[] array (one entry per
fee tier, deepest first) carrying { pool_id, kind, kind_label, lp_token, tvl_usd, volume_24h_usd, reserve1_human, reserve2_human, price_native }. Sorting
and slicing happen app-side after the merge.
Response shape (one entry shown — see frontend/src/app/containers/Screener/api/types.ts for the canonical TS type):
{
"pairs": [
{
"pair_id": 17,
"aid1": 0, "aid2": 31,
"symbol1": "BEAM", "symbol2": "USDT",
"kind": 1, "kind_label": "Medium",
"decimals1": 8, "decimals2": 8,
"price_native": 29.34293421, // aid2 per 1 aid1
"price_usd": 0.99, // USD per 1 aid2 unit
"rate_2_1": 0.03408871, // 1 / price_native
"reserve1": "12000000000000", // groths
"reserve2": "352115210340000",
"reserve1_human": 120000.0, // divided by 10^decimals
"reserve2_human": 3521152.1,
"reserve1_usd": 4092.00,
"reserve2_usd": 3521152.10,
"tvl_usd": 7613152.10,
"volume_24h_groth": "94000000000",
"volume_24h_usd": 3206.84,
"price_change_24h": 4.91, // percent
"buys_24h": 18,
"sells_24h": 22,
"trades_24h": 40,
"is_imposter": false,
"lp_token": 12345, // pools.aid_ctl
"created_at_height": 1234567
}
],
"total": 95,
"last_indexed_height": 3863512
}total is the full match count for the given filters (search/kind/include_imposters), independent of limit/offset, when the sort runs in SQL. On the app-side sort path (see below) it is the size of the loaded super-set after grouping, so it never exceeds 500.
tvl_usd and volume_24h_usd are computed app-side from the USD-per-AID rates (which come from the multi-hop helper, not SQL). When sorted by either, the handler pulls a default-ordered window of up to 500 rows, sorts in JS, and slices to [offset, offset+limit]. total on that path counts the loaded super-set, so it never exceeds 500. Other sort keys go straight to SQL.
This matters because a tiny pool with huge raw-groth reserves but only ~$37 of USD value would otherwise rank above genuinely deep pools.
Cache-Control: public, max-age=15.
Single pair, same fields as a row in /api/pairs.
id is one of:
- A numeric LP-token aid (
/api/pairs/12345) — a single tier. - A canonical tier tuple
<aid1>_<aid2>_<kind>(/api/pairs/0_31_1; legacy-separators also accepted) — a single tier, stable across DB recreations and suitable for bookmarkable URLs. - A combined-pair tuple
<aid1>_<aid2>(/api/pairs/0_31) — returns the grouped row across all fee tiers (summed stats +tiers[], reference-tier price), the same shape as agroup=pairlist entry.
404 (PAIR_NOT_FOUND) when no matching pool exists.
The row also carries ctl_supply (total LP-token supply, groths) and
snapshot_height (the height the reserves/ctl_supply are from) — these power
the liquidity-position analyzer.
Cache-Control: public, max-age=15.
Resolves a single Liquidity Add deposit so the frontend can analyse a
liquidity position (share, fees, P&L, impermanent loss — all computed
client-side from this plus /api/pairs/{id}). Exactly one query param:
height=<n>— looked up inlp_events(Postgres only).kernel=<64 hex>— the one endpoint that touches the explorer: a single/block?kernel=call maps the kernel to its block height, then the samelp_eventslookup runs. We don't index kernel ids, hence the hop.
Returns a deposit object (lp_token, aid1/aid2, symbols, decimals, kind +
fee_pct, amount1/amount2/amount_ctl in groths, height, ts, confirmed).
When a height holds several deposits, returns { "candidates": [ … ] } for the
UI to disambiguate. 404 (DEPOSIT_NOT_FOUND / KERNEL_NOT_FOUND) when nothing
matches.
Cache-Control: public, max-age=30.
Multi-operation lookup powering the liquidity-position analyzer: resolves every add/remove liquidity op across a list of references, so a position with several deposits and partial withdrawals can be valued. One query param:
refs=<…>— up to 50 block heights and/or 64-hex kernel ids, comma/space/ newline separated. Kernel ids are resolved to heights via the explorer (the only explorer touch); refs that don't resolve — or that exceed the 50 cap — come back inunresolved.
Returns { "pools": [ … ], "unresolved": [ … ] }, with ops grouped by pool
(lp_token). Each pool carries its asset/fee metadata, present-time per-unit
BEAM/USD prices (current_beam_per_aid1, current_usd_per_aid1, …), and an
events[] list; each event has kind (Deposit/Withdraw), amount1/2/ctl,
height, ts, confirmed, and the historical BEAM/USD price of each asset
at that op's height (beam_per_aid1, usd_per_aid2, … — null when the pair has
no BEAM route). All P&L / share / partial-withdrawal accounting is computed
client-side from this plus /api/pairs/{id}.
Cache-Control: public, max-age=30.
Chart candles. Accepts any id form from /api/pairs/{id}. For a combined
pair (<aid1>_<aid2>) the price OHLC is taken from the reference (deepest)
tier while volume/trade_count are summed across all tiers over the
reference tier's bucket window. (Buckets where only a thinner tier traded — and
the reference tier did not — are not drawn.) Single-tier ids return that pool's
series unchanged.
Query params:
| Name | Type | Default | Notes |
|---|---|---|---|
interval |
1m | 5m | 15m | 1h | 4h | 1d |
1h |
|
limit |
int, 1..2000 | 500 | |
to |
int (unix seconds) | now |
Returns limit candles strictly older than to. Cursor for scroll-back. |
denom |
native | usd |
native |
If usd, OHLC values are converted using BEAM/USD valid at each candle's bucket. Only meaningful when one side of the pair is BEAM (aid 0). |
Response:
{
"candles": [
{
"time": 1747400940,
"open": 29.341, "high": 29.401, "low": 29.305, "close": 29.380,
"volume": "9400000000", // groths of aid1, string
"trade_count": 7
}
],
"interval": "1h",
"denom": "native",
"more": { "to": 1747314540 } // cursor for the next older page, or null when exhausted
}USD conversion details:
- For a pool with
aid1 = 0 = BEAM: native price is aid2-per-BEAM, so USD-per-aid2 =beamUsd / native_price. The route inverts high/low after conversion since the inversion swaps extremes. - For
aid2 = 0: USD-per-aid1 =native_price * beamUsddirectly. - For pools with no BEAM side:
denom=usdsilently falls back to native — the route can't construct a USD reference without an oracle path. - The handler binary-searches per candle into the oracle history fetched for the candle window; for candles older than oracle history (e.g. backfilled trades pre-dating our deployment), it falls back to the most recent oracle snapshot.
Cache-Control: public, max-age=30.
Recent trades or LP events for one pair. A combined-pair id
(<aid1>_<aid2>) interleaves rows across every fee tier; a single-tier id
returns just that pool's rows.
Query params:
| Name | Type | Default | Notes |
|---|---|---|---|
kind |
Trade | lp |
Trade |
lp returns Deposit + Withdraw events. |
limit |
int, 1..200 | 50 | |
before |
int (unix seconds) | now |
Cursor mode — "load more" pagination. |
before_id |
int > 0 | — | Pairs with before: id of the last row of the previous page (trade_id for kind=Trade, event_id for kind=lp). When both are given the page resumes exactly after that row ((block_ts, id) < (before, before_id)), so rows that share a block timestamp are never skipped. Ignored without before. |
offset |
int ≥ 0 | — | Numbered pagination. When present, overrides before. |
count |
bool | false | Also return total (pool's full row count) for "Showing X to Y of N". |
include_unconfirmed |
bool | true | UI shows unconfirmed with a marker; CG endpoints always exclude. |
In offset mode the response echoes offset and limit, and (when count=true) total; before and before_id are null.
Trade response:
{
"trades": [
{
"trade_id": 81729,
"timestamp": 1747400940,
"height": 3863500,
"aid_in": 0, "aid_out": 31,
"amount_in": "1000000000",
"amount_out": "29342934",
"side": "buy", // computed: aid_in == aid1 → buy
"price_native": 29.34293421,
"price_usd": 0.99, // USD per aid2 unit via the base's USD rate; null without a USD path
"value_usd": 9.93, // volume_aid1 (in whole units) × USD rate of aid1
"confirmed": true,
"confirmations": 80 // truncated to 80 once confirmed
}
],
"before": 1747400940, // oldest timestamp in the page (next page cursor)
"before_id": 48213 // id of the oldest row in the page — pass back with `before`
}LP-event response (kind=lp):
{
"trades": [
{
"event_id": 1027,
"timestamp": 1747400940,
"height": 3863500,
"kind": "Deposit",
"amount1": "10000000000",
"amount2": "352115210000",
"amount_ctl": "1095445115",
"liquidity_pct": 0.18, // signed share of the pool this event added/removed (Withdraw < 0)
"confirmed": true
}
],
"before": 1747400940,
"before_id": 9137
}Cache-Control: public, max-age=15.
DEX-wide trade tape, newest first — the same rows /api/pairs/{id}/trades
serves, but across every pool instead of one. Saves a consumer from fanning out
over ~100 pairs to see what the DEX is doing.
Query params:
| Name | Type | Default | Notes |
|---|---|---|---|
limit |
int, 1..200 | 50 | |
before |
int (unix seconds) | now |
Cursor — "load older". |
before_id |
int > 0 | — | Pairs with before: trade_id of the last row seen. Together they form a keyset cursor ((block_ts, trade_id) < (before, before_id)) that does not skip same-block trades. Ignored without before. |
include_unconfirmed |
bool | true | |
include_imposters |
bool | false | If true, includes pools where either side has is_imposter = TRUE. |
kind |
0 | 1 | 2 |
— | Filter to one volatility tier. |
aid |
int ≥ 0 | — | Only trades in pools that have this asset on either side. |
There is no offset / count mode here. With no pool filter the head of the
feed moves as the indexer ticks, so a numbered offset would silently skip or
repeat rows between pages; cursor paging on before is the only stable option.
Destroyed pools are always excluded.
{
"trades": [
{
"trade_id": 81729,
"pool_id": 17, "pair_id": 17,
"aid1": 0, "aid2": 31,
"symbol1": "BEAM", "symbol2": "USDT",
"kind": 1, "kind_label": "Medium",
"timestamp": 1747400940,
"height": 3863500,
"aid_in": 0, "aid_out": 31,
"amount_in": "1000000000",
"amount_out": "29342934",
"side": "buy",
"price_native": 29.342934,
"price_usd": 0.00026,
"value_usd": 0.0764,
"confirmed": true,
"confirmations": 80
}
],
"before": 1747399118,
"before_id": 48213,
"limit": 50
}before in the response is the oldest returned trade's timestamp — feed it back
as the before param to page further. null when the page came back empty.
before_id is that trade's trade_id; pass both back together. Paging on
before alone still works but can drop the remaining trades of the block the
previous page ended in.
As on the per-pair route, price_usd and value_usd are priced off the shared
USD table: pools that aren't BEAM-quoted carry USD figures as
long as their base asset is reachable through some BEAM-quoted pool. Both stay
null when the base has no USD path.
Cache-Control: public, max-age=15.
Pooled-amount time series for one pool, decomposed by the source of the reserve changes. Drives the trade page's Pool History chart (two series: pooled aid1 + pooled aid2). A combined-pair id (<aid1>_<aid2>) sums the series across every fee tier per bucket (so it matches the grouped tvl_usd / pooled totals); a single-tier id returns that pool's series.
Query params:
| Name | Type | Default | Notes |
|---|---|---|---|
source |
total | lp | trades |
total |
total = actual pooled reserves; lp = cumulative net deposits; trades = cumulative reserve change from swaps. total ≈ lp + trades. |
interval |
1h | 1d |
1d |
Bucket width. |
from |
int (unix seconds) | — | Trim returned buckets (cumulative series stay correct at the left edge). |
to |
int (unix seconds) | — |
Response (amount* are groths of aid1/aid2; divide by 10^decimalsN):
{
"series": [
{ "ts": 1700000000, "amount1": "545527910000000", "amount2": "82055439000000" }
],
"decimals1": 8,
"decimals2": 8
}Cache-Control: public, max-age=30.
Catalog of every asset known to the backend. Wholesale (no pagination) — there are ~200 assets on mainnet today; small enough to send in one shot.
{
"assets": [
{
"aid": 0,
"name": "Beam", "short_name": "BEAM", "unit_name": "BEAM",
"description": "Native BEAM asset",
"decimals": 8,
"is_imposter": false, "imposter_reason": null,
"emission": "26279999976873600", // for aid 0: BEAM current circulation
"minted_at_height": null, // on-chain registration height
"minted_at_ts": null, // epoch seconds for minted_at_height
"minter_cid": null,
"max_supply": "26280000000000000", // for aid 0: BEAM total circulation
"pool_count": 47
}
]
}Cache-Control: public, max-age=30.
Single asset metadata + every active pool it participates in.
{
"aid": 31,
"name": "Tether USD",
"short_name": "USDT",
"unit_name": "USDT",
"description": "Wrapped USDT bridged from Ethereum",
"decimals": 8,
"is_imposter": false,
"emission": "1000000000000000",
"minted_at_height": 1234567, // on-chain registration height
"minter_cid": "295fe749…d868", // null if not minter-issued
"max_supply": "5000000000000000", // null if uncapped
"pools": [
{ "pair_id": 17, "aid1": 0, "aid2": 31, "kind": 1, "tvl_usd": 7613.10, "amount": "31946584614" }
]
}The pools.tvl_usd field is best-effort: it requires BEAM to be one of the sides (so the USD reference is reachable in one hop). Otherwise null. pools.amount is this asset's reserve in the pool — groths as a decimal string, null until the pool has a state snapshot.
400 (BAD_REQUEST) if aid isn't a non-negative integer; 404 (ASSET_NOT_FOUND) if no row exists.
Cache-Control: public, max-age=30.
Mint / burn / create / destroy events for an asset. Pass-through over the explorer's /asset?id=<aid>, lightly parsed and cached.
Query params:
| Name | Type | Default | Notes |
|---|---|---|---|
limit |
int, 1..500 | 100 |
Response:
{
"aid": 31,
"history": [
{
"height": 3862500,
"event": "Mint",
"amount": "500000000",
"total_amount": "1000500000000",
"extra": ""
}
],
"cached": false
}- 400 if
aid == 0(BEAM) — there's no/asset?id=0endpoint on the explorer; the asset detail page reads BEAM supply from/api/asset/0instead. - In-process LRU cache, 5-minute TTL. Cache key includes
limitso different page sizes don't collide.
Cache-Control: public, max-age=300.
How much of an asset each contract currently has locked, plus the unlocked remainder. Live state, no history.
No query params.
Response:
{
"aid": 7,
"entries": [
{
"cid": "b8944fd3f6a62697a89b2a55acd1cb2e3893dadece99569706efa1da847dd440",
"kind": "Nephrite v1",
"amount": "14658636293475"
}
],
"unlocked": "173224572110680",
"total": "187883208404155"
}entries— one row per contract holding a non-zero balance of the asset, sorted by amount descending foraid == 0(explorer order otherwise).kindis the parser's human-readable shader name ("DEX v0", "Nephrite v1", …), or the shader hash hex when the parser doesn't recognise the contract. Amounts are groths as decimal strings.unlocked— supply not held by any contract;total—unlockedplus the sum ofentries.aid > 0is a pass-through over the explorer's/asset?id=<aid>"Asset distribution" table.aid == 0(BEAM) is synthesized instead — the explorer rejects/asset?id=0(BEAM has no asset-registry row), so the backend collects the aid-0 entry of every contract's Locked Funds table from/contractsand anchorsunlockedto the circulating supply it tracks (emissionon/api/asset/0). If the supply figure is momentarily unavailable,unlockeddegrades to"0"andtotalcovers only the locked sum.
Cache-Control: public, max-age=30.
Canonical snapshot of current network health — hashrate, difficulty, average block time, and the chain tip — plus the last 60 blocks. Everything derives from a single read of block_metrics, and this is the single source of truth for the network_hashrate reported by /api/mining/pools and shown on the Health page.
No query params.
{
"hashrate": 78528.17,
"difficulty": 5141867.25,
"avg_block_time": 65.58,
"tip_height": 3935205,
"recent": [
{
"height": 3935146,
"ts": 1783283812,
"difficulty": 5001484.5,
"kernels": 2
}
]
}hashrate— network-wide hashrate in Sol/s (BeamHash III), the time-weighted averageΣ difficulty / Δtacross the window;nulluntil at least two blocks are indexed.difficulty— difficulty of the latest block;nullifblock_metricsis empty.avg_block_time— mean seconds between blocks,Δt / (N − 1)across the window;nulluntil at least two blocks are indexed.tip_height— height of the current chain tip;nullifblock_metricsis empty.recent— the last 60 blocks (fewer if the chain is shorter), ordered oldest → newest. Each entry:height— block height.ts— block timestamp in unix seconds (note:/api/mining/blocksreportstsas an ISO 8601 string; this endpoint uses epoch seconds).difficulty— that block's difficulty.kernels— number of kernels in the block.
The window is the last 60 blocks (≈ 1h at the 60s block target), matching the /api/charts/hashrate convention.
Cache-Control: public, max-age=30.
Current snapshot of every tracked mining pool plus the live network hashrate.
No query params.
{
"network_hashrate": 1234567.89,
"block_height": 3863512,
"blocks_24h_total": 1438,
"pools": [
{
"id": "herominers",
"name": "HeroMiners",
"website": "https://beam.herominers.com",
"payout_scheme": "PPLNS",
"hashrate": 987654.32,
"miners": 120,
"workers": 145,
"blocks_24h": 38,
"last_block_height": 3863400,
"last_block_ts": "2025-05-16T10:22:00.000Z",
"fee": 1.0,
"min_payout": 1.0,
"updated_at": "2025-05-16T10:30:00.000Z",
"hashrate_series": [950000, 970000, 987654],
"blocks_past_hour": 2,
"blocks_past_24h": 41
}
]
}network_hashrate— network-wide hashrate in Sol/s, derived asΣ difficulty / Δtacross the last 60 blocks;nullifblock_metricsis empty.block_height— height of the current network tip;nullifblock_metricsis empty.blocks_24h_total— total network blocks in the past 24h (the distribution donut's denominator; theUnknownslice is this minus the attributed sum).0ifblock_metricsis empty.- Per-pool fields:
hashrate— pool hashrate in Sol/s as self-reported by the pool's API;nullif the pool is unreachable.miners,workers,blocks_24h,last_block_height,last_block_ts,fee,min_payout,updated_at— allnullwhen the pool is offline or has never been polled successfully.fee— pool fee in percent (e.g.1.0= 1%).min_payout— minimum payout in BEAM.hashrate_series— last ≤ 30 non-null hashrate snapshots (Sol/s), ordered oldest → newest. Empty array if no data yet.blocks_past_hour— network blocks in the past hour attributed to this pool.0if none.blocks_past_24h— network blocks in the past 24h attributed to this pool.0if none.
Pool stats are scraped from each pool's own API on every new block; the freshness of each entry reflects when the poll last succeeded.
Cache-Control: public, max-age=60.
Recent blocks with best-effort pool attribution, most-recent first.
Query params:
| Name | Type | Default | Notes |
|---|---|---|---|
limit |
int, 1..200 | 50 | |
offset |
int ≥ 0 | 0 | Numbered pagination. |
{
"blocks": [
{
"height": 3863512,
"ts": "2025-05-16T10:30:00.000Z",
"mined_by": "HeroMiners"
}
]
}ts— ISO 8601 timestamp of the block (block_metrics.block_ts).mined_by— pool display name resolved from the pool's own self-reported found-blocks feed;null= unattributed. BEAM blocks carry no on-chain pool tag, so attribution is best-effort only.
Cache-Control: public, max-age=30.
Historical chart data for a named series. All series share the same response shape:
{
"series": [
{ "ts": 1700000000, "value": 1234567.89 }
]
}ts is unix seconds (integer). value is a JSON number. Points are ascending by ts. Null values are omitted. Each series is served from an in-process server-side cache (pre-warmed at boot, refreshed every 30 minutes), so the response is fast even for series that require full-history Postgres aggregates.
Responses support ETag / If-None-Match conditional requests; a 304 is returned when the cached series has not changed.
All optional. With none, the endpoint returns the full daily history — the original shape, unchanged.
| Name | Type | Default | Notes |
|---|---|---|---|
res |
1m | 1h | 1d | 1M |
1d |
Resolution. 1m and 1M apply only in range mode (below); in default mode any unrecognized value — including 1m and 1M — serves 1d. 1M buckets by whole calendar month and is only offered by series whose ladder includes it (currently the bridge-* series). |
from |
unix seconds | — | Start of a zoom window. Must be sent together with to. Clamped to >= 0. |
to |
unix seconds | — | End of a zoom window. Must be greater than from after clamping. Clamped to <= now + one bucket of res. |
Default mode — no from/to. Returns the full-history series at daily (res=1d) or hourly (res=1h) resolution. Hourly exists for every series except beam-vol, dex-vol, blackhole, pools-created, pools-closed, and the bridge-* series; requesting res=1h on those silently serves 1d. Both resolutions come from the in-process cache described above, so Cache-Control is the per-series max-age in the table below. The response is the flat shape shown above (single-series) for every series in default mode, blackhole excepted (see its row below).
Range ("zoom") mode — from and to both present. Returns only the points inside [from, to), served from a separate tile-quantized, bounded cache, at res=1m|1h|1d|1M (default 1d). Supported for every series except beam-vol, dex-vol, blackhole, pools-created, and pools-closed, which are daily-only and return an empty series here. Bad bounds (non-numeric, or to ≤ from once clamped) yield 400 {"error":"bad from/to"}. Cache-Control: public, max-age=86400 once the whole window has settled (to older than the ~80-minute / 80-block confirmation horizon), otherwise max-age=60.
Window width cap. The window is served in tiles of 256 buckets. A window wider than 2 000 buckets plus two edge tiles (10 tiles: ~1.8 days at 1m, ~107 days at 1h) is served at the next coarser resolution on the series' ladder instead of the one requested, repeatedly until it fits; the coarsest rung (1d) is served at any width, bounded only by the from/to clamps above. Whatever is served is reported back in the body's res field, so a client that needs the finer resolution should narrow the window rather than assume the request was honored. A window that would still exceed 128 tiles at the coarsest rung yields 400 {"error":"range too wide for resolution"}.
In range mode the body carries an explicit kind discriminant instead of always being the flat shape — the shape is a fixed property of the series name, never of the query params, so a client never has to sniff the body to know which one it got — and a res field naming the resolution actually served (the requested one, or a coarser one per the cap above / the series' ladder):
{ "kind": "single", "res": "1h", "series": [ { "ts": 1700000000, "value": 1234567.89 } ] }{
"kind": "multi",
"res": "1d",
"series": [
{ "key": "beam2eth", "label": "Beam → Ethereum", "points": [ { "ts": 1700000000, "value": 12 } ] }
]
}kind: "multi" is used by the three bridge-*-by-* series (split per direction / bridge / asset); every other series is kind: "single". Default mode never carries kind — it always returns the flat shape from the top of this section, except blackhole, whose default-mode body is documented in its row below.
Available series (the full set is defined in backend/src/api/routes/charts.ts):
| Series name | Unit | max-age |
Source |
|---|---|---|---|
hashrate |
Sol/s | 600 s | Postgres: Σ difficulty / Δt per day from block_metrics |
difficulty |
raw difficulty | 600 s | Postgres: daily average from block_metrics |
block-time |
seconds | 600 s | Postgres: daily average block interval from block_metrics |
tvl |
USD | 1800 s | Postgres: end-of-day pooled reserves × BEAM/USD per day |
price |
BEAM/USD | 600 s | Postgres: daily closing price from oracle_snapshots |
market-cap |
USD | 600 s | Postgres + services/beamEmission.ts: daily closing price × circulating supply at that day's block height |
coinbase |
count/day | 600 s | Postgres: blocks per day (one coinbase output per block) |
assets |
cumulative count | 600 s | Postgres: cumulative registered confidential assets |
dex-volume |
USD | 1800 s | Postgres: daily DEX volume in USD |
beam-vol |
percent (annualized) | 1800 s | Postgres: 30-day rolling realized volatility of BEAM/USD |
dex-vol |
percent (annualized) | 1800 s | Postgres: TVL-weighted per-pool 30-day rolling realized volatility |
pools-created |
cumulative count | 1800 s | Postgres: cumulative pools created over time, from pools.created_at_height |
pools-closed |
cumulative count | 1800 s | Postgres: cumulative pools destroyed over time, from pools.destroyed_at_height |
transactions-daily |
count/day | 600 s | Explorer /hdrs |
transactions-total |
cumulative count | 600 s | Explorer /hdrs |
txos-total |
cumulative count | 600 s | Explorer /hdrs |
utxos-total |
cumulative count | 600 s | Explorer /hdrs |
size-total |
bytes | 600 s | Explorer /hdrs |
archive-total |
bytes | 600 s | Explorer /hdrs |
shielded-ins-daily |
count/day | 600 s | Explorer /hdrs |
shielded-ins-total |
cumulative count | 600 s | Explorer /hdrs |
shielded-outs-daily |
count/day | 600 s | Explorer /hdrs |
shielded-outs-total |
cumulative count | 600 s | Explorer /hdrs |
contracts-total |
cumulative count | 600 s | Explorer /hdrs |
fees-daily |
groth/day | 600 s | Explorer /hdrs |
fees-total |
cumulative groth | 600 s | Explorer /hdrs |
contract-calls-daily |
count/day | 600 s | Explorer /hdrs |
contract-calls-total |
cumulative count | 600 s | Explorer /hdrs |
blackhole |
— | 1800 s | Multi-series: series is not a flat SeriesPoint[] but an array of per-asset cumulative-lock series (one line per asset locked in the BlackHole contract). |
bridge-transfers |
count/day | 300 s | bridge_messages: transfer count per bucket, all bridges and directions combined |
bridge-transfers-by-direction |
count/day | 300 s | bridge_messages: transfer count per bucket, split by direction (kind: "multi" in range mode) |
bridge-transfers-by-bridge |
count/day | 300 s | bridge_messages: transfer count per bucket, split by bridge (kind: "multi" in range mode) |
bridge-transfers-total |
cumulative count | 300 s | bridge_messages: running total of transfers |
bridge-fees |
USD/day | 300 s | bridge_messages: relayer fees per bucket, priced at the bucket's cross-rate, all bridges and directions combined |
bridge-fees-total |
cumulative USD | 300 s | bridge_messages: running total of relayer fees |
bridge-tvl |
USD | 300 s | Reconstructed bridge collateral value locked, priced at each bucket's cross-rate |
bridge-tvl-by-asset |
native units | 300 s | Reconstructed bridge collateral locked, split per asset (kind: "multi" in range mode); values are already scaled to display magnitude, not raw on-chain amounts |
In default mode, Cache-Control is the per-series max-age listed above; range mode is scoped under Query params above.
Full BANS registry action history (oldest→newest), from contract_call_events.
{
"actions": [
{ "height": 1896200, "block_ts": "2021-05-01T12:00:00.000Z", "method": "Register", "name": "syntaxjak", "args": { "Periods": 3, "name": "syntaxjak" } }
],
"meta": { "total": 999, "first_height": 1896200, "last_height": 3922383 }
}Cache-Control: public, max-age=60. Returns empty actions when BANS_CID is unset.
Liveness and peg backing for the six Beam↔EVM Pipe bridges — four b-asset bridges and BEAM/WBEAM on both Ethereum mainnet and Arbitrum One.
{
"bridges": [
{
"bridge": "busdt", "label": "bUSDT", "chain_id": 1, "aid": 37,
"eth_pipe": "0x7c3fe09e86b0d8661d261a49bfa385536b7077f9",
"eth_token": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"asset_symbol": "bUSDT",
"outgoing": { "pending": 99, "relayed": 0, "failed": 0, "unknown": 0, "unsettleable": 0, "skipped": 0, "total": 99 },
"incoming": { "not_delivered": 2, "unclaimed": 2, "complete": 136, "unknown": 0, "total": 140 },
"oldest_open_ts": "2022-06-14T09:12:00.000Z",
"last_message_ts": "2026-08-08T13:56:26.000Z",
"unclaimed_amount": 41.5,
"escrow": { "locked": 3701.4022, "decimals": 6, "observed_at": "2026-08-08T23:40:00.000Z" },
"minted": 3688.28,
"over_collateral": 0,
"collateral_ratio": 0.9964,
"settlement_source": "etherscan"
}
],
"tvl_usd": 1234567.89,
"tvl_priced": 6,
"settlement_available": true
}tvl_usd is the USD value of all locked collateral, priced off each bridge's
Beam-side asset (the wrapped asset tracks its collateral 1:1). Bridges with no
BEAM-quoted pool are excluded rather than counted as zero; tvl_priced says how
many contributed.
Status vocabularies differ by direction, because the underlying contract states do:
beam2eth—pending(no settling Ethereum tx seen),relayed,failed,unsettleable,skipped,unknown.eth2beam—not_delivered(the relayer never pushed it to Beam),unclaimed(delivered; the recipient hasn't signedReceiveFunds),complete,unknown.
unclaimed is not an error: only the recipient can claim, so a message can sit
there indefinitely. Don't count it as a bridge failure.
unsettleable and skipped are derived, not stored. Both refine pending,
which otherwise reads as "on its way" for messages that will never move — the
relayer gives up after three attempts rather than retrying indefinitely, so
nothing is coming back for them:
unsettleable— the amount underflowed, making it larger than everything the bridge holds. There is nothing to release against it. Provable from the row.skipped— a later message on the same bridge has already settled, so the relayer moved past this one. This catches stalls we cannot diagnose from our side (a fee under the relayer's minimum, for instance) without needing to know the reason.
Both are terminal, so neither counts toward oldest_open_ts, and both accept
?status= like any stored value.
over_collateral counts open beam2eth messages whose amount exceeds the
collateral the bridge holds. Such a message cannot settle — there is nothing to
release against it — so a non-zero count means either wrapped arithmetic or a
deliberate attempt. It is worth surfacing because the Beam-side relay applies no
amount check of its own: unlike the Ethereum-side relay, which rejects an
overflowing amount + relayerFee outright, it forwards whatever the Pipe
recorded provided the fee clears its minimum. null means the escrow snapshot is
missing and nothing could be compared; 0 means checked and clear.
collateral_ratio is minted supply ÷ locked collateral. ≤1.0 means every wrapped
unit is backed; small deviations are in-flight messages and relayer fees, and a
ratio above 1 means more has been issued than is held.
Custody direction differs per bridge. The b-asset bridges lock collateral on
Ethereum and mint on Beam, so escrow.locked is the Ethereum Pipe's balance and
minted is the Beam asset's emission. BEAM/WBEAM is the reverse: native BEAM is
locked in the Beam Pipe and WBEAM is minted on Ethereum, so escrow.locked is
the Beam-side locked amount and minted is the WBEAM ERC20 total supply.
settlement_available is false when no ETHERSCAN_API_KEY is configured. In that
state beam2eth messages stay pending and no failure can be observed —
"failed": 0 then means "unverifiable", not "none". Cache-Control: public, max-age=30.
Point lookup for a single transfer — "I sent this, where is it?". Takes either
side's identifier via q: an Ethereum/Arbitrum transaction hash, or a Beam
kernel ID.
{
"query": "0x9a51…", "kind": "evm_tx", "resolved_height": null,
"matches": [
{
"bridge": "beam-wbeam", "label": "BEAM / WBEAM", "direction": "eth2beam",
"msg_id": 219, "status": "unclaimed", "amount": 605.47244962,
"role": "origin",
"explanation": "Delivered to Beam and waiting for you to claim it. …"
}
]
}kind is evm_tx, beam_kernel, beam_height, or unrecognised. A bare
number is treated as a Beam block height — the only reference an outgoing
message has, since the Pipe records no kernel ID per message, so the height shown
in the transfers table can be pasted straight in. Both identifiers are 32
bytes so the input alone can't distinguish them: the EVM side is tried first,
then the kernel is resolved to a Beam height (a height can carry several
messages, so all are returned).
A Beam height — supplied directly or resolved from a kernel — matches any of the
four ways a block can belong to a transfer: an outgoing message's src_height or
src_call_height, or an incoming one's delivered_height or claimed_height.
role says which end matched: origin for the side the transfer started on,
settlement for the side it landed on.
explanation is plain-language guidance for the current state — notably, a
pending Beam→Ethereum transfer or a not_delivered message usually means the
relayer is waiting for Ethereum gas to come down, not that anything is lost.
Cache-Control: public, max-age=10 — short, because a pending transfer's state
is exactly what the caller is watching.
Individual bridge transfers, newest first.
Query params: bridge, direction (beam2eth|eth2beam), status,
sort, dir, limit (default 50, max 500), offset.
sort is one of age (default), amount, fee, msg_id, bridge, status,
direction; dir is desc (default) or asc. Unknown values fall back to the
defaults rather than erroring. Note amount sorts on the raw stored value, which
is denominated in each side's own units — meaningful within one bridge, less so
across bridges with different decimals.
{
"messages": [
{
"bridge": "beam-wbeam", "direction": "beam2eth", "msg_id": 496,
"status": "pending", "amount": 15000.0, "relayer_fee": 95.42684516,
"receiver": "24c32736a24f2ccc95a3249b17ea5c23222df71c",
"src_height": 3981297, "src_call_height": 3981298, "src_block": null,
"src_ts": "2026-08-07T01:03:30.000Z", "src_tx": null,
"settle_tx": null, "settle_block": null, "settle_ts": null,
"delivered_height": null, "delivered_ts": null,
"claimed_height": null, "claimed_ts": null,
"malformed": null
}
],
"total": 1283, "limit": 50, "offset": 0
}src_height is the chain tip the Pipe contract recorded when the transaction was
built; src_call_height is the block that actually contains the call, resolved
against the contract's call history. Use src_call_height for explorer links
— looking up src_height shows no contract activity, because the call landed a
block later.
delivered_height / claimed_height are the Beam blocks in which an incoming
(eth2beam) transfer was relayed to Beam and then claimed by its recipient, with
delivered_ts / claimed_ts the matching block times. They are always null for
beam2eth, and also null on the BEAM/WBEAM Pipes: those are upgradable2-wrapped,
so the explorer reports their calls as Passthrough with the arguments stripped
and there is no message id to key on.
amount and relayer_fee are scaled to the side the message was observed on:
Beam-side decimals for beam2eth, Ethereum-side for eth2beam. These differ —
bUSDT is 8 decimals on Beam against USDT's 6 on Ethereum. receiver is a 20-byte
Ethereum address for beam2eth and a 33-byte Beam pubkey for eth2beam, hex, no
0x.
malformed names the reason a message's figures are not a real transfer, and is
null for ordinary ones. Two shapes occur, and neither should be rendered as a
number — both read as an enormous genuine transfer:
"overflow"—amount + relayerFeewrapped past uint256 in the Ethereum Pipe, which computes that sum with unchecked arithmetic (solidity ^0.7.2). The wrap drops the total the sender actually pays to almost nothing while the emitted amount stays enormous, so these are attempts on the bridge, not transfers; the relayer tests the same predicate and refuses them (bDAI 43–48, bUSDT 128/136, bWBTC 22). The stored figures are reported unchanged — there is no meaningful number to show."underflow"— a Beam-side amount that wrapped. Beam amounts are uint64 and the fee is subtracted from the transferred amount without a check, so a fee larger than the amount stores the wrapped difference. Hereamountis reported unwrapped, as a negative number: how far the fee overshot. Nothing of value crossed.
The message is still listed either way — "the bridge accepted this" is exactly
what the endpoint exists to show. Cache-Control: public, max-age=30.
Global omnibox. One query string fans out across Postgres (assets, pools, dapps, publishers), two static catalogs (charts, app pages) and — only when the query's shape warrants it — the explorer.
Exactly one param: q (1–128 chars). 400 (BAD_QUERY) otherwise.
Explorer lookups are shape-gated and each bounded to 700 ms: an all-digits query tries a block height, 64 hex chars tries both a kernel and a contract id, and a bare name tries the BANS registry. A query matching none of those shapes never touches the explorer at all, which is what keeps the common case fast.
Contracts also resolve by name, from a static catalog of the CIDs in config
— DEX, Oracle, DAO Vault, DAO Vote, DApp Store, BANS, Asset Minter, Black Hole.
The explorer already answers CID → name (searching 729fe098… returns
"DEX v0"); this is the way in from the other side, so q=dex returns the DEX
contract alongside the DEX page. Matched synchronously with no I/O, in the same
contract group as a CID hit, so the response shape is unchanged. Contracts
whose CID is not configured are omitted. Deliberately not every contract the
explorer has seen: those names come from a third-party parser and would need
the imposter handling assets get.
{
"query": "beam",
"groups": [
{
"type": "asset",
"label": "Assets",
"items": [
{ "type": "asset", "id": "0", "title": "Beam (BEAM)", "subtitle": "AID 0",
"href": "/asset/0", "score": 90, "flags": [], "color": null, "logoUrl": null }
]
}
],
"sources": { "db": "ok", "explorer": "ok" }
}Item fields beyond type/id/title/subtitle/href/score/flags are
per-type extras the UI uses for rendering (assets carry color and logoUrl,
for instance); treat them as optional.
Groups come back in a fixed display order — pages, assets, pools, dapps,
publishers, blocks, kernels, contracts, BANS domains, charts, IPFS — with empty
ones omitted and items sorted by descending score.
sources tells the UI why a group may be missing rather than making it guess:
db is ok | error, explorer is ok | error | timeout | skipped. A partial
result is still a 200 — one slow explorer never fails the whole search.
Cache-Control: public, max-age=15.
Landing summary for the DAO explorer: treasury value and asset count, all-time revenue, and the current governance epoch, plus a short merged activity feed.
{
"treasury": { "value_usd": 9408.74, "assets": 38 },
"revenue": { "all_time_usd": 9408.68 },
"governance": { "current_epoch": 107, "active_proposals": 0, "turnout_pct": null },
"recent": [
{ "kind": "flow", "height": 4004442, "ts": "2026-08-23T03:30:06.000Z",
"method": "DEX", "funds": { "47": "+297030" } }
]
}turnout_pct is null when the current epoch has no closed vote to measure.
Cache-Control: public, max-age=60.
DaoVault holdings, the flows that produced them, and the USD value series.
{
"total_usd": 9408.74,
"holdings": [
{ "aid": 47, "symbol": "Nph", "amount": "470947472099", "value_usd": 5223.38, "pct": 55.52 }
],
"flows": [
{ "height": 4004442, "ts": "2026-08-23T03:30:06.000Z", "method": "DEX",
"funds": { "47": "+297030" } }
],
"value_series": [ { "day": "2022-08-16", "usd": 276.67 } ]
}funds is keyed by AID with signed groth strings (+ in, - out) — one flow
can move several assets at once. amount is groths; pct is the holding's
share of total_usd. Assets with no USD path contribute null value and are
excluded from the total rather than counted as zero.
Cache-Control: public, max-age=60.
Per-asset drill-down: lifetime in/out totals plus the individual flows.
| Name | Type | Default | Notes |
|---|---|---|---|
limit |
int, 1..500 | 100 | Most recent flows first. |
{
"aid": 47,
"deposits_groth": "470947472099",
"withdrawals_groth": "0",
"rows": [
{ "height": 4003503, "ts": "2026-08-22T11:51:40.000Z", "method": "DEX", "amount": "+297030" }
]
}400 (BAD_REQUEST) for a non-integer or negative aid.
Cache-Control: public, max-age=60.
Protocol revenue accrued to the DAO, sliced four ways.
{
"total_usd": 9408.68,
"series": [ { "day": "2022-08-16", "by_asset": { "47": 12.4 } } ],
"by_tier": [ { "tier": 0, "usd": 0.57 } ],
"by_source": [ { "source": "Nephrite", "usd": 4759.59, "pct": 50.59 } ],
"top_pools": [ { "pool_id": 30, "pair": "BEAM/Nph", "tier": 2, "usd": 1181.11 } ]
}Revenue is valued at the point in time it accrued, not at today's price — the
same convention as total_volume_usd on /api/stats.
Cache-Control: public, max-age=300 (the heaviest DAO query; it aggregates the
full history).
DaoVote state: current epoch, staked BeamX, and every proposal with its tally.
{
"current_epoch": 107,
"total_staked": "363984601011879",
"kpis": { "active_proposals": 0, "turnout_pct": null,
"voting_power": "363984601011879", "voters": 0 },
"voting_power_series": [ { "day": "2022-07-04", "staked": 14111.1 } ],
"epochs": [],
"proposals": [
{ "id": 4, "epoch": 93, "title": "…", "status": "closed", "outcome": "passed",
"variant_count": 2, "quorum_pct": 50, "yes_needed": "181992300505940",
"turnout_pct": 58.3, "voted_groth": "265555615982351", "tallies": [] }
]
}Groth strings are BeamX stake weight, not vote counts — voting power is stake.
status is open | closed; outcome is null while a proposal is open.
Cache-Control: public, max-age=60.
One proposal in full, including its description (markdown) and a paginated voter list.
| Name | Type | Default | Notes |
|---|---|---|---|
offset |
int ≥ 0 | 0 | |
limit |
int, 1..200 | 25 |
{
"proposal": { "id": 4, "epoch": 93, "title": "…", "description": "…",
"forum_link": "…", "status": "closed", "outcome": "passed",
"variant_count": 2, "quorum_pct": 50, "yes_needed": "…",
"turnout_pct": 58.3, "voted_groth": "…", "tallies": [] },
"votes": {
"total": 4,
"rows": [
{ "voter": "2f79…5301", "variant": 1, "label": "Yes",
"weight": "57756791178674", "height": 3920348 }
]
}
}The proposal object carries the same fields as a /api/dao/governance entry
plus description (markdown) and forum_link. votes.total is the full voter
count, unaffected by offset/limit, so a client can page without a second
request. weight is the voter's BeamX stake in groths.
400 (BAD_REQUEST) for a non-integer id, 404 (NOT_FOUND) when no such
proposal exists. Cache-Control: public, max-age=60.
Current state of the Oracle2 price feed (ORACLE_CID), projected by
services/oracle2.ts from the oracle2_app.wasm app shader.
{
"cid": "4f160f01dcc6751e61d793279b803328d5332125fe8492e93ee8f3bfe9abe13b",
"kind": "Oracle2 v0",
"height": 4024924,
"refreshed_at": "2026-09-06T09:41:12.004Z",
"h_validity": 220,
"min_providers": 3,
"median": null,
"median_h_end": 0,
"median_valid": false,
"valid_providers": 2,
"quorum": false,
"providers": [
{ "index": 2, "pk": "69a12cff…212a7601", "value": "0.010839069",
"h_updated": 4024911, "age": 13, "stale": false }
]
}height is the chain tip the snapshot was taken at; age and stale are
measured against it, an entry being stale once age > h_validity. median is
the median stored on-chain, which the contract recomputes only when a
provider writes to it — null (with median_h_end: 0) means no median has
been written since the last settings change, and median_valid says whether
the stored one still covers height. quorum is
valid_providers >= min_providers. Values are decimal strings in USD.
503 (UNAVAILABLE) until the indexer has written its first snapshot — which
needs WALLET_API_URL, as the shader runs inside the wallet daemon.
Cache-Control: public, max-age=30.
The DApp Store registry, projected from on-chain calls by
services/dappStore.ts.
| Name | Type | Default | Notes |
|---|---|---|---|
include_deleted |
bool (1/true) |
false | Include dapps with deleted_at set. |
Returns { "dapps": [ … ] }, newest-updated first, capped at 500. Each entry:
id, publisher { pubkey, name }, name, description, category, icon,
ipfs_id, api_version, min_api_version, version (+ version_parts),
first_seen_height / first_seen_at, last_updated_height / last_updated_at,
deleted_at.
The height/timestamp fields are nullable: a dapp published under a multi-dapp
publisher has no unambiguous on-chain attribution, so we return null rather
than guessing. version is composed from version_parts when the projector
didn't record a version string.
Cache-Control: public, max-age=60.
One dapp plus its full version history.
{
"dapp": { "id": "…", "name": "BeamTerminal", "…": "…" },
"versions": [
{ "version": "1.0.0.0", "ipfs_hash": "…", "height": 3980112,
"block_ts": "2026-07-01T10:00:00.000Z", "action": 0 }
]
}versions is oldest-first. action is the raw registry opcode from the
contract call that produced the row. 404 (DAPP_NOT_FOUND) when unknown.
Cache-Control: public, max-age=60.
Known publishers, most-prolific first, capped at 500.
Each entry: pubkey, name, short_title, about_me, website,
social { twitter, linkedin, instagram, telegram, discord }, the same nullable
first_seen_* / last_updated_* pairs as /api/dapps, and dapps_count
(non-deleted dapps only).
Cache-Control: public, max-age=60.
Raw DApp Store contract calls, newest first — the projection's source of truth. While the projector is still maturing, read this to see what the registry actually contains before trusting a projected row.
| Name | Type | Default | Notes |
|---|---|---|---|
limit |
int, 1..1000 | 200 | |
action |
int | — | Filter to one registry opcode. |
Returns { "calls": [ { "kernel_id", "call_index", "height", "block_ts", "action", "args", "confirmed" } ] }. args is the explorer's decoded argument
object, passed through unchanged — its shape follows the parser, not us.
Cache-Control: public, max-age=30.
Streams a .dapp bundle out of BEAM's private mainnet IPFS swarm
(wallet-api → asio-ipfs → bitswap). This is what the site's Download button
points at.
| Name | Type | Default | Notes |
|---|---|---|---|
filename |
string | <cid>.dapp |
Sanitized, then sent as Content-Disposition: attachment. |
Open gateway by design — any CID streams, with no allowlist check against the
dapps table.
Errors: 400 (BAD_CID) when the path segment doesn't look like a CID; 503
(IPFS_UNAVAILABLE) when wallet-api has no IPFS configured; 503
(IPFS_CONTENT_UNAVAILABLE) when no swarm peer serves the CID. The last one is
deliberately 503 rather than 504 — Cloudflare rewrites origin 502/504 bodies,
and the Download button needs the JSON error to explain itself. The IPFS fetch
is bounded at 60 s, under Cloudflare's 100 s origin-response limit.
General-purpose read-only IPFS gateway over the same transport. Served at the
site root without the /api prefix, matching the path shape of every other IPFS
gateway, so a client can swap ipfs.io / dweb.link for this host unchanged.
| Name | Type | Default | Notes |
|---|---|---|---|
download |
bool (1/true) |
false | Force Content-Disposition: attachment. |
filename |
string | <cid> |
Sanitized before use. |
Content-Type is sniffed from the payload. Types that could script in our origin
(text/html, image/svg+xml, XML, application/wasm) are always coerced to
application/octet-stream and forced to attachment regardless of download, so
the browser saves them instead of rendering them. Responses also carry
X-Content-Type-Options: nosniff, X-Frame-Options: DENY and
Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors 'none'.
Same error codes as /api/dapp/{cid}. CIDs are immutable, so
Cache-Control: public, max-age=31536000, immutable.
Wallet-gossiped asset-to-asset swap offers, mirrored from the wallet-api by
services/assetSwapOffers.ts. These are peer-to-peer offers, not AMM pools —
the explorer cannot serve them.
| Name | Type | Default | Notes |
|---|---|---|---|
include |
closed | all |
— | Also return offers we've marked gone. Changes sort to last_seen_at DESC. |
send |
int (AID) | — | Filter by the asset being sent. |
receive |
int (AID) | — | Filter by the asset being received. |
Default is open offers only (gone_at IS NULL AND expire_time > now()), soonest
to expire first, capped at 500.
{
"offers": [
{
"id": "…", "is_my": false,
"send": { "asset_id": 0, "amount": "1000000000", "currency_name": "BEAM" },
"receive": { "asset_id": 31, "amount": "29342934", "currency_name": "USDT" },
"create_time": "2026-08-23T03:00:00.000Z",
"expire_time": "2026-08-24T03:00:00.000Z",
"first_seen_at": "2026-08-23T03:00:30.000Z",
"last_seen_at": "2026-08-23T04:00:30.000Z",
"gone_at": null
}
]
}first_seen_at / last_seen_at / gone_at are our observation window, not
chain facts — gossip has no on-chain record, so an offer is "gone" once it stops
being re-advertised. Cache-Control: public, max-age=15.
Cross-chain (BEAM ↔ BTC/LTC/ETH/…) atomic-swap offers, mirrored the same way.
| Name | Type | Default | Notes |
|---|---|---|---|
include |
closed | all |
— | Also return offers marked gone. |
currency |
string | — | Counter-currency symbol, case-insensitive (BTC, USDT, …). |
side |
beam | counter |
— | Which side of the swap the offer is on. |
Default is open offers only, newest first, capped at 500. Each entry: tx_id,
is_beam_side, status + status_string, beam_amount, swap_amount,
swap_currency + swap_currency_name, time_created, min_height,
height_expired, and the same first_seen_at / last_seen_at / gone_at
observation window as /api/asset-swaps.
Cache-Control: public, max-age=15.
Latest aggregate atomic-swap totals snapshot.
{
"latest": {
"ts": "2026-08-23T04:00:00.000Z",
"height": 4005100,
"total_swaps_count": 1284,
"offered": { "BEAM": "…", "BTC": "…", "LTC": "…", "QTUM": "…", "DOGE": "…",
"DASH": "…", "ETH": "…", "DAI": "…", "USDT": "…", "WBTC": "…" }
}
}latest is null before the first snapshot lands. Amounts are strings in each
currency's smallest unit. Cache-Control: public, max-age=30.
The same totals as a time series, for charting.
| Name | Type | Default | Notes |
|---|---|---|---|
since |
ISO 8601 | 30 days ago | |
bucket |
15m | 1h | 1d |
1h |
Down-sampling interval. |
Returns { "bucket", "since", "points": [ … ] } where each point has the same
shape as latest above, oldest first. Buckets aggregate with MAX — these are
monotonic cumulative totals, so the bucket's high is its end state.
Cache-Control: public, max-age=60.
Three routes render images rather than JSON, for link unfurls and embeds. They
are part of the public surface but aren't meant to be consumed as data. All
three live at the site root, without the /api prefix, so the URLs stay short
and hotlinkable.
| Route | Type | Notes |
|---|---|---|
GET /og/site.svg |
image/svg+xml |
Site-wide Open Graph card: headline stats. |
GET /og/pair/{id}.svg |
image/svg+xml |
Per-pair card. id takes the same forms as /api/pairs/{id}. |
GET /pair/{id}/chart.png |
image/png |
Rendered price chart for one pair. |
/pair/{id}/chart.png accepts days (int 1..3650 or all, default 1),
w (200..2000, default 720) and h (150..1200, default 360). Candle
granularity follows days — 5m up to a day, widening to 1d beyond a year — so
the image stays readable at every range. An unknown pair returns a 404 whose body is still a PNG carrying a
"Pair not found" message, so an embedding client renders something rather than a
broken image.
Cache-Control: public, max-age=300 on the SVG cards.
The swap panel asks the AMM shader for quotes directly (pool_trade with bPredictOnly=1) via the user's wallet. We don't mirror that on the server because:
- The shader has to run anyway when the wallet executes the trade.
- A server quote would drift from on-chain reality between request and broadcast.
- Local AMM math is trivial (constant product) for instant UI feedback before the wallet's authoritative quote arrives.
The swap panel's local estimate uses dy = r2·dx / (r1+dx) · (1 - fee) with the latest reserve1 / reserve2 from /api/pairs/{id}. See frontend.md §Wallet integration.
Backed by backend/src/api/repos/usd.ts:loadUsdTable. For each request that needs USD figures, the route loads:
beam_usd— latestoracle_snapshots.beam_usd.- A
perAid: Map<aid, usdPerWholeUnit>built by routing each non-BEAM asset through its deepest BEAM-quoted pool (highestreserve1BEAM groths). Assets without a BEAM-quoted path get no rate.
The same table is used everywhere — /api/stats, /api/pairs, /api/pairs/{id}, /cg/tickers — so USD numbers match across surfaces by construction.
The frontend polls. There's no WebSocket in v1.
/api/stats— every 60 s./api/pairs?...— re-fetched on mount or when sort/filter changes. Not auto-polled./api/pairs/{id}— every 30 s on the detail page./api/pairs/{id}/ohlcv— once on mount, plus on interval/denom change and on scroll-back (uses themore.tocursor)./api/pairs/{id}/trades— initial load + 30 s top-of-feed refresh that splices new trades onto the head.
The useFetcher / usePolling hooks live in frontend/src/app/containers/Screener/hooks.ts. They keep last-known data on screen during refreshes so the UI doesn't flicker between loaded and "Loading…" every interval.
In-wallet, the same hooks run unchanged. A future v1.x can opt into per-block refresh via BeamDappConnector.subscribe('ev_system_state', …) and drop the 30 s timers; the API doesn't need to change.
Measured against a 4-core / 8 GB VPS with ~50 k trades indexed:
| Endpoint | p50 | p95 |
|---|---|---|
/api/health |
<5 ms | 20 ms |
/api/stats |
20 ms | 80 ms |
/api/pairs |
40 ms | 180 ms |
/api/pairs/{id} |
20 ms | 100 ms |
/api/pairs/{id}/ohlcv |
25 ms | 120 ms |
/api/pairs/{id}/trades |
15 ms | 80 ms |
/api/asset/{aid}/history (cold) |
200 ms | 1 s (cached: <10 ms) |
/api/asset/{aid}/history is the outlier because it round-trips to the explorer; the 5-minute LRU keeps the hit rate high in practice.
The endpoints documented here are mounted at /api/*. When we break shape, we'll add /api/v2/* alongside (the version segment isn't parsed today; it's a future affordance, not a current contract).