Hartii developer docs

HartiiLabs

This page assumes you've read the API reference for exact shapes. Everything here is built from real, currently-public routes — no route mentioned below requires a key or a wallet connection to read.

CORS

Every JSON route responds with Access-Control-Allow-Origin: * — there is no origin allowlist to configure on your end. Verified live from a non-hartiilabs.com origin:

bash
curl -sI -H "Origin: https://quaiaxe.com" https://hartiilabs.com/api/burns | grep -i access-control
# Access-Control-Allow-Origin: *
# Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
# Access-Control-Allow-Headers: Content-Type

This means a browser-side fetch() from any site works with no proxy. The one exception is /api/quai-price, which sets only Access-Control-Allow-Origin: * (no Allow-Methods/Allow-Headers) and has no OPTIONS preflight handler — harmless for a simple GET, but don't rely on a custom header reaching it.

Polling etiquette and caching

There is no webhook/push API for third parties (the /api/live/ws WebSocket channel is internal to the hartiilabs.com frontend and undocumented as a public wire protocol). Poll REST routes instead, and respect each route's Cache-Control — polling faster than a route's max-age just re-fetches the same cached edge response, so there's no benefit to it and it wastes your own request budget:

DataRoutemax-ageSensible poll interval
Token board / listGET /api/tokens60s (ranked sorts)30–60s
One token's summaryGET /api/token/:addr15s10–15s
Trades/holders/candles for one tokenGET /api/token/:addr/{trades,holders,candles}15s10–15s while a page is open; stop when the tab is hidden
Site-wide trade tickerGET /api/trades/recent15s10–15s
Ecosystem/burn totalsGET /api/ecosystem-stats, GET /api/burns300s60–120s is already faster than the data changes
Graduation feedGET /api/graduationsno-store (never cached)poll with ?after=<last sequence>; every request is fresh so there's no reason to poll faster than your own use case needs
Indexer freshnessGET /api/status30spoll this, not your own guess, if you need to know how stale the data is

The hartiilabs.com frontend itself uses tiered intervals depending on urgency — for example its own live-trade poller runs every 4 seconds while trades are actively flowing and backs off to every 15 seconds after 5 empty polls in a row, pausing entirely while the browser tab is hidden. That backoff pattern (fast while active, slow when quiet, paused when not visible) is a reasonable default for anything you build against these same routes.

Detecting staleness

Don't assume the API is always fresh — poll GET /api/status and compare indexer.lastIndexedAt (or indexer.lastIndexedBlock) against the current time (or your own chain-head read) rather than guessing:

bash
curl -s https://hartiilabs.com/api/status | jq '.indexer'
# { "present": true, "factoryAddr": "0x001AF1...", "lastIndexedAt": "2026-09-23T14:36:40.818Z", "lastIndexedBlock": 10249946 }

If lastIndexedAt is more than a couple of minutes old, the indexer cron may be stalled — see indexer.md for the normal cadence.

Recipe: a token list / leaderboard

js
async function fetchTrendingTokens(limit = 24) {
  const res = await fetch(`https://hartiilabs.com/api/tokens?sort=trending&limit=${limit}&spark=1`);
  if (!res.ok) throw new Error(`tokens: ${res.status}`);
  const { items, nextCursor } = await res.json();
  return { items, nextCursor };
}

function formatQuai(weiString) {
  // BigInt-safe; never Number(weiString) for a value you display exactly.
  const wei = BigInt(weiString || '0');
  const whole = wei / 10n ** 18n;
  const frac = (wei % 10n ** 18n).toString().padStart(18, '0').slice(0, 4);
  return `${whole}.${frac}`;
}
  • Use sort=volume for a "top by volume" board, sort=new for a launch feed, sort=graduation (via curveProgress) style sorts if you want "closest to graduating" — see the full sort enum in api.md. Pagination is offset-based: pass the previous response's nextCursor straight back as ?cursor=.
  • Pass spark=1 only if you're actually drawing a sparkline — it costs the server an extra candle query per page and every token without recent trades will still return an empty array rather than a synthesized flat line, so design your tile to handle that.
  • logoUrl is already a same-origin relative path (/api/token-logo/:addr?v=...) — don't try to resolve meta.logoCid to an IPFS gateway yourself; the platform serves it from its own edge specifically so you don't have to deal with gateway reliability.

Recipe: a token detail page (price, chart, trades, holders)

Four independent, cacheable calls — fire them in parallel, not in sequence:

js
async function loadTokenPage(addressOrTicker) {
  const [tokenRes, candlesRes, tradesRes, holdersRes] = await Promise.all([
    fetch(`https://hartiilabs.com/api/token/${addressOrTicker}`),
    fetch(`https://hartiilabs.com/api/token/${addressOrTicker}/candles?tf=5m&limit=288`),
    fetch(`https://hartiilabs.com/api/token/${addressOrTicker}/trades?limit=50`),
    fetch(`https://hartiilabs.com/api/token/${addressOrTicker}/holders?limit=50`),
  ]);
  if (tokenRes.status === 404) return null; // unindexed, hidden, or bad ticker — all identical
  if (!tokenRes.ok) throw new Error(`token: ${tokenRes.status}`);
  const { token, graduation_progress } = await tokenRes.json();
  const { items: candles } = await candlesRes.json();
  const { items: trades } = await tradesRes.json();
  const { items: holders } = await holdersRes.json();
  return { token, graduation_progress, candles, trades, holders };
}

