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. |
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.
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. |
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.
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. 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.
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" }
],
"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 (its single most recent trade's priceWei). 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. 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 (v0, read-only)
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 in v0.preview: trueandmaker: nullare constant in v0 — there is no code path that sets either to anything else yet.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/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,
"funded": false,
"readable": false,
"quai": "0",
"tokenBalance": "0",
"reference": { "...": "same reference shape, or null" },
"inventory": { "valueQuai": "0", "ratio": 0.5, "inBand": true, "breach": null, "rebalance": null },
"error": null
}],
"vault": null,
"at": "2026-10-04T12: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 → every position is honestly zeroed:vault: null,funded: false,quai: "0",tokenBalance: "0", andinventoryreflects an empty, in-band 50/50 book. This is the default in v0 — no vault is configured on hartiilabs.com today, so this is what a live call returns.- When the vault is configured,
funded: trueand the route does two live reads per pair (quai_getBalanceon the vault, and the token'sbalanceOf(vault)): on successreadable: trueandquai/tokenBalanceare the real decimal-QUAI balances; on a chain-read failurereadable: falseanderror: "Could not read the configured market-maker vault right now."—quai/tokenBalancefrom the last-known shape are not substituted, so a reader never confuses a stale balance for a fresh one. inventoryis the same 50/50 ± band computation documented in Market-maker tools › inventory bands, marked to the pair's own referencemid.- 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
fills exist in v0 (nothing quotes yet), so 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-04T12: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-04T12:00:05.000Z",
"priceQuai": "2239658519168809", "amountToken": "1000000000000000000000",
"amountQuai": "2239658519168809000", "feeQuai": "6718975557506427",
"inventoryQuaiAfter": "...", "inventoryTokenAfter": "...",
"maker": null, "source": null, "createdAt": "2026-10-04T12: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. 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.
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 tokens.
{
"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.
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, optional Turnstile bot-check, optional AI moderation screening that is informational only and never blocks). 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" }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).