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

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.

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.

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. Hidden/unknown token → 200 { items: [] }. Caching: public, max-age=15, s-maxage=15.

GET /api/token/:addr/holders

Query paramTypeDefaultNotes
limitint100Clamped 1–100.
json
{
  "address": "0x0035...",
  "items": [
    { "network": "mainnet", "tokenAddress": "0x0035...",
      "holder": "0x0033333333333333333333333333333333333333",
      "balance": "107241610597565238455244285", "updatedAt": "2026-09-17T18:43:40.590Z" }
  ],
  "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. 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" }
  ],
  "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.

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.

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.

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.

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, 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. The server sends a hello frame with a per-channel seq on subscribe, then heartbeat frames roughly every 25 seconds, then event frames carrying an incrementing seq per channel. 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.

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": 0, "lastScannedBlock": 10249979 }

Cache-Control: no-store. 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, "d1Reachable": true, "network": "mainnet",
  "indexer": { "present": true, "factoryAddr": "0x001AF1BbB40807fcb99C9Eeaa49dF5E91e7Efd42" },
  "at": "2026-09-23T14:37:20.046Z" }

Cache-Control: no-store — always live.

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

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.