Notes:

  • A 404 from /api/token/:addr is the only possible outcome for "doesn't exist" and "exists but hidden by moderation" — you cannot distinguish the two, by design. Render your standard not-found state, not a special "flagged" state.
  • candles?tf=5m&limit=288 gives you roughly a day of 5-minute buckets (288 × 5min = 24h); switch tf to 1h or 4h for a longer visible window without asking for more buckets. There's no time-range query — you always get the newest N buckets, so a "load older history" control isn't supported by this API today.
  • A token's own trades endpoint already merges in that token's burns (side: "burn") — you don't need a second call to show burns alongside trades on a token page.
  • graduation_progress.progressPct is the sellout-based progress bar value the site itself uses (tokensSold / curveSupply), not a QUAI-raised percentage — use it directly rather than recomputing from raisedWei.

Recipe: a wallet portfolio

js
async function loadPortfolio(wallet) {
  const res = await fetch(`https://hartiilabs.com/api/portfolio/${wallet}`);
  const { holdings, totals } = await res.json();
  return holdings.map((h) => ({
    ...h,
    valueQuaiDisplay: formatQuai(h.valueQuai),         // wei string, despite the name
    unrealizedPnlDisplay: h.unrealizedPnlQuai == null
      ? '—'                                             // null = genuinely unknown, not zero
      : formatQuai(h.unrealizedPnlQuai),
  }));
}

Every *Quai field on this route (priceQuai, valueQuai, costBasisQuai, realizedPnlQuai, unrealizedPnlQuai) is a wei string — the "Quai" in the name is just naming, not a unit change; always run it through BigInt()/formatQuai(), never Number(). realizedPnlQuai is the one field guaranteed to always be a real number ("0" at minimum); every other *Quai/*Wei field here can be null when the underlying price or cost basis is genuinely unknown — render that as "—", not "0". Cost basis is computed with average cost, not FIFO: each sell reduces the running average-cost pool proportionally to the quantity sold, rather than consuming the earliest lot first. If you need to reproduce P&L independently instead of trusting this endpoint, replicate that convention or your numbers won't match the site's.

Recipe: embedding burn totals (as quaiaxe.com does)

GET /api/burns is the whole-platform burn ledger; it also breaks totals down per-token, so a token's own project site can show just its own line:

js
async function fetchTokenBurnTotal(tokenAddress) {
  const res = await fetch('https://hartiilabs.com/api/burns');
  const { tokens, totalWei, at } = await res.json();
  const row = tokens.find((t) => t.address.toLowerCase() === tokenAddress.toLowerCase());
  return { burnedWei: row?.burnedWei ?? null, at }; // null if this token has never burned
}

Cache this yourself for at least the route's own 300-second max-age — a widget polling every few seconds gains nothing (the edge cache serves the identical body) and just adds load for no fresher number. If you want a single token's burn total without scanning the platform-wide list, token.burnedWei on GET /api/token/:addr gives you the same figure directly.

Recipe: an embeddable price chart, without calling the API yourself

For the common case of "put a live chart for one HartiiLabs token on my site," you don't need to call the API at all — the platform serves a ready-made iframe:

html
<iframe
  src="https://hartiilabs.com/embed/HRT"
  width="640" height="560"
  style="border:0;border-radius:12px;overflow:hidden"
  title="HRT live chart — HartiiLabs">
</iframe>

:ticker accepts a symbol or an address, same resolution rule as /api/token/:addressOrTicker. Use this instead of re-implementing a candle chart if all you need is "show the chart," and fall back to the candles/trades recipe above only if you need the raw data (e.g. to compute your own indicators or render in a native app where an iframe isn't an option).

Recipe: a share-card image or badge, without JSON at all

Two routes return images directly and are safe to use as plain <img src> values with no API parsing on your side:

  • GET /api/og/:addressOrTicker.png — 1200×630 share card (name, symbol, price, mini chart). Falls back to a generic static card on any internal failure, so it never renders a broken image.
  • GET /api/badge/:ticker.svg — compact ~220×40 price badge, e.g. for a GitHub README: `![HRT price](https://hartiilabs.com/api/badge/HRT.svg)`.

Both are edge-cached for a few minutes server-side — you don't need to cache them yourself beyond normal browser image caching.

Rate limits

No public read route in this API is rate-limited. The write routes that accept unauthenticated public input (comments, comment reactions, token flags, /api/token-meta, /api/beacon) each carry an in-isolate, per-IP and/or per-wallet sliding-window limit — see the specific route in api.md for the exact numbers, and expect a 429 with a plain {"error": "..."} body if you exceed one. Because the limiter's storage resets on a cold Cloudflare isolate, don't treat it as a hard quota to budget precisely against; treat a 429 as "back off for a minute," same as you would against any other API.

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.