Hartii developer docs

HartiiLabs

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^18 wei, same scale as ETH. These are too large for a JS number past ~15 digits — parse with BigInt(value), not Number(value). A handful of fields break this convention on purpose and are called out explicitly below (wallet/:wallet/stats's volumeQuai, and chart sparkline arrays, 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 answers OPTIONS with 204. (GET /api/quai-price is the one exception — see its entry.) Verified live:
    text
    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/DYNAMIC in response headers) — polling faster than a route's max-age just re-serves the same cached body.
  • Errors are honest, not fabricated. A route that cannot reach D1 answers 503 { "error": "temporarily unavailable", "retryable": true } with Cache-Control: no-store — never a fake empty/zero result. A route that reads D1 successfully and finds nothing answers 200 with 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.js exists 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 paramTypeDefaultNotes
sortstringtrendingOne of trending, new, volume, price, change, marketcap, graduation, holders, age, latest; unknown values silently fall back to trending.
limitint50Clamped 1–100.
cursorstringnoneOffset-based; the opaque string from a previous response's nextCursor.
viewstringgridgrid or table — echoed back, a client display hint only.
venuestringall venueshartii for native launches; quainance for Quainance-launched tokens. Missing or other values return both.
spark1 to enableoffAttaches 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):

json
{
  "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):

FieldTypeNotes
address, creator, curveAddressstringLowercase 0x… addresses.
statusstringactive or graduated.
supplystring|nullMinted supply as recorded at launch; null for every current token. Use totalSupplyWei.
totalSupplyWeistring|nullLive 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.
burnedWeistring|nullAll-time burns; null means never computed, not zero.
lastPriceWei, raisedWei, tokensSold, curveSupply, poolQuaiWei, volume24hWeistring|null (wei)null until the indexer's summary pass has run for this token.
marketCapWeistring|null (wei)lastPriceWei × totalSupplyWei / 1e18. null only while lastPriceWei is unknown. See Pricing a token.
tradeCount24hint|null
change24h / priceChange24hnumber|nullPercent, same value under both keys.
trendingScorenumber|nullWash-resistant score, see Indexer.
holderCountint|nullDistinct addresses with non-zero balance.
ageSecondsint|nullDerived from launchedAt at response time.
logoUrlstring|nullRelative path to GET /api/token-logo/:addr; null if no logo set.
metaobject|nullRaw 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.

json
{
  "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 paramTypeDefaultNotes
limitint501–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.

json
{
  "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 paramTypeDefaultNotes
tfstring5mOne of 1m, 5m, 15m, 1h, 4h, 1d.
limitint720Newest-N buckets, capped at 2000.
json
{
  "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 paramTypeDefaultNotes
limitint100Clamped 1–100 — this is also the hard per-call row cap; limit above 100 is silently clamped down, it does not widen a page.
offsetnon-negative int0Added 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.
json
{
  "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.

json
{
  "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 paramTypeDefault
limitint30 (cap 50)
json
{
  "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 paramTypeDefaultNotes
limitint50Clamped 1–200.
cursorstring (ISO timestamp)noneFrom a previous response's nextCursor; strictly-less-than on block_time.
sidebuy|sellnoneOptional filter; any other value is ignored.
json
{
  "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 paramTypeDefaultNotes
limitint501–100.
tokenaddressnoneOptional; must match /^0x[0-9a-fA-F]{40}$/ or it's ignored.
json
{
  "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.

json
{
  "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.

json
{
  "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:

priceSourceWhenpricePool
live-reserve-referenceGraduated native HartiiLabs tokennull — reads GET /api/token/:addr/reference's midWei directly.
quainance-pool-reservesQuainance-launched tokenThe discovered WQUAI pair address priceQuai was read from.
indexed-last-tradePre-graduation native tokennull — this token's single most recent trade's priceWei (the only source pre-v1).
unavailableA live/RPC read failednull — 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.

json
{
  "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/claimedTotalWei come from D1 (indexed Buy/Sell/CreatorFeesWithdrawn).
  • feeBps and claimableWei are live eth_calls to the curve contract (feeBps(), creatorFees()), bounded to the first liveReadCap curves (30) per request — beyond the cap the D1-derived fields still return, but feeBps/claimableWei/autoBuyback are null.
  • earnedToDateWei = volumeWei × feeBps × 50% (creator's half), computed in exact BigInt math from the two numbers above (only when both are known).
  • autoBuyback: true marks a V2 curve whose "claim" call burns the pot instead of paying the creator — its balance is reported separately as totals.queuedBurnWei, never folded into totals.claimableWei, so the two are never presented as one interchangeable figure.
  • Any field that could not be read live is null, not 0 — "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):

json
{
  "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.mid is the depth-weighted average of every venue that has a price, weighted by each venue's depthQuaiWei (equal-weighted instead if every priced venue reports zero depth). low/high widen per venue by a staleness penalty (ageBlocks × 5 bps/block by default, capped at 5,000 bps) before taking the overall min/max — a fresher read narrows the band, a stale one widens it. confidenceBps is the wider of (mid−low)/(high−mid) as a fraction of mid, in bps.
  • flags can include drift:<venue> (that venue's price sits more than 100 bps from mid) and thin (combined venue depth is below a 1,000 QUAI floor). The example above is genuinely flagged drift on 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[].weight is each venue's share of the mid weighted average (always sums to 1 across priced venues; an unpriced venue like external/conversion above gets 0). ageBlocks is null for 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.

json
{
  "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/status come straight from the pair registry (src/data/mmPairs.json) — see Market-maker tools › pair registry. status stays "planned" for every pair today — no vault is funded.
  • preview: true and maker: null are 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.
  • quote is 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: false plus a non-empty reasons array 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 configured maxConfidenceBps/maxSpreadBps (400 / 300), so the preview correctly flags itself as unexecutable. size/sizes.bid/sizes.ask are decimal-QUAI depth caps per side (half of depthTargetQuai each when skewBps is 0, shifted toward the lighter side otherwise). If the reference read fails for a pair, reference/venues/blockNumber fall back to null/[]/ null and quote reflects 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.

json
{
  "paused": false,
  "pairs": [{ "pair": "QAXE/WQUAI", "paused": false, "reason": null, "lastActionAt": null }],
  "limits": { "maxPairShareBps": 3000, "maxDailyLossBps": "200", "maxActionsPerDay": 1000 },
  "lastRunAt": null,
  "mode": "dry"
}
  • paused is the global kill switch (mm_state.global_paused); each pairs[] entry's own paused/reason is that pair's latched state (see Market-maker tools › risk guard and the kill switch) — reason is one of admin-pause, pair-share-limit, daily-loss-limit, daily-action-limit, vault-paused, or null when not paused.
  • limits mirrors the risk guard's hard-clamped constants — maxPairShareBps: 3000 (30%), maxDailyLossBps: "200" (2%, a string — risk.js's MAX_DAILY_LOSS_BPS is a BigInt), maxActionsPerDay: 1000. These are the defaults; a persisted mm_state.limits override (still capped by the same hard constants) would replace them.
  • mode is "not-deployed" when there is no HARTII_LABS_DB binding and no valid HARTII_LABS_MM_VAULT; otherwise "live" only when MM_DRY_RUN === "false" (exact lowercase-string match) and HARTII_LABS_MM_VAULT is a well-formed 0x + 40-hex address; otherwise "dry". On hartiilabs.com today this is "dry".
  • lastRunAt is mm_state.last_run_at, written by the executor at the end of every cron cycle — null only if the Worker has never completed a cycle. A fresh value here does not by itself mean a maker is quoting; cross-check mode and each pair's own status.
  • 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 paramTypeDefaultNotes
limitint50Clamped 1–100.
json
{ "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 }
  • kind is one of rebalance-buy, rebalance-sell, add-liquidity, or skip (see Market-maker tools › the executor for what each means and the full list of skip reasons, e.g. unfunded, pre-graduation, confidence-too-wide, reference-drift, risk-paused, vault-paused, pending-action-under-10-minutes, no-action-needed).
  • params is whatever the engine decided before any signing — amountQuaiWei/amountTokenWei wei-integer strings, empty {} for a skip.
  • mode is "dry" or "live" for that specific recorded action (reflects the gate at the moment it ran, not the current moment); status is "planned" (dry), "skipped", or for a live action "pending" → "success"/"failed". tx_hash is null until 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.

json
{
  "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_VAULT is an optional environment variable — the address of the wallet acting as the market-maker vault. Unset, empty, or not a well-formed 0x + 40-hex address → configured: false, vault: null, funded: false, quai: "0", tokenBalance: "0", lpBalance/lpValueQuai/lpShareQuai: "0", and inventory reports 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.
  • configured is true/false purely from whether HARTII_LABS_MM_VAULT parses as an address — it does not mean funded. When configured: true but the balance reads haven't resolved yet (or failed), funded is null (unknown), never a guessed false; once resolved, funded is true only if QUAI, token, or LP balance is actually non-zero.
  • When the vault is configured, the route does live reads per pair (quai_getBalance on the vault, the token's balanceOf(vault), and the HartiiSwap LP token's balanceOf(vault) if a pool exists): on success readable: true and quai/tokenBalance are real decimal-QUAI balances; on a chain-read failure readable: false and error: "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/pairAddress are the same discovered HartiiSwap WQUAI pool address (the dashboard reads lpPair, the engine/tests read pairAddress); lpBalance is the vault's LP token balance; lpValueQuai/lpShareQuai are 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; lpSource is "hartiiswap-reserves" when resolved, null otherwise.
  • inventory.valueQuai/ratio/inBand/breach/rebalance are the wallet-only 50/50 ± band computation documented in Market-maker tools › inventory bands, marked to the pair's own reference mid. inventory.lpShareQuai (same value as the top-level lpShareQuai) and inventory.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 paramTypeDefaultNotes
limitint50Clamped 1–100.
cursorint0Offset-based (not the nextOffset idiom the holders route uses — this one's field is literally named cursor, and it's an offset either way).
json
{ "items": [], "nextCursor": null, "limit": 50, "at": "2026-10-05T12:00:00.000Z" }

A populated row (shape only — not a real fill):

json
{
  "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.

json
{
  "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 realisedQuai on the amount consumed. Fees are tracked in feesQuai and already netted out of inventory.quai, but not subtracted from realisedQuai/unrealisedQuai — show the gross trading result and the fee drag as two separate numbers, never fold one into the other.
  • unrealisedQuai is null (not 0) for any pair still holding a non-zero token balance whose current reference price couldn't be read — "unknown," never fabricated. The top-level unrealisedQuai is null the moment any one pair's is null.
  • Every amount here is a wei integer string, same exception called out at the top of this section — realisedQuai, unrealisedQuai, feesQuai, and inventory.quai/inventory.token are not decimal-scaled despite the naming.
  • No DB binding → 200 with 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=5 on 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 paramTypeDefaultNotes
pairstring""Exact pair name, e.g. QAXE/WQUAI. No validation against the registry — an unknown pair just returns items: [].
json
{ "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/inventoryQuaiValue are raw wei integer strings, same exception as /api/mm/fills//api/mm/pnl above. Each row is cumulative as of that UTC day, not that day's delta — chart the values directly, never sum them. inventoryQuaiValue/unrealisedQuai are null (not 0) 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 paramTypeNotes
tokenaddressOptional — returns that one token's request(s) instead of a page.
offsetintDefault 0, used only without token.
json
{ "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]) — 9 is Quai's chain id, part of the signed payload.
  • token/pair must be 0x + 40-hex and nonzero; pair is 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, and wallet must equal the token's on-chain-indexed creator — 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's accepted/declined decision. status starts "requested".
  • Rate limit: 10/minute per IP.
  • Errors: 400 (bad body, or an expired/future ts), 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).

json
{
  "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 :pair against the live HartiiSwap factory (factory() call, cross-checked against the factory's own getPair) before trusting anything else about it — 404 { "error": "Not a HartiiSwap pool." } otherwise.
  • tvlQuaiWei is 2 × the pool's QUAI-side reserve (live getReserves() read) — always available once the pool is verified.
  • feeAprBps is null on 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 the reason above instead. complete: true/coverageSeconds would indicate seven verified days once a swap-history collector exists.
  • lpReserve (non-null only for a graduated V4 curve) describes that token's separate on-curve LP-reserve reward stream — explicitly labelled appliesTo: '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).

json
{
  "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.

json
{
  "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/creationFees7d are computed from indexed trade volume × each curve's live feeBps (owner-adjustable per curve, 0–300 bps) and the factory's live creationFee() — never a hardcoded assumption. feesPartial: true means at least one curve's fee rate couldn't be read, so the totals are a floor, not the exact figure.
  • burnedWei/burnEvents/burnedTokens/lastBurnAt mirror /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 paramTypeDefaultNotes
daysint14Clamped 1–90.
json
{
  "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.

Four curated lists in one call: new launches, about-to-graduate, short-term movers, and 24h volume leaders.

json
{
  "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. liquidityProgress is a float 0..1.
  • snipers: positive 1h price change from candles, sorted descending, top 20. priceChange1h is a percent number.
  • movers: ranked by the indexer-maintained volume24hWei, top 20. volume24hQuai is a decimal QUAI string (not wei — already divided).
  • Each list is capped at 20 regardless of total token count. sparkline entries 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 paramTypeDefaultNotes
afternon-negative int (as string)0Sequence cursor from a previous response.
limitint50Hard-capped at 50.
json
{
  "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 paramTypeNotes
categorystringOptional; 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.
json
{
  "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's wallet field 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 }.

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:

text
curl -sI https://hartiilabs.com/api/token-logo/0x0035187a7660f595d93cd53a4d16c635d6cffc8f
# Content-Type: image/png
# Cache-Control: public, max-age=86400, s-maxage=604800, immutable

A 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.

text
curl -sI https://hartiilabs.com/api/badge/QAXE
# Content-Type: image/svg+xml; charset=utf-8
# Cache-Control: public, max-age=60, s-maxage=300

Unknown 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)

json
{ "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.

json
{ "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.

json
{
  "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.

json
{ "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>:

SecretGuardsGate
HARTII_LABS_INDEXER_TOKENPOST /api/indexer-run, POST /api/admin/repin-logosrequireIndexerAuth
HARTII_LABS_ADMIN_TOKENPOST /api/admin/hidden, GET /api/admin/hidden, POST /api/admin/comments, POST /api/admin/set-logo, POST /api/categories, GET /api/beacon?admin=1requireAdminAuth
HARTII_LABS_LIVE_TOKENPOST /api/live/publishrequireLiveAuth

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):

text
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}; wallet is a server-generated guest:<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.

Quai Network mainnet · chain 9 · Cyprus-1. Figures marked "read on" a date were read from the chain that day; re-read before relying on them.