Base URL: https://hartiilabs.com. All routes below are functions/api/** (Cloudflare Pages
Functions) backed by D1. There are no API keys — every route documented as "public" is
unauthenticated and free to call.
Conventions
- All monetary/token amounts are wei-scaled decimal strings, e.g.
"raisedWei": "165825527731240739544166". 1 QUAI =10^18wei, same scale as ETH. These are too large for a JSnumberpast ~15 digits — parse withBigInt(value), notNumber(value). A handful of fields break this convention on purpose and are called out explicitly below (wallet/:wallet/stats'svolumeQuai, and chartsparklinearrays, which are QUAI-scaled floats for plotting only — never treat them as exact). - CORS is wide open: every JSON route responds
Access-Control-Allow-Origin: *,Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS,Access-Control-Allow-Headers: Content-Type, and answersOPTIONSwith204. (GET /api/quai-priceis the one exception — see its entry.) Verified live:curl -sI -H "Origin: https://example.com" https://hartiilabs.com/api/burns # Access-Control-Allow-Origin: * (same for every Origin — there is no allowlist) - Caching: every route sets an explicit
Cache-Control(see each entry) and most are also CDN-cached (cf-cache-status: HIT/MISS/DYNAMICin response headers) — polling faster than a route'smax-agejust re-serves the same cached body. - Errors are honest, not fabricated. A route that cannot reach D1 answers
503 { "error": "temporarily unavailable", "retryable": true }withCache-Control: no-store— never a fake empty/zero result. A route that reads D1 successfully and finds nothing answers200with a genuinely empty shape (items: [],"totalWei": null, etc.) — that is a final answer, not a loading state. A hidden/moderated token is indistinguishable from one that was never indexed: every read path for it 404s or empty-shapes exactly like "doesn't exist." - No rate limiting on read routes.
functions/_lib/rateLimit.jsexists and is wired into the signed write endpoints (comments, reactions, flags, metadata edits — see below), but none of the GET/read routes in this reference are rate-limited. Be a good citizen anyway — see Integration recipes. - Network is always
"mainnet"— there is no other network to select.
Tokens
GET /api/tokens
Launchpad directory/feed — same endpoint the site's board uses.
| Query param | Type | Default | Notes |
|---|---|---|---|
sort | string | trending | One of trending, new, volume, price, change, marketcap, graduation, holders, age, latest; unknown values silently fall back to trending. |
limit | int | 50 | Clamped 1–100. |
cursor | string | none | Offset-based; the opaque string from a previous response's nextCursor. |
view | string | grid | grid or table — echoed back, a client display hint only. |
spark | 1 to enable | off | Attaches sparkline (7-day window of 4h candle closes, QUAI-scaled floats, oldest first) to each item. Empty array if the token has no recent candles — never a fabricated flat line. |
trending and volume (and the other ranked sorts) are computed by re-ranking an in-process
directory snapshot rather than a plain ORDER BY — trending sorts on the indexer-maintained
trendingScore (written by the indexer's summary pass, not computed per-request), volume on
volume24hWei. These ranked results are memoized per-isolate for 60 seconds.
Caching: ranked sorts (trending, volume, price, change, marketcap, graduation,
holders, age, latest) get public, max-age=60, s-maxage=60; the base path gets the default
public, max-age=15, s-maxage=15. No DB binding → 200 with items: [], partial: true. D1
refusal → 503 { "error": "temporarily unavailable", "retryable": true }, no-store.
Example (GET /api/tokens?limit=2, trimmed, one item):
{
"network": "mainnet",
"view": "grid",
"items": [{
"network": "mainnet",
"address": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
"creator": "0x0011111111111111111111111111111111111111",
"name": "QuaiAxe",
"symbol": "QAXE",
"curveAddress": "0x004bc407903a51506bcf0b1ab423958c5991c237",
"launchTx": "0x004a0045...",
"launchBlock": 9955113,
"launchedAt": "2026-09-06T13:09:12.447Z",
"supply": null,
"totalSupplyWei": "877074085029199910587818112",
"burnedWei": "122925914970800089412181888",
"meta": { "description": "...", "socials": { "website": "...", "twitter": "...", "telegram": "...", "discord": "" }, "logoKv": true, "logoCid": "Qme..." },
"logoUrl": "/api/token-logo/0x0035187a...?v=2026-09-21T20%3A54%3A33.773Z",
"description": "...",
"socials": { "website": "...", "twitter": "...", "telegram": "...", "discord": "" },
"style": null,
"status": "graduated",
"updatedAt": "2026-09-21T20:54:33.773Z",
"lastPriceWei": "3042142570835435",
"raisedWei": "0",
"tokensSold": "784000000000000000000000000",
"curveSupply": "784000000000000000000000000",
"poolQuaiWei": "165825527731240739544166",
"volume24hWei": "59620483275532255828565",
"tradeCount24h": 25,
"change24h": 44.51,
"priceChange24h": 44.51,
"trendingScore": 178861.44982659677,
"marketCapWei": "2668184411843867129265764",
"holderCount": 134,
"ageSeconds": 1474080,
"source": "hartii-launchpad",
"lastTradeAt": "2026-09-23T07:48:32.368Z"
}],
"nextCursor": "2",
"partial": false,
"at": "2026-09-23T14:37:32.135Z"
}Field notes (per item):
| Field | Type | Notes |
|---|---|---|
address, creator, curveAddress | string | Lowercase 0x… addresses. |
status | string | active or graduated. |
supply | string|null | Minted supply as recorded at launch; null for every current token. Use totalSupplyWei. |
totalSupplyWei | string|null | Live total supply = 1,000,000,000 minted − burnedWei. Matches token.totalSupply() on chain to the wei. Use this for supply and market cap — never curveSupply. |
burnedWei | string|null | All-time burns; null means never computed, not zero. |
lastPriceWei, raisedWei, tokensSold, curveSupply, poolQuaiWei, volume24hWei | string|null (wei) | null until the indexer's summary pass has run for this token. |
marketCapWei | string|null (wei) | lastPriceWei × totalSupplyWei / 1e18. null only while lastPriceWei is unknown. See Pricing a token. |
tradeCount24h | int|null | |
change24h / priceChange24h | number|null | Percent, same value under both keys. |
trendingScore | number|null | Wash-resistant score, see Indexer. |
holderCount | int|null | Distinct addresses with non-zero balance. |
ageSeconds | int|null | Derived from launchedAt at response time. |
logoUrl | string|null | Relative path to GET /api/token-logo/:addr; null if no logo set. |
meta | object|null | Raw stored metadata blob; description/socials/style are also hoisted to top level for convenience. |
GET /api/token/:addressOrTicker
Single token detail. :addressOrTicker is either a 0x + 40-hex address or a ticker symbol
(/api/token/HRT works) — first-launched token owns a symbol, first-come-first-served; later
tokens sharing a symbol are only reachable by address.
No query params. Caching: public, max-age=15, s-maxage=15. Errors: unknown/hidden token →
404 { "error": "not indexed", "address": "<param>" }; D1 refusal → 503 { "error": "temporarily unavailable", "retryable": true, "address": "<param>" }, no-store.
{
"token": { "...": "same shape as one /api/tokens item" },
"graduation_progress": { "graduated": true, "progressPct": 100 },
"at": "2026-09-23T14:37:40.152Z"
}graduation_progress.progressPct is tokensSold / curveSupply × 100 (sellout basis, matching
what the contract actually graduates on) — not a raised-QUAI percentage. A graduated token is
always 100.
Trades & candles
GET /api/token/:addr/trades
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | 1–100. |
Returns this token's trades merged with its burns into one time-ordered feed (a burn is a
Transfer to the zero address — announced beside buys/sells rather than requiring a second
request). Hidden/unknown token → 200 { items: [] }, not a 404. Caching: public, max-age=15, s-maxage=15.
{
"address": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
"items": [
{ "network": "mainnet", "tokenAddress": "0x0035...", "txHash": "0x007a...", "logIndex": 1,
"blockNumber": 10245041, "blockTime": "2026-09-23T07:48:32.368Z",
"trader": "0x0044444444444444444444444444444444444444", "side": "sell",
"quaiAmount": "3435148981866778576855", "tokenAmount": "1117215912812398093612081",
"priceWei": "3074740470908066" }
],
"at": "2026-09-23T14:37:40.785Z"
}A burn row has side: "burn", quaiAmount: null, and tokenAmount set to the wei destroyed.
GET /api/token/:addr/candles
| Query param | Type | Default | Notes |
|---|---|---|---|
tf | string | 5m | One of 1m, 5m, 15m, 1h, 4h, 1d. |
limit | int | 720 | Newest-N buckets, capped at 2000. |
{
"address": "0x0035...",
"items": [
{ "tf": "1h", "bucketStart": 1790121600, "open": "2770797937922332", "high": "2770797937922332",
"low": "2770797937922332", "close": "2770797937922332", "volumeQuai": "343194826528785000000",
"tradeCount": 1 }
],
"at": "2026-09-23T14:37:43.946Z",
"bounded": true
}bucketStart is unix seconds, ascending order. open/high/low/close/volumeQuai are wei
strings. Hidden/unknown token → 200 { items: [] }. Caching: public, max-age=15, s-maxage=15.
GET /api/token/:addr/holders
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 100 | Clamped 1–100. |
{
"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.
{
"token": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
"creator": "0x0011111111111111111111111111111111111111",
"devActivity": {
"devBuyQuai": 23268, "devSellQuai": 0, "devBuyCount": 29, "devSellCount": 0,
"lastSellTime": null, "hasSold": false, "dumpedPct": 0
},
"at": "2026-09-23T14:37:44.545Z"
}Caching: public, max-age=15, s-maxage=15.
GET /api/trades/recent
Newest trades (merged with burns) across all visible tokens — the site-wide ticker.
| Query param | Type | Default |
|---|---|---|
limit | int | 30 (cap 50) |
{
"items": [
{ "tokenAddress": "0x0076bc...", "txHash": "0x00460053...", "logIndex": 5,
"blockNumber": 10249831, "blockTime": "2026-09-23T14:28:33.457Z",
"trader": "0x0044bed4...", "side": "burn", "quaiAmount": null,
"tokenAmount": "1963537265200000000000000", "symbol": "POEM",
"curveAddress": "0x0036790c..." }
],
"at": "2026-09-23T14:37:53.462Z"
}Caching: public, max-age=15, s-maxage=15.
GET /api/trades/:wallet
Full trade history for one wallet, newest first, offset-free cursor pagination.
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | Clamped 1–200. |
cursor | string (ISO timestamp) | none | From a previous response's nextCursor; strictly-less-than on block_time. |
side | buy|sell | none | Optional filter; any other value is ignored. |
{
"wallet": "0x0033333333333333333333333333333333333333",
"items": [
{ "tokenAddress": "0x0035...", "txHash": "0x0060...", "logIndex": 9, "blockNumber": 10116349,
"blockTime": "2026-09-15T21:10:43.664Z", "trader": "0x004f7f...", "side": "buy",
"quaiAmount": "10000000000000000000000", "tokenAmount": "10820365474840429875562399",
"priceWei": "924183200951211", "symbol": "QAXE", "name": "QuaiAxe" }
],
"total": 5,
"nextCursor": "2026-09-15T21:10:43.664Z",
"at": "2026-09-23T14:38:06.516Z"
}total is the full matching count (ignoring pagination, honoring side). Hidden tokens are
excluded. Caching: public, max-age=15, s-maxage=15.
GET /api/traders/top
Leaderboard by all-time QUAI volume, across all tokens or scoped to one.
| Query param | Type | Default | Notes |
|---|---|---|---|
limit | int | 50 | 1–100. |
token | address | none | Optional; must match /^0x[0-9a-fA-F]{40}$/ or it's ignored. |
{
"items": [
{ "trader": "0x0022222222222222222222222222222222222222", "trades": 73,
"volumeQuai": "71251793299521694334976", "lastActive": "2026-09-23T14:28:32.246Z" }
],
"at": "2026-09-23T14:37:57.601Z"
}volumeQuai here is a wei string (despite the name reading like a display value) — ranked via
CAST(...AS REAL) internally, so it is precision-lossy past ~15 digits; fine for a leaderboard,
don't use it for accounting. Caching: public, max-age=15, s-maxage=15.
Wallet, portfolio, creator
GET /api/wallet/:wallet/stats
Activity aggregates that drive achievement badges on the site.
{
"wallet": "0x0033333333333333333333333333333333333333",
"stats": {
"tradeCount": 5, "buyCount": 5, "sellCount": 0,
"volumeQuai": 70000, "largestTradeQuai": 30000, "distinctTokens": 2,
"firstTradeTime": "2026-09-14T14:50:43.005Z", "lastTradeTime": "2026-09-15T21:10:43.664Z",
"tokensCreated": 0, "graduatedCount": 0
},
"at": "2026-09-23T14:38:05.069Z"
}No query params; wallet is honest-zeroed (never an error) if it has no activity. Caching:
public, max-age=15, s-maxage=15.
GET /api/portfolio/:wallet
Holdings, cost basis (average-cost method, not FIFO), and P&L for a wallet.
{
"wallet": "0x0033333333333333333333333333333333333333",
"holdings": [
{ "tokenAddress": "0x000010c7602a0b91e81d7d18c12a47792ffb09f8", "name": "Ask Quai", "symbol": "ASK",
"curveAddress": "0x002690512c43a48db94cc479a36797f7f49d4bc6", "launchBlock": 10079201,
"curveSupply": "784000000000000000000000000",
"logoUrl": "/api/token-logo/0x000010c7...?v=2026-09-18T07%3A55%3A46.534Z", "status": "active",
"balance": "350040929693482655359602290", "priceQuai": "40862631322710",
"valueQuai": "14303593457923433272243", "costBasisQuai": "10000000000000000000000",
"realizedPnlQuai": "0", "unrealizedPnlQuai": "4303593457923433272243" }
],
"totals": { "valueQuai": "344043713727620615784671", "realizedPnlQuai": "0",
"unrealizedPnlQuai": "274043713727620615784671" },
"at": "2026-09-23T14:38:05.697Z"
}All of balance, priceQuai, valueQuai, costBasisQuai, realizedPnlQuai,
unrealizedPnlQuai are wei strings despite the Quai-suffixed names. priceQuai is the token's
current price (its single most recent trade's priceWei). costBasisQuai/unrealizedPnlQuai are
null when there's no buy history to attribute a basis to (e.g. balance arrived via transfer, not
a tracked trade) — a null here means "unknowable," never a fabricated 0. No query params.
Caching: public, max-age=15, s-maxage=15.
GET /api/creator/:wallet
Everything the Creator dashboard shows: one row per token this wallet launched.
{
"wallet": "0x0011111111111111111111111111111111111111",
"curves": [
{ "tokenAddress": "0x0035187a...", "symbol": "QAXE", "name": "QuaiAxe",
"curveAddress": "0x004bc407...", "status": "graduated", "launchedAt": "2026-09-06T13:09:12.447Z",
"volumeWei": "628349616832067248456440", "tradeCount": 356, "holderCount": 134,
"feeBps": 100, "earnedToDateWei": "3141748084160336242282",
"claimableWei": "2037293380981762530647",
"autoBuyback": false,
"lastClaim": { "txHash": "0x002a0019...", "at": "2026-09-19T11:02:29.988Z",
"amountWei": "142350007421997897392" },
"claimCount": 1, "claimedTotalWei": "142350007421997897392" }
],
"totals": {
"tokensLaunched": 2, "volumeWei": "628654616832067248456440",
"earnedToDateWei": "3143273084160336242282", "claimableWei": "2038818380981762530647",
"queuedBurnWei": "0", "autoBuybackCurves": 0, "holders": 138, "liveReadCap": 30
},
"at": "2026-09-23T14:38:08.414Z"
}volumeWei,tradeCount,holderCount,lastClaim/claimCount/claimedTotalWeicome from D1 (indexedBuy/Sell/CreatorFeesWithdrawn).feeBpsandclaimableWeiare liveeth_calls to the curve contract (feeBps(),creatorFees()), bounded to the firstliveReadCapcurves (30) per request — beyond the cap the D1-derived fields still return, butfeeBps/claimableWei/autoBuybackarenull.earnedToDateWei = volumeWei × feeBps × 50% (creator's half), computed in exact BigInt math from the two numbers above (only when both are known).autoBuyback: truemarks a V2 curve whose "claim" call burns the pot instead of paying the creator — its balance is reported separately astotals.queuedBurnWei, never folded intototals.claimableWei, so the two are never presented as one interchangeable figure.- Any field that could not be read live is
null, not0— "unknown" and "zero" are always kept distinct in this route.
No query params. Errors: bad wallet param → 400; no DB/D1 refusal → 503 { curves: [], totals: null, error: "temporarily unavailable", retryable: true }, no-store. Caching: default
public, max-age=15, s-maxage=15 on success, no-store on error.
Burns, ecosystem stats, and charts
GET /api/burns
Single source of truth for "total burned" — any Transfer to the zero address (treasury
buybacks, creator burns, holder burns, curve auto-buyback), across all visible tokens.
{
"totalWei": "584258400700235134958323460",
"events": 130,
"tokenCount": 10,
"lastBurnAt": "2026-09-23T14:28:33.457Z",
"tokens": [
{ "address": "0x0076bc5b3a8ee996bef290a57d9a4624c5f9f9aa", "symbol": "POEM",
"burnedWei": "344412894805529134470188070" }
],
"at": "2026-09-23T14:37:15.035Z"
}tokens sorted descending by burnedWei. No query params. Caching: public, max-age=300, s-maxage=300 (5 minutes — burns are rare and the total moves slowly). No DB / D1 refusal →
totalWei: null, events: null (never a fake "0"); D1 refusal is 503, no-store.
GET /api/ecosystem-stats
Site-wide dashboard numbers.
{
"launches": 32, "totalLaunches": 32, "activeTokens": 30, "graduatedTokens": 2,
"totalHolders": 235,
"volume24hQuai": "101333640741254648971358", "volume24hWei": "101333640741254648971358",
"volume7dWei": "718597279224112168832477", "totalVolumeWei": "1075083212255358381423920",
"topTokens": [ { "address": "0x0076bc...", "name": "POEM", "symbol": "POEM", "updatedAt": "..." } ],
"topToken": { "address": "0x0035187a...", "name": "QuaiAxe", "symbol": "QAXE",
"volume24hWei": "59620483275532255828565" },
"isPartial": false,
"treasuryFees7d": "3592986396120560844151", "creationFees7d": "55000000000000000000",
"launches7d": 11, "feesPartial": false,
"burnedWei": "584258400700235134958323460", "burnEvents": 130, "burnedTokens": 10,
"lastBurnAt": "2026-09-23T14:28:33.457Z",
"at": "2026-09-23T14:37:13.893Z"
}treasuryFees7d/creationFees7dare computed from indexed trade volume × each curve's livefeeBps(owner-adjustable per curve, 0–300 bps) and the factory's livecreationFee()— never a hardcoded assumption.feesPartial: truemeans at least one curve's fee rate couldn't be read, so the totals are a floor, not the exact figure.burnedWei/burnEvents/burnedTokens/lastBurnAtmirror/api/burns.- No query params. Caching:
public, max-age=300, s-maxage=300. D1 refusal →503,no-store.
GET /api/volume-series
Per-UTC-day traded volume for the dashboard chart.
| Query param | Type | Default | Notes |
|---|---|---|---|
days | int | 14 | Clamped 1–90. |
{
"network": "mainnet",
"days": [
{ "day": "2026-09-21", "quaiWei": "93375527322478099743270" },
{ "day": "2026-09-22", "quaiWei": "324034706977607660248126" },
{ "day": "2026-09-23", "quaiWei": "33757681123253474128401" }
],
"available": true,
"truncated": false,
"at": "2026-09-23T14:37:38.046Z"
}available: false means the series couldn't be read at all (render "unavailable," not a
zero-line); a day genuinely worth "0" is a real fact and is shown as such. truncated: true
adds a note field warning the earliest days may be undercounted (more trades fell in the window
than the query's row cap). Caching: public, max-age=120, s-maxage=120.
GET /api/multi-trending
Four curated lists in one call: new launches, about-to-graduate, short-term movers, and 24h volume leaders.
{
"network": "mainnet",
"new": [ { "...": "token fields", "sparkline": [{ "t": 1790121600, "priceQuai": 0.0011 }], "badge": "NEW" } ],
"graduating": [ { "...": "token fields", "liquidityProgress": 0.62, "sparkline": [...], "badge": "SOON" } ],
"snipers": [ { "...": "token fields", "priceChange1h": 8.4, "sparkline": [...], "badge": "HOT" } ],
"movers": [ { "...": "token fields", "volume24hQuai": "101333...", "sparkline": [...], "badge": "MOVER" } ],
"partial": false,
"at": "..."
}new: launched in the last 24h, newest first, top 20.graduating:status !== 'graduated'and sellout progress ≥ 40%, sorted by progress, top 20.liquidityProgressis a float0..1.snipers: positive 1h price change from candles, sorted descending, top 20.priceChange1his a percent number.movers: ranked by the indexer-maintainedvolume24hWei, top 20.volume24hQuaiis a decimal QUAI string (not wei — already divided).- Each list is capped at 20 regardless of total token count.
sparklineentries are{ t: <unix seconds>, priceQuai: <float> }— plotting only. - No query params. Caching:
public, max-age=60, s-maxage=60. D1 refusal →503,no-store.
GET /api/graduations
Monotonic event feed (sequence-cursor, not timestamp) — built for sync consumers, not display.
| Query param | Type | Default | Notes |
|---|---|---|---|
after | non-negative int (as string) | 0 | Sequence cursor from a previous response. |
limit | int | 50 | Hard-capped at 50. |
{
"items": [
{ "sequence": 1, "address": "0x0035187a...", "name": "QuaiAxe", "symbol": "QAXE",
"blockNumber": 10064744, "graduatedAt": "2026-09-12T21:34:06.013Z", "txHash": null }
],
"hasMore": false,
"at": "2026-09-23T14:37:45.232Z"
}sequence is a monotonic integer independent of block number (multiple graduations can share a
block) — poll with after=<last item's sequence>. graduatedAt is when this app's indexer first
observed the event (or, for a backfilled gap, when the heal ran) — not necessarily the exact
on-chain block timestamp. Caching: always no-store — this route is never cached, by design,
so a client's cursor position stays exact.
GET /api/categories
| Query param | Type | Notes |
|---|---|---|
category | string | Optional; must be one of memecoin, utility, art, gaming, defi, social or it's ignored. When valid, adds addresses (token list) and filterCategory to the response. |
{
"categories": [ { "category": "memecoin", "count": 0 }, "..." ],
"defaults": ["memecoin", "utility", "art", "gaming", "defi", "social"],
"at": "2026-09-23T14:37:59.442Z"
}Caching: public, max-age=15, s-maxage=15. (POST /api/categories exists to assign a category to
a token but requires the admin bearer token — see operator-only routes.)
Comments, reactions, flags (wallet-signed writes)
These are public routes, but writes require an EIP-191 personal_sign from the acting wallet —
no accounts, no sessions, no API key. The message format is fixed per route (see below) so a
client can construct and sign it identically to the frontend. Use quais, not ethers, to sign
and to recover.
GET /api/token/:addr/comments / POST /api/token/:addr/comments
GET returns the latest 50 comments, newest first, no-store. Hidden/unknown token → 200 { items: [] }.
POST body: { wallet, text, ts, signature }. Server-side rules:
text≤ 280 chars, non-empty.ts(ms epoch) must be within 10 minutes in the past / 60 seconds in the future of the server clock, or the request is rejected as expired (closes the replay window).- Signed message:
`hartiilabs:comment:<tokenAddress lowercase>:<ts>:<text>`. - The recovered signer must equal the claimed
wallet— the client'swalletfield is never trusted alone. - Rate limits (per isolate, sliding window): 10 requests/min per IP, 4/min per wallet.
- Success:
201 { ok: true, comment: {...} }.
GET /api/comment-reactions?token=0x… / POST / DELETE /api/comment-reactions
Emoji reactions on a comment. Allowed emoji: 👍 ❤️ 🔥 🚀 😂 👀 — any other value is rejected.
POST/DELETE body: { tokenAddress, commentWallet, commentCreatedAt, wallet, emoji, ts, signature }.
Signed message: `hartiilabs:react:<token>:<commentWallet>:<commentCreatedAt>:<emoji>:<ts>`.
Same 10 min/60 s timestamp skew rule as comments. Rate limits: 20/min per IP, 10/min per wallet.
DELETE toggles a reaction off. Both respond { ok: true } (DELETE also returns deleted).
no-store throughout.
POST /api/flag-token
Community moderation — anyone with a wallet can flag a token for human review (never auto-hides).
Body: { wallet, tokenAddress, reason, description, ts, signature }. reason must be one of
scam, rug, misleading, spam, other; description ≤ 500 chars. Signed message:
`hartiilabs:flag:<tokenAddress>:<ts>:<reason>`. Same timestamp-skew rule. Rate limits: 10/min
per IP, 4/min per wallet, and a separate cap of 5 flags per wallet per day. Success: 201 { ok: true, createdAt }.
POST /api/token/:addr/update-meta, POST /api/token/:addr/set-logo
Creator-only edits (recovered signer must equal the token's on-chain-indexed creator, not an
admin token). update-meta edits description/socials/style; set-logo uploads a new logo
image (binds the signature to a SHA-256 digest of the uploaded bytes, so a captured signature
can't be replayed with different image bytes). Both use the same 10 min/60 s timestamp rule and
10/min-IP + 4/min-wallet rate limits. See Integration recipes if you need the
exact message formats — these are creator-tooling routes, not typically needed by a read-only
integration.
POST /api/token/announce
Public, rate-limited (5/min/IP) insert-only route: given a launch txHash
(/^0x[0-9a-f]{64}$/i), verifies the receipt succeeded and contains a TokenLaunched log from
the factory, then inserts the token row if it doesn't already exist (never overwrites
creator-edited metadata on a re-announce). Exists so a freshly-launched token appears immediately
rather than waiting for the next indexer tick.
Media
GET /api/token-logo/:addr
Serves a token's logo image. :addr must be a 0x + 40-hex address.
Content-Type image/png (or whatever was stored). Resolution order: edge cache → KV bytes →
IPFS gateway fetch (self-heals into KV on success). Verified live:
curl -sI https://hartiilabs.com/api/token-logo/0x0035187a7660f595d93cd53a4d16c635d6cffc8f
# Content-Type: image/png
# Cache-Control: public, max-age=86400, s-maxage=604800, immutableA hit is cached immutable for a week at the edge — the ?v=<updatedAt> query string on the
logoUrl field elsewhere in this API is what busts that cache on a logo replacement, so always
use the logoUrl the API gives you rather than constructing this path yourself. A miss (hidden,
nonexistent, or no bytes anywhere) is a cached 404, public, max-age=60, s-maxage=300.
GET /api/og/:addressOrTicker
Live 1200×630 PNG share card (candlestick price chart rendered server-side). Accepts an address
or ticker, with or without a .png suffix. Content-Type image/png, Cache-Control: public, max-age=300. On any failure (unknown token, render error) it redirects (302) to a static
fallback image rather than ever returning a broken image response — safe to embed in an
<img src> unconditionally.
GET /api/badge/:ticker
Compact ~220×40 SVG price badge (name, price in QUAI, 24h change%). Ticker only, no address form.
curl -sI https://hartiilabs.com/api/badge/QAXE
# Content-Type: image/svg+xml; charset=utf-8
# Cache-Control: public, max-age=60, s-maxage=300Unknown ticker → 404 with a "not found" placeholder SVG body (still an image/svg+xml response,
so it renders as an image, not a broken link); a real backend failure → 500 with an "unavailable"
placeholder SVG, so the two failure modes are visually distinguishable if you look closely, but
both are still safe to drop into an <img> tag.
POST /api/token-meta
Public draft-metadata endpoint used by the launch flow before a token exists on-chain (name, symbol, description, socials, logo upload, optional Turnstile bot-check, optional AI moderation screening that is informational only and never blocks). Not typically needed by a read-only integration — see the source or Contracts → Launching a token if you're building your own launch UI.
Live push
GET /api/live/ws
WebSocket upgrade — push channel for new trades/burns, an alternative to polling. Subscribe by
connecting to wss://hartiilabs.com/api/live/ws?channel=<channel> and sending
{"type":"subscribe","channel":"<channel>"} frames for additional channels on the same socket;
{"type":"unsubscribe","channel":"..."} to drop one. 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)
{ "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.
{ "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.
{
"status": "ok",
"at": "2026-09-23T14:37:21.440Z",
"d1Reachable": true,
"network": "mainnet",
"indexer": { "present": true, "factoryAddr": "0x001AF1BbB40807fcb99C9Eeaa49dF5E91e7Efd42",
"lastIndexedAt": "2026-09-23T14:36:40.818Z", "lastIndexedBlock": 10249946 },
"lastTradeAt": "2026-09-23T14:28:32.246Z",
"rpc": { "ok": true, "chainId": "0x9", "latencyMs": 500 }
}status is "ok" only when D1 is reachable, the RPC health-check succeeds, and the indexer is
configured — otherwise "degraded". Useful as a single check before trusting freshness-sensitive
data. Caching: public, max-age=30 (no s-maxage).
GET /api/quai-price
QUAI/USD reference price — MEXC primary, CoinGecko fallback.
{ "usd": 0.010293, "source": "mexc", "at": 1790174223533 }Caching: public, s-maxage=120, max-age=60. Both sources failing → 503 { "error": "price unavailable" }, no-store, with no CORS header on that specific error branch.
Operator-only routes
These exist and are documented here for completeness; they require a bearer secret you don't
have and don't need for read integration. Three separate secrets, each compared with a
constant-time check, each accepted as either Authorization: Bearer <token> or the fallback
header x-hartii-labs-indexer-token: <token>:
| Secret | Guards | Gate |
|---|---|---|
HARTII_LABS_INDEXER_TOKEN | POST /api/indexer-run, POST /api/admin/repin-logos | requireIndexerAuth |
HARTII_LABS_ADMIN_TOKEN | POST /api/admin/hidden, GET /api/admin/hidden, POST /api/admin/comments, POST /api/admin/set-logo, POST /api/categories, GET /api/beacon?admin=1 | requireAdminAuth |
HARTII_LABS_LIVE_TOKEN | POST /api/live/publish | requireLiveAuth |
Leaking one never grants the others — they're intentionally independent secrets. An unauthorized
request gets 401 { "error": "Unauthorized <kind> request." }; an unconfigured secret (missing or
under 16 chars) gets 503 { "error": "<Kind> token is not configured." }
(functions/_lib/auth.js). Verified live on a route this doc's research never had to POST to
(GET /api/admin/hidden is itself bearer-gated, so a plain GET with no Authorization header
already exercises the same 401 path — POST /api/indexer-run//api/admin/* were never called,
per instructions):
curl https://hartiilabs.com/api/admin/hidden
# 401 {"error":"Unauthorized admin request."}The 401 shape for POST /api/indexer-run and the other bearer-gated POST routes above is taken
from functions/_lib/auth.js (the same requireIndexerAuth/requireAdminAuth/requireLiveAuth
code path GET /api/admin/hidden exercises above) rather than a live POST — those routes were
deliberately never called.
POST /api/indexer-run is the indexer scan itself — see Indexer for what it does.
POST /api/admin/hidden hides/unhides a token from every read path at once (the single moderation
gate — see Indexer → freshness).
POST /api/admin/set-logo lets the operator replace any token's logo. GET /api/beacon?admin=1
lists aggregated client-error fingerprints from the site's own error beacon (POST /api/beacon,
public, fire-and-forget, always 204, strips wallet addresses before storing — not a data API,
not documented further here).