Base URL: https://hartiilabs.com. All routes below are functions/api/** (Cloudflare Pages
Functions) backed by D1. There are no API keys — every route documented as "public" is
unauthenticated and free to call.
Conventions
- All monetary/token amounts are wei-scaled decimal strings, e.g.
"raisedWei": "165825527731240739544166". 1 QUAI =10^18wei, same scale as ETH. These are too large for a JSnumberpast ~15 digits — parse withBigInt(value), notNumber(value). A handful of fields break this convention on purpose and are called out explicitly below (wallet/:wallet/stats'svolumeQuai, and chartsparklinearrays, which are QUAI-scaled floats for plotting only — never treat them as exact). - CORS is wide open: every JSON route responds
Access-Control-Allow-Origin: *,Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS,Access-Control-Allow-Headers: Content-Type, and answersOPTIONSwith204. (GET /api/quai-priceis the one exception — see its entry.) Verified live:curl -sI -H "Origin: https://example.com" https://hartiilabs.com/api/burns # Access-Control-Allow-Origin: * (same for every Origin — there is no allowlist) - Caching: every route sets an explicit
Cache-Control(see each entry) and most are also CDN-cached (cf-cache-status: HIT/MISS/DYNAMICin response headers) — polling faster than a route'smax-agejust re-serves the same cached body. - Errors are honest, not fabricated. A route that cannot reach D1 answers
503 { "error": "temporarily unavailable", "retryable": true }withCache-Control: no-store— never a fake empty/zero result. A route that reads D1 successfully and finds nothing answers200with a genuinely empty shape (items: [],"totalWei": null, etc.) — that is a final answer, not a loading state. A hidden/moderated token is indistinguishable from one that was never indexed: every read path for it 404s or empty-shapes exactly like "doesn't exist." - No rate limiting on read routes.
functions/_lib/rateLimit.jsexists and is wired into the signed write endpoints (comments, reactions, flags, metadata edits — see below), but none of the GET/read routes in this reference are rate-limited. Be a good citizen anyway — see Integration recipes. - Network is always
"mainnet"— there is no other network to select.
Tokens
GET /api/tokens
Launchpad directory/feed — same endpoint the site's board uses.
| Query param | Type | Default | Notes |
|---|---|---|---|
sort | string | trending | One of trending, new, volume, price, change, marketcap, graduation, holders, age, latest; unknown values silently fall back to trending. |
limit | int | 50 | Clamped 1–100. |
cursor | string | none | Offset-based; the opaque string from a previous response's nextCursor. |
view | string | grid | grid or table — echoed back, a client display hint only. |
venue | string | all venues | hartii for native launches; quainance for Quainance-launched tokens. Missing or other values return both. |
spark | 1 to enable | off | Attaches sparkline (7-day window of 4h candle closes, QUAI-scaled floats, oldest first) to each item. Empty array if the token has no recent candles — never a fabricated flat line. |
trending and volume (and the other ranked sorts) are computed by re-ranking an in-process
directory snapshot rather than a plain ORDER BY — trending sorts on the indexer-maintained
trendingScore (written by the indexer's summary pass, not computed per-request), volume on
volume24hWei. These ranked results are memoized per-isolate for 60 seconds.
Caching: ranked sorts (trending, volume, price, change, marketcap, graduation,
holders, age, latest) get public, max-age=60, s-maxage=60; the base path gets the default
public, max-age=15, s-maxage=15. No DB binding → 200 with items: [], partial: true. D1
refusal → 503 { "error": "temporarily unavailable", "retryable": true }, no-store.
For a complete partner catalog, use ?venue=hartii&sort=new&limit=100 and follow nextCursor
until it is null. This includes indexed launches across V1, V3, V4 and V5; there was no V2
deployment. Quainance launches are a separate list (?venue=quainance). A failed D1 read returns
503 and must be retried rather than treated as a complete empty catalog.
Example (GET /api/tokens?limit=2, trimmed, one item):
{
"network": "mainnet",
"view": "grid",
"items": [{
"network": "mainnet",
"address": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
"creator": "0x0011111111111111111111111111111111111111",
"name": "QuaiAxe",
"symbol": "QAXE",
"curveAddress": "0x004bc407903a51506bcf0b1ab423958c5991c237",
"launchTx": "0x004a0045...",
"launchBlock": 9955113,
"launchedAt": "2026-09-06T13:09:12.447Z",
"supply": null,
"totalSupplyWei": "877074085029199910587818112",
"burnedWei": "122925914970800089412181888",
"meta": { "description": "...", "socials": { "website": "...", "twitter": "...", "telegram": "...", "discord": "" }, "logoKv": true, "logoCid": "Qme..." },
"logoUrl": "/api/token-logo/0x0035187a...?v=2026-09-21T20%3A54%3A33.773Z",
"description": "...",
"socials": { "website": "...", "twitter": "...", "telegram": "...", "discord": "" },
"style": null,
"status": "graduated",
"updatedAt": "2026-09-21T20:54:33.773Z",
"lastPriceWei": "3042142570835435",
"raisedWei": "0",
"tokensSold": "784000000000000000000000000",
"curveSupply": "784000000000000000000000000",
"poolQuaiWei": "165825527731240739544166",
"volume24hWei": "59620483275532255828565",
"tradeCount24h": 25,
"change24h": 44.51,
"priceChange24h": 44.51,
"trendingScore": 178861.44982659677,
"marketCapWei": "2668184411843867129265764",
"holderCount": 134,
"ageSeconds": 1474080,
"source": "hartii-launchpad",
"lastTradeAt": "2026-09-23T07:48:32.368Z"
}],
"nextCursor": "2",
"partial": false,
"at": "2026-09-23T14:37:32.135Z"
}Field notes (per item):
| Field | Type | Notes |
|---|---|---|
address, creator, curveAddress | string | Lowercase 0x… addresses. |
status | string | active or graduated. |
supply | string|null | Minted supply as recorded at launch; null for every current token. Use totalSupplyWei. |
totalSupplyWei | string|null | Live total supply = 1,000,000,000 minted − burnedWei. Matches token.totalSupply() on chain to the wei. Use this for supply and market cap — never curveSupply. |
burnedWei | string|null | All-time burns; null means never computed, not zero. |
lastPriceWei, raisedWei, tokensSold, curveSupply, poolQuaiWei, volume24hWei | string|null (wei) | null until the indexer's summary pass has run for this token. |
marketCapWei | string|null (wei) | lastPriceWei × totalSupplyWei / 1e18. null only while lastPriceWei is unknown. See Pricing a token. |
tradeCount24h | int|null | |
change24h / priceChange24h | number|null | Percent, same value under both keys. |
trendingScore | number|null | Wash-resistant score, see Indexer. |
holderCount | int|null | Distinct addresses with non-zero balance. |
ageSeconds | int|null | Derived from launchedAt at response time. |
logoUrl | string|null | Relative path to GET /api/token-logo/:addr; null if no logo set. |
meta | object|null | Raw stored metadata blob; description/socials/style are also hoisted to top level for convenience. |
For each /api/tokens item, source is hartii-launchpad for a native launch and
quainance-launchpad for a Quainance bonded launch. venue is null for native tokens and
quainance for external ones. Quainance rows also carry venueMarket, venueLauncher and, after
graduation, venuePool.
GET /api/token/:addressOrTicker
Single token detail. :addressOrTicker is either a 0x + 40-hex address or a ticker symbol
(/api/token/HRT works) — first-launched token owns a symbol, first-come-first-served; later
tokens sharing a symbol are only reachable by address.
No query params. Caching: public, max-age=15, s-maxage=15. Errors: unknown/hidden token →
404 { "error": "not indexed", "address": "<param>" }; D1 refusal → 503 { "error": "temporarily unavailable", "retryable": true, "address": "<param>" }, no-store.
{
"token": { "...": "same shape as one /api/tokens item" },
"graduation_progress": { "graduated": true, "progressPct": 100 },
"at": "2026-09-23T14:37:40.152Z"
}graduation_progress.progressPct is tokensSold / curveSupply × 100 (sellout basis, matching
what the contract actually graduates on) — not a raised-QUAI percentage. A graduated token is
always 100.
Trades & candles
GET /api/token/:addr/trades
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | 1–100. |
Returns this token's trades merged with its burns into one time-ordered feed (a burn is a
Transfer to the zero address — announced beside buys/sells rather than requiring a second
request). Hidden/unknown token → 200 { items: [] }, not a 404. Caching: public, max-age=15, s-maxage=15.
{
"address": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
"items": [
{ "network": "mainnet", "tokenAddress": "0x0035...", "txHash": "0x007a...", "logIndex": 1,
"blockNumber": 10245041, "blockTime": "2026-09-23T07:48:32.368Z",
"trader": "0x0044444444444444444444444444444444444444", "side": "sell",
"quaiAmount": "3435148981866778576855", "tokenAmount": "1117215912812398093612081",
"priceWei": "3074740470908066" }
],
"at": "2026-09-23T14:37:40.785Z"
}A burn row has side: "burn", quaiAmount: null, and tokenAmount set to the wei destroyed.
Trade rows include venue: null for Hartii-native trades and quainance for indexed Quainance
markets. This is a recent feed capped at 100 rows, not a full-history cursor API.
GET /api/token/:addr/candles
| Query param | Type | Default | Notes |
|---|---|---|---|
tf | string | 5m | One of 1m, 5m, 15m, 1h, 4h, 1d. |
limit | int | 720 | Newest-N buckets, capped at 2000. |
{
"address": "0x0035...",
"items": [
{ "tf": "1h", "bucketStart": 1790121600, "open": "2770797937922332", "high": "2770797937922332",
"low": "2770797937922332", "close": "2770797937922332", "volumeQuai": "343194826528785000000",
"tradeCount": 1 }
],
"at": "2026-09-23T14:37:43.946Z",
"bounded": true
}bucketStart is unix seconds, ascending order. open/high/low/close/volumeQuai are wei
strings. The indexer stores all six timeframe buckets for Hartii-native and Quainance venue trades.
Hidden/unknown token → 200 { items: [] }. Caching: public, max-age=15, s-maxage=15.
GET /api/token/:addr/holders
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 100 | Clamped 1–100 — this is also the hard per-call row cap; limit above 100 is silently clamped down, it does not widen a page. |
offset | non-negative int | 0 | Added 2026-10-04. Row offset into the sorted (largest balance first) holder list. Anything that doesn't parse as a non-negative integer (missing, negative, non-numeric) is treated as 0. |
{
"address": "0x0035...",
"items": [
{ "network": "mainnet", "tokenAddress": "0x0035...",
"holder": "0x0033333333333333333333333333333333333333",
"balance": "107241610597565238455244285", "updatedAt": "2026-09-17T18:43:40.590Z" }
],
"offset": 0,
"nextOffset": 100,
"at": "2026-09-23T14:37:41.493Z"
}Sorted largest balance first (see Indexer for why this sort is
exact without an integer cast). balance is a wei string; see
Indexer for how balances are derived.
Snapshot paging (since 1.23.0). The order is total (balance descending, then holder address), so pages
never skip or repeat a holder. Pass the previous response's nextCursor as cursor to page by key; every
response carries asOf, and passing it back makes the call answer 409 {"restart":true} if the holder
set was rewritten meanwhile, so a snapshot is either one consistent version or a clear restart. A database
error is 503, never an empty last page. ?check=supply compares the exact sum of holder balances with the
token's on-chain totalSupply() and returns conserved; require conserved:true before building anything
from a snapshot. offset/nextOffset still work but shift under concurrent changes.
Paging (added 2026-10-04): nextOffset is offset + <rows returned> when the page came back
full (items.length equals the effective, 100-capped limit), and null on a short/last page —
walk the whole holder list by passing each response's nextOffset back in as the next request's
offset until you get null. Because the row cap is enforced before the paging decision, asking
for limit=5000 still only ever returns up to 100 rows per call and advances nextOffset by 100
at a time, not by 5000. No DB binding or an unindexed/hidden token still return 200 { items: [], offset, nextOffset: null } — paging never turns an honest-empty result into an error.
Caching: public, max-age=15, s-maxage=15.
GET /api/token/:addr/dev-activity
The token creator's own buy/sell footprint — a rug-pull tell. No query params.
{
"token": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
"creator": "0x0011111111111111111111111111111111111111",
"devActivity": {
"devBuyQuai": 23268, "devSellQuai": 0, "devBuyCount": 29, "devSellCount": 0,
"lastSellTime": null, "hasSold": false, "dumpedPct": 0
},
"at": "2026-09-23T14:37:44.545Z"
}Caching: public, max-age=15, s-maxage=15.
GET /api/trades/recent
Newest trades (merged with burns) across all visible tokens — the site-wide ticker.
| Query param | Type | Default |
|---|---|---|
limit | int | 30 (cap 50) |
{
"items": [
{ "tokenAddress": "0x0076bc...", "txHash": "0x00460053...", "logIndex": 5,
"blockNumber": 10249831, "blockTime": "2026-09-23T14:28:33.457Z",
"trader": "0x0044bed4...", "side": "burn", "quaiAmount": null,
"tokenAmount": "1963537265200000000000000", "symbol": "POEM",
"curveAddress": "0x0036790c..." }
],
"at": "2026-09-23T14:37:53.462Z"
}Caching: public, max-age=15, s-maxage=15.
GET /api/trades/:wallet
Full trade history for one wallet, newest first, offset-free cursor pagination.
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | Clamped 1–200. |
cursor | string (ISO timestamp) | none | From a previous response's nextCursor; strictly-less-than on block_time. |
side | buy|sell | none | Optional filter; any other value is ignored. |
{
"wallet": "0x0033333333333333333333333333333333333333",
"items": [
{ "tokenAddress": "0x0035...", "txHash": "0x0060...", "logIndex": 9, "blockNumber": 10116349,
"blockTime": "2026-09-15T21:10:43.664Z", "trader": "0x004f7f...", "side": "buy",
"quaiAmount": "10000000000000000000000", "tokenAmount": "10820365474840429875562399",
"priceWei": "924183200951211", "symbol": "QAXE", "name": "QuaiAxe" }
],
"total": 5,
"nextCursor": "2026-09-15T21:10:43.664Z",
"at": "2026-09-23T14:38:06.516Z"
}total is the full matching count (ignoring pagination, honoring side). Hidden tokens are
excluded. Caching: public, max-age=15, s-maxage=15.
GET /api/traders/top
Leaderboard by all-time QUAI volume, across all tokens or scoped to one.
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | 1–100. |
token | address | none | Optional; must match /^0x[0-9a-fA-F]{40}$/ or it's ignored. |
{
"items": [
{ "trader": "0x0022222222222222222222222222222222222222", "trades": 73,
"volumeQuai": "71251793299521694334976", "lastActive": "2026-09-23T14:28:32.246Z" }
],
"at": "2026-09-23T14:37:57.601Z"
}volumeQuai here is a wei string (despite the name reading like a display value) — ranked via
CAST(...AS REAL) internally, so it is precision-lossy past ~15 digits; fine for a leaderboard,
don't use it for accounting. Caching: public, max-age=15, s-maxage=15.
Wallet, portfolio, creator
GET /api/wallet/:wallet/stats
Activity aggregates that drive achievement badges on the site.
{
"wallet": "0x0033333333333333333333333333333333333333",
"stats": {
"tradeCount": 5, "buyCount": 5, "sellCount": 0,
"volumeQuai": 70000, "largestTradeQuai": 30000, "distinctTokens": 2,
"firstTradeTime": "2026-09-14T14:50:43.005Z", "lastTradeTime": "2026-09-15T21:10:43.664Z",
"tokensCreated": 0, "graduatedCount": 0
},
"at": "2026-09-23T14:38:05.069Z"
}No query params; wallet is honest-zeroed (never an error) if it has no activity. Caching:
public, max-age=15, s-maxage=15.
GET /api/portfolio/:wallet
Holdings, cost basis (average-cost method, not FIFO), and P&L for a wallet.
{
"wallet": "0x0033333333333333333333333333333333333333",
"holdings": [
{ "tokenAddress": "0x000010c7602a0b91e81d7d18c12a47792ffb09f8", "name": "Ask Quai", "symbol": "ASK",
"curveAddress": "0x002690512c43a48db94cc479a36797f7f49d4bc6", "launchBlock": 10079201,
"curveSupply": "784000000000000000000000000",
"logoUrl": "/api/token-logo/0x000010c7...?v=2026-09-18T07%3A55%3A46.534Z", "status": "active",
"balance": "350040929693482655359602290", "priceQuai": "40862631322710",
"valueQuai": "14303593457923433272243", "costBasisQuai": "10000000000000000000000",
"realizedPnlQuai": "0", "unrealizedPnlQuai": "4303593457923433272243",
"priceSource": "live-reserve-reference", "pricePool": null }
],
"totals": { "valueQuai": "344043713727620615784671", "realizedPnlQuai": "0",
"unrealizedPnlQuai": "274043713727620615784671" },
"at": "2026-09-23T14:38:05.697Z"
}All of balance, priceQuai, valueQuai, costBasisQuai, realizedPnlQuai,
unrealizedPnlQuai are wei strings despite the Quai-suffixed names. priceQuai is the token's
current price. costBasisQuai/unrealizedPnlQuai are null when there's no buy history to
attribute a basis to (e.g. balance arrived via transfer, not a tracked trade) — a null here means
"unknowable," never a fabricated 0.
priceSource (added for market-maker tools v1 — functions/_lib/portfolioPrices.js) says
where priceQuai actually came from:
priceSource | When | pricePool |
|---|---|---|
live-reserve-reference | Graduated native HartiiLabs token | null — reads GET /api/token/:addr/reference's midWei directly. |
quainance-pool-reserves | Quainance-launched token | The discovered WQUAI pair address priceQuai was read from. |
indexed-last-trade | Pre-graduation native token | null — this token's single most recent trade's priceWei (the only source pre-v1). |
unavailable | A live/RPC read failed | null — never falls back to a stale indexed trade once a token is eligible for a live read. |
No query params. Caching: public, max-age=15, s-maxage=15.
GET /api/creator/:wallet
Everything the Creator dashboard shows: one row per token this wallet launched.
{
"wallet": "0x0011111111111111111111111111111111111111",
"curves": [
{ "tokenAddress": "0x0035187a...", "symbol": "QAXE", "name": "QuaiAxe",
"curveAddress": "0x004bc407...", "status": "graduated", "launchedAt": "2026-09-06T13:09:12.447Z",
"volumeWei": "628349616832067248456440", "tradeCount": 356, "holderCount": 134,
"feeBps": 100, "earnedToDateWei": "3141748084160336242282",
"claimableWei": "2037293380981762530647",
"autoBuyback": false,
"lastClaim": { "txHash": "0x002a0019...", "at": "2026-09-19T11:02:29.988Z",
"amountWei": "142350007421997897392" },
"claimCount": 1, "claimedTotalWei": "142350007421997897392" }
],
"totals": {
"tokensLaunched": 2, "volumeWei": "628654616832067248456440",
"earnedToDateWei": "3143273084160336242282", "claimableWei": "2038818380981762530647",
"queuedBurnWei": "0", "autoBuybackCurves": 0, "holders": 138, "liveReadCap": 30
},
"at": "2026-09-23T14:38:08.414Z"
}volumeWei,tradeCount,holderCount,lastClaim/claimCount/claimedTotalWeicome from D1 (indexedBuy/Sell/CreatorFeesWithdrawn).feeBpsandclaimableWeiare liveeth_calls to the curve contract (feeBps(),creatorFees()), bounded to the firstliveReadCapcurves (30) per request — beyond the cap the D1-derived fields still return, butfeeBps/claimableWei/autoBuybackarenull.earnedToDateWei = volumeWei × feeBps × 50% (creator's half), computed in exact BigInt math from the two numbers above (only when both are known).autoBuyback: truemarks a V2 curve whose "claim" call burns the pot instead of paying the creator — its balance is reported separately astotals.queuedBurnWei, never folded intototals.claimableWei, so the two are never presented as one interchangeable figure.- Any field that could not be read live is
null, not0— "unknown" and "zero" are always kept distinct in this route.
No query params. Errors: bad wallet param → 400; no DB/D1 refusal → 503 { curves: [], totals: null, error: "temporarily unavailable", retryable: true }, no-store. Caching: default
public, max-age=15, s-maxage=15 on success, no-store on error.
Market-maker tools (v1, dry-run)
GET /api/token/:addr/reference
Depth-weighted price reference across a token's known venues (its bonding-curve/internal pool and
its HartiiSwap WQUAI pair today; typed null placeholders for an external venue and a QUAI/Qi
conversion venue, reserved for later). This is the primitive the /api/mm/* routes below are built
on — fetch it directly if you only want the reference price, not the quote preview.
No query params. Caching: public, max-age=5, s-maxage=5 on success, plus an internal 5-second
Cloudflare edge-cache entry keyed per token address (a cache hit skips the chain reads entirely and
returns byte-identical JSON). Errors: no HARTII_LABS_DB binding → 404 { "error": "not indexed", "address": "<param>", "note": "HARTII_LABS_DB binding not configured" } (still max-age=5 — this
shape is itself cached as a short-TTL "partial" response); malformed address or unknown/hidden
token → 404 { "error": "not indexed", "address": "<param>" } at the normal max-age=15; a chain
read throwing mid-request → 503 { "error": "temporarily unavailable", "retryable": true, "address": "<param>" }, no-store.
Example — computed by this repo's own computeReference/weiDecimal from the recorded QAXE
mainnet fixture (test/mmReferenceRoute.test.mjs; QAXE curve 0x004bc407903a51506bcf0b1ab423958c5991c237
graduated, HartiiSwap pair 0x00052a890c39ff5592ca88a89924d6083e4e3179, chain head block
10,455,895):
{
"token": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
"blockNumber": 10455895,
"at": "2026-10-04T12:00:00.000Z",
"reference": {
"mid": "0.002239658519168809",
"low": "0.002216964295947177",
"high": "0.003423928017629067",
"midWei": "2239658519168809",
"lowWei": "2216964295947177",
"highWei": "3423928017629067",
"confidenceBps": 5288,
"flags": ["drift:curve", "drift:hartiiswap"]
},
"venues": [
{ "name": "curve", "address": "0x004bc407903a51506bcf0b1ab423958c5991c237",
"price": "0.002216964295947177", "depthQuai": "141560.130971721715110791",
"weight": 0.9811972615133717, "ageBlocks": 0,
"priceWei": "2216964295947177", "depthQuaiWei": "141560130971721715110791" },
{ "name": "hartiiswap", "address": "0x00052a890c39ff5592ca88a89924d6083e4e3179",
"price": "0.003423928017629067", "depthQuai": "2712.724777369203045966",
"weight": 0.01880273848662829, "ageBlocks": 0,
"priceWei": "3423928017629067", "depthQuaiWei": "2712724777369203045966" },
{ "name": "external", "address": null, "price": null, "depthQuai": null,
"weight": 0, "ageBlocks": null, "priceWei": null, "depthQuaiWei": null },
{ "name": "conversion", "address": null, "price": null, "depthQuai": null,
"weight": 0, "ageBlocks": null, "priceWei": null, "depthQuaiWei": null }
],
"version": 1
}reference.midis the depth-weighted average of every venue that has a price, weighted by each venue'sdepthQuaiWei(equal-weighted instead if every priced venue reports zero depth).low/highwiden per venue by a staleness penalty (ageBlocks × 5 bps/blockby default, capped at 5,000 bps) before taking the overall min/max — a fresher read narrows the band, a stale one widens it.confidenceBpsis the wider of(mid−low)/(high−mid)as a fraction ofmid, in bps.flagscan includedrift:<venue>(that venue's price sits more than 100 bps frommid) andthin(combined venue depth is below a 1,000 QUAI floor). The example above is genuinely flaggeddrifton both venues — QAXE's curve and HartiiSwap pool were priced noticeably apart at this recorded block; that is the fixture being honest, not a display bug.venues[].weightis each venue's share of themidweighted average (always sums to 1 across priced venues; an unpriced venue likeexternal/conversionabove gets0).ageBlocksisnullfor a venue whose price couldn't be read.- Pricing a token documents the underlying curve/pool spot-price math this endpoint reads from; this endpoint adds the cross-venue aggregation on top.
GET /api/mm/pairs
The market-maker pair registry, each entry enriched with a live reference and a deterministic quote preview. No query params.
{
"pairs": [{
"pair": "QAXE/WQUAI",
"token": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
"depthTargetQuai": 25000,
"targetRatio": 0.5,
"band": 0.15,
"limits": { "minSpreadBps": 40, "maxSpreadBps": 300, "maxConfidenceBps": 400 },
"status": "planned",
"reference": { "...": "same shape as reference.reference above, or null if unreadable" },
"venues": [{ "...": "same shape as the venues array above, or [] if unreadable" }],
"blockNumber": 10455895,
"at": "2026-10-04T12:00:00.000Z",
"preview": true,
"maker": null,
"quote": {
"bid": "0.001640773831143069", "ask": "0.002838543207194549", "mid": "0.002239658519168809",
"size": "12500", "sizes": { "bid": "12500", "ask": "12500" },
"spreadBps": 5348, "skewBps": 0,
"reasons": ["confidence-too-wide", "spread-too-wide"], "ok": false
}
}],
"at": "2026-10-04T12:00:00.000Z",
"version": 1
}pair/token/depthTargetQuai/targetRatio/band/limits/statuscome straight from the pair registry (src/data/mmPairs.json) — see Market-maker tools › pair registry.statusstays"planned"for every pair today — no vault is funded.preview: trueandmaker: nullare constant in this route — there is no code path that sets either to anything else. This stays true even now that the executor exists: the Worker reads its own live pair state separately (see the executor), this preview always computes against an empty book regardless.quoteis the deterministic bid/ask preview computed from the pair's own reference and an always-empty (quai: 0, token: 0) inventory — it is never computed from a real funded position.ok: falseplus a non-emptyreasonsarray is an honest "would not quote this" result, not an error — the worked example above is a real one: this recorded QAXE snapshot's confidence (5,288 bps) and resulting spread (5,348 bps) both exceed the pair's configuredmaxConfidenceBps/maxSpreadBps(400 / 300), so the preview correctly flags itself as unexecutable.size/sizes.bid/sizes.askare decimal-QUAI depth caps per side (half ofdepthTargetQuaieach whenskewBpsis 0, shifted toward the lighter side otherwise). If the reference read fails for a pair,reference/venues/blockNumberfall back tonull/[]/nullandquotereflects an empty, unpriced reference (reasons: ["reference-missing"]).- Caching:
public, max-age=5, s-maxage=5, plus a 5-second Cloudflare edge-cache entry for the whole response (single cache key — there's one registry, not one per token).
GET /api/mm/status
The dashboard's single overview contract — engine mode, global/per-pair pause state, risk limits, last run time. No query params.
{
"paused": false,
"pairs": [{ "pair": "QAXE/WQUAI", "paused": false, "reason": null, "lastActionAt": null }],
"limits": { "maxPairShareBps": 3000, "maxDailyLossBps": "200", "maxActionsPerDay": 1000 },
"lastRunAt": null,
"mode": "dry"
}pausedis the global kill switch (mm_state.global_paused); eachpairs[]entry's ownpaused/reasonis that pair's latched state (see Market-maker tools › risk guard and the kill switch) —reasonis one ofadmin-pause,pair-share-limit,daily-loss-limit,daily-action-limit,vault-paused, ornullwhen not paused.limitsmirrors the risk guard's hard-clamped constants —maxPairShareBps: 3000(30%),maxDailyLossBps: "200"(2%, a string —risk.js'sMAX_DAILY_LOSS_BPSis aBigInt),maxActionsPerDay: 1000. These are the defaults; a persistedmm_state.limitsoverride (still capped by the same hard constants) would replace them.modeis"not-deployed"when there is noHARTII_LABS_DBbinding and no validHARTII_LABS_MM_VAULT; otherwise"live"only whenMM_DRY_RUN === "false"(exact lowercase-string match) andHARTII_LABS_MM_VAULTis a well-formed0x+ 40-hex address; otherwise"dry". On hartiilabs.com today this is"dry".lastRunAtismm_state.last_run_at, written by the executor at the end of every cron cycle —nullonly if the Worker has never completed a cycle. A fresh value here does not by itself mean a maker is quoting; cross-checkmodeand each pair's ownstatus.- No DB binding → the shape above with every pair honestly unpaused and
lastRunAt: null, not an error. Caching:public, max-age=5, s-maxage=5.
GET /api/mm/actions
Dry-run (and, once live, real) action history — the executor's own decision log, newest first.
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | Clamped 1–100. |
{ "items": [
{ "id": 1, "at": "2026-10-05T12:00:00.000Z", "pair": "QAXE/WQUAI", "kind": "rebalance-buy",
"params": { "amountQuaiWei": "12500000000000000000000" }, "reason": "inventory-token-heavy",
"mode": "dry", "tx_hash": null, "status": "planned" }
], "limit": 50, "at": "2026-10-05T12:00:05.000Z", "version": 1 }kindis one ofrebalance-buy,rebalance-sell,add-liquidity, orskip(see Market-maker tools › the executor for what each means and the full list of skipreasons, e.g.unfunded,pre-graduation,confidence-too-wide,reference-drift,risk-paused,vault-paused,pending-action-under-10-minutes,no-action-needed).paramsis whatever the engine decided before any signing —amountQuaiWei/amountTokenWeiwei-integer strings, empty{}for askip.modeis"dry"or"live"for that specific recorded action (reflects the gate at the moment it ran, not the current moment);statusis"planned"(dry),"skipped", or for a live action"pending"→"success"/"failed".tx_hashisnulluntil a live send actually broadcasts.- No DB binding →
200 { items: [], limit, at, version: 1 }. Caching:public, max-age=5, s-maxage=5.
GET /api/mm/positions
Live on-chain balances for the configured market-maker vault, one entry per registered pair. No query params.
{
"positions": [{
"pair": "QAXE/WQUAI",
"token": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
"vault": null,
"configured": false,
"funded": false,
"readable": false,
"quai": "0",
"tokenBalance": "0",
"lpPair": null,
"pairAddress": null,
"lpBalance": "0",
"lpValueQuai": "0",
"lpShareQuai": "0",
"lpSource": null,
"reference": { "...": "same reference shape, or null" },
"inventory": { "valueQuai": "0", "ratio": null, "inBand": false, "breach": null, "rebalance": null,
"lpShareQuai": "0", "totalValueQuai": "0" },
"error": null
}],
"vault": null,
"at": "2026-10-05T12:00:00.000Z",
"version": 1
}HARTII_LABS_MM_VAULTis an optional environment variable — the address of the wallet acting as the market-maker vault. Unset, empty, or not a well-formed0x+ 40-hex address →configured: false,vault: null,funded: false,quai: "0",tokenBalance: "0",lpBalance/lpValueQuai/lpShareQuai:"0", andinventoryreports an honestly unbalanced empty book (ratio: null,inBand: false— an empty wallet is never "balanced"). This is the default today — no vault is configured on hartiilabs.com, so this is what a live call returns.configuredistrue/falsepurely from whetherHARTII_LABS_MM_VAULTparses as an address — it does not mean funded. Whenconfigured: truebut the balance reads haven't resolved yet (or failed),fundedisnull(unknown), never a guessedfalse; once resolved,fundedistrueonly if QUAI, token, or LP balance is actually non-zero.- When the vault is configured, the route does live reads per pair (
quai_getBalanceon the vault, the token'sbalanceOf(vault), and the HartiiSwap LP token'sbalanceOf(vault)if a pool exists): on successreadable: trueandquai/tokenBalanceare real decimal-QUAI balances; on a chain-read failurereadable: falseanderror: "Could not read the configured market-maker vault right now."— nothing from a stale shape is substituted. - LP fields (added for v1, from one shared LP read so the two naming conventions below can
never disagree):
lpPair/pairAddressare the same discovered HartiiSwap WQUAI pool address (the dashboard readslpPair, the engine/tests readpairAddress);lpBalanceis the vault's LP token balance;lpValueQuai/lpShareQuaiare the same number — the vault's LP balance's share of the pool's two-sided QUAI value (held / totalSupply × quaiReserve × 2) — under the dashboard's and the engine's respective field names;lpSourceis"hartiiswap-reserves"when resolved,nullotherwise. inventory.valueQuai/ratio/inBand/breach/rebalanceare the wallet-only 50/50 ± band computation documented in Market-maker tools › inventory bands, marked to the pair's own referencemid.inventory.lpShareQuai(same value as the top-levellpShareQuai) andinventory.totalValueQuai(valueQuai + lpShareQuai) are reported alongside it, not folded in — the wallet inventory ratio/band never includes the LP position. The vault's QUAI balance can be shared across multiple pairs' rows and must not be summed across them.- Caching:
public, max-age=5, s-maxage=5, plus a single edge-cache entry for the whole response (one vault, one cache key).
GET /api/mm/fills
Paged, append-only read of the mm_fills D1 table — the FIFO accounting ledger's raw input. No
vault is funded today, so there is no vault activity for either the (dry-run) executor or the
indexer to pick up — a live call returns an honestly empty page.
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | Clamped 1–100. |
cursor | int | 0 | Offset-based (not the nextOffset idiom the holders route uses — this one's field is literally named cursor, and it's an offset either way). |
{ "items": [], "nextCursor": null, "limit": 50, "at": "2026-10-05T12:00:00.000Z" }A populated row (shape only — not a real fill):
{
"id": 1, "pair": "QAXE/WQUAI", "token": "0x0035187a...", "side": "buy",
"txHash": "0x00...", "blockNumber": 10455900, "blockTime": "2026-10-05T12:00:05.000Z",
"priceQuai": "2239658519168809", "amountToken": "1000000000000000000000",
"amountQuai": "2239658519168809000", "feeQuai": "6718975557506427",
"inventoryQuaiAfter": "...", "inventoryTokenAfter": "...",
"maker": null, "source": null, "kind": "swap", "provisional": false,
"createdAt": "2026-10-05T12:00:05.100Z"
}priceQuai/amountToken/amountQuai/feeQuai/inventoryQuaiAfter/inventoryTokenAfter are raw
wei integer strings (straight from mm_fills TEXT columns) — see the note at the top of this
section. kind is "swap", "lp-add", or "lp-remove". provisional: true means a (future) live
executor send landed this row from its own transaction receipt and the site's indexer has not yet
independently confirmed it; provisional is absent/false once confirmed, or for any row the
indexer wrote directly. The unique key is (txHash, logIndex) — see
Market-maker tools › fill ingestion for the router
self-forwarding correlation this depends on. No DB binding → 200 { items: [], nextCursor: null, limit, at, note: "HARTII_LABS_DB binding not configured" } (not an error — an unconfigured
binding is an honest empty ledger, same pattern as the rest of this API). D1 failure → 503 { "error": "temporarily unavailable", "retryable": true, items: [], nextCursor: null, at },
no-store. Caching: public, max-age=5, s-maxage=5 on success (edge-cached only for the default
cursor=0&limit=50 call).
GET /api/mm/pnl
FIFO profit-and-loss computed in-request from every row in mm_fills, marked to each pair's
current reference mid. No query params.
{
"realisedQuai": "0", "unrealisedQuai": "0", "feesQuai": "0",
"inventory": { "quai": "0", "token": "0" },
"byPair": {},
"pairs": [{
"pair": "QAXE/WQUAI", "token": "0x0035187a...", "...": "rest of the pair-registry fields",
"pnl": { "realisedQuai": "0", "unrealisedQuai": "0", "feesQuai": "0",
"inventory": { "quai": "0", "token": "0" } },
"reference": { "...": "same reference shape, or null" }
}],
"at": "2026-10-04T12:00:00.000Z",
"version": 1
}- FIFO: a buy opens a token lot at its QUAI principal cost; a sell consumes the oldest open lots
first and books
realisedQuaion the amount consumed. Fees are tracked infeesQuaiand already netted out ofinventory.quai, but not subtracted fromrealisedQuai/unrealisedQuai— show the gross trading result and the fee drag as two separate numbers, never fold one into the other. unrealisedQuaiisnull(not0) for any pair still holding a non-zero token balance whose current reference price couldn't be read — "unknown," never fabricated. The top-levelunrealisedQuaiisnullthe moment any one pair's isnull.- Every amount here is a wei integer string, same exception called out at the top of this
section —
realisedQuai,unrealisedQuai,feesQuai, andinventory.quai/inventory.tokenare not decimal-scaled despite the naming. - No DB binding →
200with every total zeroed as above (an honest empty ledger, not an error). D1 failure →503 { "error": "temporarily unavailable", "retryable": true, pairs: [], realisedQuai: null, unrealisedQuai: null, feesQuai: null, inventory: null, byPair: {}, at },no-store— note this is the one place in this section where the error shape nulls the totals instead of zeroing them, precisely so a reader can't mistake "could not compute" for "computed to zero." Caching:public, max-age=5, s-maxage=5on success, plus a single whole-response edge-cache entry.
GET /api/mm/pnl/history
Daily cumulative P&L snapshots for one pair, written by the site's indexer run (immutable per
pair/UTC day) — not the same data as GET /api/mm/pnl above, which recomputes FIFO P&L
in-request from every fill.
| Query param | Type | Default | Notes |
|---|---|---|---|
pair | string | "" | Exact pair name, e.g. QAXE/WQUAI. No validation against the registry — an unknown pair just returns items: []. |
{ "pair": "QAXE/WQUAI", "items": [
{ "date": "2026-10-05", "pair": "QAXE/WQUAI", "realisedQuai": "0", "unrealisedQuai": "0",
"feesQuai": "0", "inventoryQuaiValue": "0" }
], "limit": 365, "at": "2026-10-05T12:00:00.000Z", "version": 1 }- Ordered newest-first, capped at 365 rows (one row per pair per UTC day; a day with no snapshot yet simply has no row — never a fabricated zero row).
realisedQuai/unrealisedQuai/feesQuai/inventoryQuaiValueare raw wei integer strings, same exception as/api/mm/fills//api/mm/pnlabove. Each row is cumulative as of that UTC day, not that day's delta — chart the values directly, never sum them.inventoryQuaiValue/unrealisedQuaiarenull(not0) when that day's mark genuinely couldn't be read.- No DB binding →
200 { pair, items: [], limit: 365, at, version: 1 }. D1 failure →503 { "error": "temporarily unavailable", "retryable": true, pair, items: [] },no-store. Caching:public, max-age=5, s-maxage=5.
GET /api/mm/requests / POST /api/mm/requests
Creator-signed requests for market-making support on a token's own pool — see Market-maker tools › creator depth requests for the full flow. A signature only; no transaction.
GET, no auth:
| Query param | Type | Notes |
|---|---|---|
token | address | Optional — returns that one token's request(s) instead of a page. |
offset | int | Default 0, used only without token. |
{ "items": [
{ "token": "0x0035187a...", "creator": "0x0011111111111111111111111111111111111111",
"pair": "0x00052a89...", "note": "Would love depth on our pool.",
"at": "2026-10-05T12:00:00.000Z", "status": "requested" }
], "hasMore": false, "nextOffset": 100 }Newest first (?token= unfiltered-ordered), 100 rows per page. No DB binding → 503 { "error": "Requests unavailable: database not configured." }.
POST body: { token, pair, note, ts, wallet, signature }.
- Signed message:
`hartiilabs:mm-request:v1:` + JSON.stringify([9, token.toLowerCase(), pair.toLowerCase(), note, ts])—9is Quai's chain id, part of the signed payload. token/pairmust be0x+ 40-hex and nonzero;pairis the pool contract address, not a display name;note≤ 500 characters.ts(ms epoch): rejected if more than 60 seconds in the future or more than 600,000 ms (10 minutes) in the past of the server clock.- The recovered signer must equal
wallet, andwalletmust equal the token's on-chain-indexedcreator— not just any valid signature. - One row per
token(INSERT OR IGNORE) — a repeat POST for an already-requested token is idempotent and returns the existing row; it can never overwrite an operator'saccepted/declineddecision.statusstarts"requested". - Rate limit: 10/minute per IP.
- Errors:
400(bad body, or an expired/futurets),401(invalid or mismatched signature),403(wallet is not the indexed creator),404(token not indexed),429(rate limited),503(DB unavailable). Success:200 { item: {...same row shape as GET...} },no-store.
GET /api/pool/:pair/apr
Live HartiiSwap pool fee APR for a graduated token's pool — honestly null until enough history
exists. :pair is the pool's own contract address (not the token address).
{
"pair": "0x00052a890c39ff5592ca88a89924d6083e4e3179",
"token": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
"tvlQuaiWei": "283120261943443430221582",
"feeAprBps": null,
"feesQuaiWei": null,
"coverageSeconds": 0,
"complete": false,
"lpReserve": null,
"source": "live-pool-reserves; swap-history-unavailable",
"reason": "Fewer than 7 days of verified pool swap data. Pool swap history is not indexed yet.",
"at": "2026-10-05T12:00:00.000Z"
}- Verifies
:pairagainst the live HartiiSwap factory (factory()call, cross-checked against the factory's owngetPair) before trusting anything else about it —404 { "error": "Not a HartiiSwap pool." }otherwise. tvlQuaiWeiis2 × the pool's QUAI-side reserve(livegetReserves()read) — always available once the pool is verified.feeAprBpsisnullon hartiilabs.com today, and the dashboard renders this as "—", never a zero or an estimate. Formula when it is available:(7-day volume × 25/10000) / TVL × 365/7, BigInt, bps as TEXT. This checkout has no persisted HartiiSwap Swap-event history or coverage cursor, so the route refuses to substitute bonding-curve volume (a different, unrelated number) and returns thereasonabove instead.complete: true/coverageSecondswould indicate seven verified days once a swap-history collector exists.lpReserve(non-nullonly for a graduated V4 curve) describes that token's separate on-curve LP-reserve reward stream — explicitly labelledappliesTo: 'curve-lp-only'; it is not an APR on this HartiiSwap pool, and liquidity providers on this pool don't qualify for it.- Malformed
:pair→400 { "error": "Invalid pair address." }. Any read failure →503 { "error": "Pool reserves unavailable. Retry shortly." }. Caching:public, max-age=5, s-maxage=5.
Burns, ecosystem stats, and charts
GET /api/burns
Single source of truth for "total burned" — any Transfer to the zero address (treasury
buybacks, creator burns, holder burns, curve auto-buyback), across all visible Hartii tokens.
tokens lists every burned token (not just the newest), and totalWei always equals the
sum of tokens[].burnedWei; a hidden token leaves both together. Each figure equals the token's
on-chain 1e27 − totalSupply() (verified 2026-10-05).
{
"totalWei": "584258400700235134958323460",
"events": 130,
"tokenCount": 10,
"lastBurnAt": "2026-09-23T14:28:33.457Z",
"tokens": [
{ "address": "0x0076bc5b3a8ee996bef290a57d9a4624c5f9f9aa", "symbol": "POEM",
"burnedWei": "344412894805529134470188070" }
],
"at": "2026-09-23T14:37:15.035Z"
}tokens sorted descending by burnedWei. No query params. Caching: public, max-age=300, s-maxage=300 (5 minutes — burns are rare and the total moves slowly). No DB / D1 refusal →
totalWei: null, events: null (never a fake "0"); D1 refusal is 503, no-store.
GET /api/ecosystem-stats
Site-wide dashboard numbers.
{
"launches": 32, "totalLaunches": 32, "activeTokens": 30, "graduatedTokens": 2,
"totalHolders": 235,
"volume24hQuai": "101333640741254648971358", "volume24hWei": "101333640741254648971358",
"volume7dWei": "718597279224112168832477", "totalVolumeWei": "1075083212255358381423920",
"topTokens": [ { "address": "0x0076bc...", "name": "POEM", "symbol": "POEM", "updatedAt": "..." } ],
"topToken": { "address": "0x0035187a...", "name": "QuaiAxe", "symbol": "QAXE",
"volume24hWei": "59620483275532255828565" },
"isPartial": false,
"treasuryFees7d": "3592986396120560844151", "creationFees7d": "55000000000000000000",
"launches7d": 11, "feesPartial": false,
"burnedWei": "584258400700235134958323460", "burnEvents": 130, "burnedTokens": 10,
"lastBurnAt": "2026-09-23T14:28:33.457Z",
"at": "2026-09-23T14:37:13.893Z"
}treasuryFees7d/creationFees7dare computed from indexed trade volume × each curve's livefeeBps(owner-adjustable per curve, 0–300 bps) and the factory's livecreationFee()— never a hardcoded assumption.feesPartial: truemeans at least one curve's fee rate couldn't be read, so the totals are a floor, not the exact figure.burnedWei/burnEvents/burnedTokens/lastBurnAtmirror/api/burns.- No query params. Caching:
public, max-age=300, s-maxage=300. D1 refusal →503,no-store.
GET /api/volume-series
Per-UTC-day traded volume for the dashboard chart.
| Query param | Type | Default | Notes |
|---|---|---|---|
days | int | 14 | Clamped 1–90. |
{
"network": "mainnet",
"days": [
{ "day": "2026-09-21", "quaiWei": "93375527322478099743270" },
{ "day": "2026-09-22", "quaiWei": "324034706977607660248126" },
{ "day": "2026-09-23", "quaiWei": "33757681123253474128401" }
],
"available": true,
"truncated": false,
"at": "2026-09-23T14:37:38.046Z"
}available: false means the series couldn't be read at all (render "unavailable," not a
zero-line); a day genuinely worth "0" is a real fact and is shown as such. truncated: true
adds a note field warning the earliest days may be undercounted (more trades fell in the window
than the query's row cap). Caching: public, max-age=120, s-maxage=120.
GET /api/multi-trending
Four curated lists in one call: new launches, about-to-graduate, short-term movers, and 24h volume leaders.
{
"network": "mainnet",
"new": [ { "...": "token fields", "sparkline": [{ "t": 1790121600, "priceQuai": 0.0011 }], "badge": "NEW" } ],
"graduating": [ { "...": "token fields", "liquidityProgress": 0.62, "sparkline": [...], "badge": "SOON" } ],
"snipers": [ { "...": "token fields", "priceChange1h": 8.4, "sparkline": [...], "badge": "HOT" } ],
"movers": [ { "...": "token fields", "volume24hQuai": "101333...", "sparkline": [...], "badge": "MOVER" } ],
"partial": false,
"at": "..."
}new: launched in the last 24h, newest first, top 20.graduating:status !== 'graduated'and sellout progress ≥ 40%, sorted by progress, top 20.liquidityProgressis a float0..1.snipers: positive 1h price change from candles, sorted descending, top 20.priceChange1his a percent number.movers: ranked by the indexer-maintainedvolume24hWei, top 20.volume24hQuaiis a decimal QUAI string (not wei — already divided).- Each list is capped at 20 regardless of total token count.
sparklineentries are{ t: <unix seconds>, priceQuai: <float> }— plotting only. - No query params. Caching:
public, max-age=60, s-maxage=60. D1 refusal →503,no-store.
GET /api/graduations
Monotonic event feed (sequence-cursor, not timestamp) — built for sync consumers, not display.
| Query param | Type | Default | Notes |
|---|---|---|---|
after | non-negative int (as string) | 0 | Sequence cursor from a previous response. |
limit | int | 50 | Hard-capped at 50. |
{
"items": [
{ "sequence": 1, "address": "0x0035187a...", "name": "QuaiAxe", "symbol": "QAXE",
"blockNumber": 10064744, "graduatedAt": "2026-09-12T21:34:06.013Z", "txHash": null }
],
"hasMore": false,
"at": "2026-09-23T14:37:45.232Z"
}sequence is a monotonic integer independent of block number (multiple graduations can share a
block) — poll with after=<last item's sequence>. graduatedAt is when this app's indexer first
observed the event (or, for a backfilled gap, when the heal ran) — not necessarily the exact
on-chain block timestamp. Caching: always no-store — this route is never cached, by design,
so a client's cursor position stays exact.
GET /api/categories
| Query param | Type | Notes |
|---|---|---|
category | string | Optional; must be one of memecoin, utility, art, gaming, defi, social or it's ignored. When valid, adds addresses (token list) and filterCategory to the response. |
{
"categories": [ { "category": "memecoin", "count": 0 }, "..." ],
"defaults": ["memecoin", "utility", "art", "gaming", "defi", "social"],
"at": "2026-09-23T14:37:59.442Z"
}Caching: public, max-age=15, s-maxage=15. (POST /api/categories exists to assign a category to
a token but requires the admin bearer token — see operator-only routes.)
Comments, reactions, flags (wallet-signed writes)
These are public routes, but writes require an EIP-191 personal_sign from the acting wallet —
no accounts, no sessions, no API key. The message format is fixed per route (see below) so a
client can construct and sign it identically to the frontend. Use quais, not ethers, to sign
and to recover.
GET /api/token/:addr/comments / POST /api/token/:addr/comments
GET returns the latest 50 comments, newest first, no-store. Hidden/unknown token → 200 { items: [] }.
POST body: { wallet, text, ts, signature }. Server-side rules:
text≤ 280 chars, non-empty.ts(ms epoch) must be within 10 minutes in the past / 60 seconds in the future of the server clock, or the request is rejected as expired (closes the replay window).- Signed message:
`hartiilabs:comment:<tokenAddress lowercase>:<ts>:<text>`. - The recovered signer must equal the claimed
wallet— the client'swalletfield is never trusted alone. - Rate limits (per isolate, sliding window): 10 requests/min per IP, 4/min per wallet.
- Success:
201 { ok: true, comment: {...} }.
GET /api/comment-reactions?token=0x… / POST / DELETE /api/comment-reactions
Emoji reactions on a comment. Allowed emoji: 👍 ❤️ 🔥 🚀 😂 👀 — any other value is rejected.
POST/DELETE body: { tokenAddress, commentWallet, commentCreatedAt, wallet, emoji, ts, signature }.
Signed message: `hartiilabs:react:<token>:<commentWallet>:<commentCreatedAt>:<emoji>:<ts>`.
Same 10 min/60 s timestamp skew rule as comments. Rate limits: 20/min per IP, 10/min per wallet.
DELETE toggles a reaction off. Both respond { ok: true } (DELETE also returns deleted).
no-store throughout.
POST /api/flag-token
Community moderation — anyone with a wallet can flag a token for human review (never auto-hides).
Body: { wallet, tokenAddress, reason, description, ts, signature }. reason must be one of
scam, rug, misleading, spam, other; description ≤ 500 chars. Signed message:
`hartiilabs:flag:<tokenAddress>:<ts>:<reason>`. Same timestamp-skew rule. Rate limits: 10/min
per IP, 4/min per wallet, and a separate cap of 5 flags per wallet per day. Success: 201 { ok: true, createdAt }.
POST /api/token/:addr/update-meta, POST /api/token/:addr/set-logo
Creator-only edits (recovered signer must equal the token's on-chain-indexed creator, not an
admin token). update-meta edits description/socials/style; set-logo uploads a new logo
image (binds the signature to a SHA-256 digest of the uploaded bytes, so a captured signature
can't be replayed with different image bytes). Both use the same 10 min/60 s timestamp rule and
10/min-IP + 4/min-wallet rate limits. See Integration recipes if you need the
exact message formats — these are creator-tooling routes, not typically needed by a read-only
integration.
POST /api/token/announce
Public, rate-limited (5/min/IP) insert-only route: given a launch txHash
(/^0x[0-9a-f]{64}$/i), verifies the receipt succeeded and contains a TokenLaunched log from
the factory, then inserts the token row if it doesn't already exist (never overwrites
creator-edited metadata on a re-announce). Exists so a freshly-launched token appears immediately
rather than waiting for the next indexer tick.
Media
GET /api/token-logo/:addr
Serves a token's logo image. :addr must be a 0x + 40-hex address.
For list-sized avatars, append size=thumb to the supplied logoUrl, preserving its existing query parameters. When available, this returns a 128 × 128 WebP thumbnail. If no thumbnail exists, it returns the original image with a ten-minute edge cache so a later thumbnail can replace it. Omit size for the full image. Thumbnail responses use a separate, versioned cache key; clients do not need to add an internal cache-version parameter.
Content-Type image/png (or whatever was stored). Resolution order: edge cache → KV bytes →
IPFS gateway fetch (self-heals into KV on success). Verified live:
curl -sI https://hartiilabs.com/api/token-logo/0x0035187a7660f595d93cd53a4d16c635d6cffc8f
# Content-Type: image/png
# Cache-Control: public, max-age=86400, s-maxage=604800, immutableA hit is cached immutable for a week at the edge — the ?v=<updatedAt> query string on the
logoUrl field elsewhere in this API is what busts that cache on a logo replacement, so always
use the logoUrl the API gives you rather than constructing this path yourself. A miss (hidden,
nonexistent, or no bytes anywhere) is a cached 404, public, max-age=60, s-maxage=300.
GET /api/og/:addressOrTicker
Live 1200×630 PNG share card (candlestick price chart rendered server-side). Accepts an address
or ticker, with or without a .png suffix. Content-Type image/png, Cache-Control: public, max-age=300. On any failure (unknown token, render error) it redirects (302) to a static
fallback image rather than ever returning a broken image response — safe to embed in an
<img src> unconditionally.
GET /api/badge/:ticker
Compact ~220×40 SVG price badge (name, price in QUAI, 24h change%). Ticker only, no address form.
curl -sI https://hartiilabs.com/api/badge/QAXE
# Content-Type: image/svg+xml; charset=utf-8
# Cache-Control: public, max-age=60, s-maxage=300Unknown ticker → 404 with a "not found" placeholder SVG body (still an image/svg+xml response,
so it renders as an image, not a broken link); a real backend failure → 500 with an "unavailable"
placeholder SVG, so the two failure modes are visually distinguishable if you look closely, but
both are still safe to drop into an <img> tag.
POST /api/token-meta
Public draft-metadata endpoint used by the launch flow before a token exists on-chain (name,
symbol, description, socials, logo upload, Turnstile bot-check, optional AI moderation
screening that is informational only and never blocks). Since 1.23.0 it fails closed: with no Turnstile
secret configured it answers 503 turnstile_not_configured; logos are capped at 1 MiB; unlaunched drafts and
their logos expire after 7 days; and daily quotas apply per wallet, per day for logos and per day for writes. Not typically needed by a read-only
integration — see the source or Contracts → Launching a token if you're
building your own launch UI.
Live push
GET /api/live/ws
WebSocket upgrade — push channel for new trades/burns, an alternative to polling. Subscribe by
connecting to wss://hartiilabs.com/api/live/ws?channel=<channel> and sending
{"type":"subscribe","channel":"<channel>"} frames for additional channels on the same socket;
{"type":"unsubscribe","channel":"..."} to drop one. Every frame carries a protocol version v
(1, unchanged by the 1.1.0 latency programme). The server sends a hello frame with a
per-channel seq on subscribe, then heartbeat frames, then event frames carrying an incrementing
seq per channel. Heartbeat cadence depends on what's subscribed: roughly every 25 seconds while
only non-curve channels are watched (e.g. just the sitewide trade feed), or on every watcher tick —
at most 15 seconds, often sooner — once at least one curve channel has a subscriber. If a client's
last-seen seq and an incoming frame's seq aren't consecutive, treat it as a gap and reconcile
from the indexed REST API (a message was missed — the socket does not replay history). A silent
socket (no heartbeat for ~45s) should be treated as dead and reconnected with backoff.
Trade event frames carry an additive blockTime field as of 1.1.0: the block's unix timestamp in
seconds (a number), or absent/null when the block header hasn't been seen yet (it fills in on
a later tick once it has). This is a different value from the ISO-string blockTime returned by
the REST trade routes above — don't conflate the two formats. Pushed (unconfirmed) trade frames also
carry hubAt — the hub's own clock (ms) when the chain's log notification reached it — so a client
can measure hub→client fan-out without caring about block timestamps.
As of 1.2.0 the global channel additionally carries head frames — one per chain block the hub
sees ({ "blockNumber": 10410125, "timestamp": 1790974238, "hubAt": 1790974238110 }, cadence
≈ 2–8 s). They are what hartiilabs.com uses to check a pending trade's receipt the moment its block
lands instead of waiting for the next poll; an integrator can do the same (call
GET /api/token/<address>/trades or your own receipt lookup on each head). Unknown frame
types must be ignored — v stays 1. Also as of 1.2.0 the hub pushes trades for every launched
curve to the global channel (not only curves some open token page watches) and lands each pushed
trade in the indexed REST routes within ~1 s (POST /api/live/ingest, bearer-gated, internal —
not a public write route), so a fresh GET /api/token/<address>/trades is at most a block or so
behind the chain while the hub's upstream subscription is healthy. Rows that arrived this way and
have not yet been confirmed by the indexer carry provisional: true (1.3.0; the field is absent on
confirmed rows); the indexer removes a provisional row only if the transaction's own receipt shows
it was dropped by a reorg.
Server kill switch: if HARTII_LABS_LIVE_HUB_ENABLED=false, the endpoint answers 503 and every
consumer should fall back to polling the REST routes above — build your integration to do this
gracefully rather than depending on push being available.
GET /api/live/publish — health check (unauthenticated)
{ "enabled": true, "ok": true, "network": "mainnet", "sockets": 2, "curves": 1, "lastScannedBlock": 10410005,
"upstream": { "enabled": true, "state": "open", "lastHeadAt": 1790974248865, "lastFrameAt": 1790974248865,
"curves": 1, "lastError": null },
"knownCurves": { "count": 138, "loadedAt": 1790975000000 },
"lastIngestError": null }Cache-Control: no-store. upstream (1.1.0) is the hub's own subscription to the Quai RPC
websocket — state: "open" with an advancing lastHeadAt means trades are being pushed per block;
"closed" while curves > 0 means the hub is on its 10 s safety poll. knownCurves (1.2.0) is the
full launched-curve set the hub watches for the sitewide feed; lastIngestError (1.2.0) is the last
failure of the D1 fast lane, null when healthy. POST /api/live/publish (bearer-gated, operator only) is the manual
publish path for ops/smoke-testing — normal indexer publishing doesn't go through this HTTP route.
Status
GET /api/health
Liveness check — ok: true always means the Function itself ran; d1Reachable is the separate,
honest signal for the database dependency.
{ "ok": true, "version": "1.1.0", "d1Reachable": true, "network": "mainnet",
"indexer": { "present": true, "factoryAddr": "0x001AF1BbB40807fcb99C9Eeaa49dF5E91e7Efd42" },
"at": "2026-10-02T21:40:20.046Z" }Factory coverage (since 1.23.0). The response also carries factories: scanned (the factory
addresses the indexer reads), uiLaunch (the factories the site launches through) and missing. If a
factory the site launches through is not scanned, the route answers 503 with ok:false and the missing
address, because tokens launched there would never be indexed; /api/status then reports degraded.
Cache-Control: no-store — always live. version (added in 1.1.0) is the deployed app's SemVer
version — the same number as the <meta name="application-version"> tag on every page and the
vX.Y.Z git tag of the commit that is live. The app's CHANGELOG.md (in the hartii-labs repo)
lists what each version changed; MAJOR bumps are the only ones that can break an integration
(public API / live-protocol changes), MINOR adds capability, PATCH fixes.
GET /api/status
Richer status surface — deliberately only directly observable facts, no risk verdicts.
{
"status": "ok",
"at": "2026-09-23T14:37:21.440Z",
"d1Reachable": true,
"network": "mainnet",
"indexer": { "present": true, "factoryAddr": "0x001AF1BbB40807fcb99C9Eeaa49dF5E91e7Efd42",
"lastIndexedAt": "2026-09-23T14:36:40.818Z", "lastIndexedBlock": 10249946 },
"lastTradeAt": "2026-09-23T14:28:32.246Z",
"rpc": { "ok": true, "chainId": "0x9", "latencyMs": 500 }
}status is "ok" only when D1 is reachable, the RPC health-check succeeds, and the indexer is
configured — otherwise "degraded". Useful as a single check before trusting freshness-sensitive
data. Caching: public, max-age=30 (no s-maxage).
GET /api/quai-price
QUAI/USD reference price — MEXC primary, CoinGecko fallback.
{ "usd": 0.010293, "source": "mexc", "at": 1790174223533 }Caching: public, s-maxage=120, max-age=60. Both sources failing → 503 { "error": "price unavailable" }, no-store, with no CORS header on that specific error branch.
Operator-only routes
These exist and are documented here for completeness; they require a bearer secret you don't
have and don't need for read integration. Three separate secrets, each compared with a
constant-time check, each accepted as either Authorization: Bearer <token> or the fallback
header x-hartii-labs-indexer-token: <token>:
| Secret | Guards | Gate |
|---|---|---|
HARTII_LABS_INDEXER_TOKEN | POST /api/indexer-run, POST /api/admin/repin-logos | requireIndexerAuth |
HARTII_LABS_ADMIN_TOKEN | POST /api/admin/hidden, GET /api/admin/hidden, POST /api/admin/comments, POST /api/admin/set-logo, POST /api/categories, GET /api/beacon?admin=1 | requireAdminAuth |
HARTII_LABS_LIVE_TOKEN | POST /api/live/publish | requireLiveAuth |
Leaking one never grants the others — they're intentionally independent secrets. An unauthorized
request gets 401 { "error": "Unauthorized <kind> request." }; an unconfigured secret (missing or
under 16 chars) gets 503 { "error": "<Kind> token is not configured." }
(functions/_lib/auth.js). Verified live on a route this doc's research never had to POST to
(GET /api/admin/hidden is itself bearer-gated, so a plain GET with no Authorization header
already exercises the same 401 path — POST /api/indexer-run//api/admin/* were never called,
per instructions):
curl https://hartiilabs.com/api/admin/hidden
# 401 {"error":"Unauthorized admin request."}The 401 shape for POST /api/indexer-run and the other bearer-gated POST routes above is taken
from functions/_lib/auth.js (the same requireIndexerAuth/requireAdminAuth/requireLiveAuth
code path GET /api/admin/hidden exercises above) rather than a live POST — those routes were
deliberately never called.
POST /api/indexer-run is the indexer scan itself — see Indexer for what it does.
POST /api/admin/hidden hides/unhides a token from every read path at once (the single moderation
gate — see Indexer → freshness).
POST /api/admin/set-logo lets the operator replace any token's logo. GET /api/beacon?admin=1
lists aggregated client-error fingerprints from the site's own error beacon (POST /api/beacon,
public, fire-and-forget, always 204, strips wallet addresses before storing — not a data API,
not documented further here).
Guest chat
The public chat dock sends without a wallet connection or signature.
POST /api/chat/guest-session: no wallet or identity fields required. Returns{ok, token, wallet, exp};walletis a server-generatedguest:<32 hex>subject, never a verified blockchain address. Caller-supplied wallet, name or bot identity cannot choose the subject.POST /api/chat:Authorization: Bearer <token>and{text}. Guest names are server-generated. Plain-text, length, scam/link and rate rules apply.POST /api/chat/delete: the same bearer header and{id}; only that session's author can delete.POST /api/chat/report: the same header and{id}. Guest reports are recorded for moderation; only distinct verified-wallet reports count toward automatic hiding.DELETE /api/chat/session: revokes the presented bearer token.
Only token hashes, public guest subjects and expiry are stored server-side in existing ephemeral KV. The browser keeps its token/subject/expiry locally. Session expiry or reset ends deletion authority over earlier guest messages. No raw IP or user-agent storage is added; temporary salted in-memory counters are best-effort per-isolate rate controls. Messages are public and can be copied by visitors. Application records expire within24h; the denormalized cache expires by its oldest included message deadline and may disappear sooner.
The legacy signed wallet/bot message API continues to prove its own wallet identity. A guest token has no wallet, bot, administrative or transaction-signing authority. Token-specific comments use their separately documented signing protocol.