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:
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-TypeThis 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:
| Data | Route | max-age | Sensible poll interval |
|---|---|---|---|
| Token board / list | GET /api/tokens | 60s (ranked sorts) | 30–60s |
| One token's summary | GET /api/token/:addr | 15s | 10–15s |
| Trades/holders/candles for one token | GET /api/token/:addr/{trades,holders,candles} | 15s | 10–15s while a page is open; stop when the tab is hidden |
| Site-wide trade ticker | GET /api/trades/recent | 15s | 10–15s |
| Ecosystem/burn totals | GET /api/ecosystem-stats, GET /api/burns | 300s | 60–120s is already faster than the data changes |
| Graduation feed | GET /api/graduations | no-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 freshness | GET /api/status | 30s | poll 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:
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
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=volumefor a "top by volume" board,sort=newfor a launch feed,sort=graduation(viacurveProgress) style sorts if you want "closest to graduating" — see the fullsortenum in api.md. Pagination is offset-based: pass the previous response'snextCursorstraight back as?cursor=. - Pass
spark=1only 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. logoUrlis already a same-origin relative path (/api/token-logo/:addr?v=...) — don't try to resolvemeta.logoCidto 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:
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
404from/api/token/:addris 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=288gives you roughly a day of 5-minute buckets (288 × 5min = 24h); switchtfto1hor4hfor 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
tradesendpoint 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.progressPctis the sellout-based progress bar value the site itself uses (tokensSold / curveSupply), not a QUAI-raised percentage — use it directly rather than recomputing fromraisedWei.
Recipe: a wallet portfolio
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:
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:
<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:``.
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.