Reference

moto.fun API - Full Inventory

Every moto.fun REST endpoint across both services - path, params, response shape, caps, caching, and rate limits.

moto.fun API inventory

Complete Endpoint Reference for External Integrators

Two services carry moto.fun data. They are not the same backend and they do not share conventions.

Motoswap APIThe moto.fun app backend
Basehttps://api.motoswap.org<motofunUrl>/backend-fun/<chain-slug>
FrameworkHono, OpenAPI-documentedHono on Bun
In /openapi.jsonYesNo
ETagsOn cached GETs (not /config, /health/live, /version or writes)Yes, on JSON 200 responses
PaginationCursorNone. Hardcoded caps
AuthNone on readsNone on reads
RealtimeNoSSE

motofunUrl comes from GET https://api.motoswap.org/config. See addresses.


PART 1 - MOTOSWAP API (api.motoswap.org)

Conventions

Wire types are the standard Motoswap ones: addresses lowercase strings, wei and uint256 as decimal strings, USD as decimal-dollar strings ("1234.56"), seconds as numbers. Error body is { "error": "<string>" }. Non-2xx responses are never cached.

Cache tiers on this group: BLOCK is public, max-age=3, s-maxage=3, stale-while-revalidate=9. STATIC is max-age=300, s-maxage=300, stale-while-revalidate=900.

ETag versioning: every /motofun/* route versions on the freshest block across the three moto.fun raw tables, not on the chain head. A curve with no activity keeps returning 304 through every DEX block. Send If-None-Match and poll freely.

Rate limit: /motofun/* is per-IP burst 600, 150 per second. /launchpad/* is burst 120, 30 per second, tighter because the logo route serves bytes.

The 404 that is not a 404: every /motofun/* route answers 404 { "error": "moto.fun is not enabled on this deploy" } when the configured curve address is zero. That is a deployment state, not a missing resource. Check /config.motofunEnabled first.

USD is frozen at write time. Every USD field except tvlUsd is a snapshot taken when the row was written and is never re-priced. tvlUsd is a live gauge.

"Now" is the indexer head block timestamp, not wall clock, in every 24h window on this group.


GET /motofun/stats

Venue-wide counters. No parameters. Tier BLOCK.

{
  "coins": 0,
  "bonding": 0,
  "frozen": 0,
  "graduated": 0,
  "tvlUsd": "0",
  "volume24hUsd": "0",
  "volumeAllUsd": "0",
  "fees24hUsd": "0",
  "feesAllUsd": "0",
  "protocolFees24hUsd": "0",
  "creatorFees24hUsd": "0",
  "trades24h": 0,
  "launches24h": 0
}
  • coins, bonding, frozen, graduated, trades24h, launches24h: numbers
  • Every *Usd field: decimal-dollar string
  • tvlUsd is the sum of real ETH in non-graduated curves at the live WETH price. Graduated coins' liquidity lives in pools and is counted by the DEX side, not here
  • bonding = max(0, coins - graduated - frozen)

Statuses: 200, 404 (moto.fun off).


GET /motofun/coins

Newest-first coin list. Tier BLOCK, query allow-list ["cursor"].

ParamInTypeRequiredDefault
cursorquerystringnonone

Page size is 25. The cursor is an opaque signed keyset cursor over (timestamp, token_address).

{
  "items": [
    {
      "address": "0x…",
      "name": "string",
      "symbol": "string",
      "creator": "0x…",
      "timestamp": 0,
      "status": "bonding",
      "pairWeth": "0x…",
      "pairMoto": "0x…",
      "volume24hUsd": "0",
      "volumeAllUsd": "0",
      "trades": 0,
      "lastPriceX18": "0"
    }
  ],
  "nextCursor": null,
  "hasMore": false
}
  • status: "bonding" | "frozen" | "graduated", folded on read from the latest surviving lifecycle row. A coin with no lifecycle row is "bonding"
  • pairWeth, pairMoto: address or null. Non-null only after graduation
  • lastPriceX18: decimal string of the latest trade's price_x18, or null if the coin never traded
  • trades: number

A malformed cursor returns an empty 200, not a 403

There is no cursor error hook on this route. An undecodable cursor produces an empty page with status 200. Check hasMore rather than treating an empty items as the end of the list.

Statuses: 200, 404 (moto.fun off).


GET /motofun/coins/{address}

Point lookup: is this a curve coin, and has it graduated?

ParamInTypeRequired
addresspathaddressyes
{
  "address": "0x…",
  "name": "string",
  "symbol": "string",
  "status": "bonding",
  "pairWeth": "0x…",
  "pairMoto": "0x…"
}

No volume, price, or creator fields; that is the list shape only.

Statuses: 200; 404 { "error": "moto.fun is not enabled on this deploy" }; 404 { "error": "this token never launched on the moto.fun curve" }. Tier BLOCK. The version is venue-wide but the ETag folds the request path, so the tag is unique per address.


GET /motofun/revenue/series

Per-bucket venue revenue, Collector leg and creator leg separately.

ParamInTypeRequiredDefaultValues
rangequeryenumno"30d"7d, 30d, 90d, 1y
bucketqueryenumno"day"day, epoch
{
  "bucket": "day",
  "buckets": [
    { "t": 0, "total": "0", "protocol": "0", "creator": "0" }
  ]
}
  • t: bucket start, unix seconds
  • total, protocol, creator: decimal-dollar strings
  • The series is dense and zero-filled from the range start to the head bucket inclusive
  • protocol and creator are measured from the Trade event's exact fee wei, never derived from a bps constant

Tier BLOCK, query allow-list ["range", "bucket"]. Statuses: 200, 404.


moto.fun on shared Motoswap routes

GET /config (tier STATIC) carries:

KeyTypeNotes
addresses.launchpadCurveaddressZero when moto.fun display is off
addresses.launchpadLensaddressGated independently of the curve
motofunEnabledbooleanExactly launchpadCurve != 0x0
motofunUrlstringThe moto.fun app origin. "" means fall back
launchPhaseenum"prelaunch" | "vamp" | "dex"

GET /activity accepts venue: "motoswap" | "motofun" | "all", default "all". moto.fun rows carry kind of motofun_launch, motofun_trade, or motofun_graduation. With moto.fun off, the venue is forced to "motoswap" regardless of the request, because rows may exist in the database on a display-off deploy.

GET /analytics/overview carries an optional top-level key, absent entirely when moto.fun is off:

{ "motofun": { "volumeUsd": "0", "feesUsd": "0" } }

moto.fun is an additive component here. The DEX revenue series (/analytics/revenue/series, /stats/series) do not include curve volume; use /motofun/revenue/series for that.

GET /creator/{address} tags every coin with the surface that registered it:

{
  "address": "0x…",
  "tokens": [
    {
      "token": "0x…",
      "disabled": false,
      "venue": "motofun",
      "lastClaimAt": 0,
      "usdEarned": "0",
      "quotes": [
        { "quoteAsset": "0x…", "earned": "0", "usdEarned": "0",
          "swaps": 0, "claimed": "0", "unclaimed": "0" }
      ]
    }
  ],
  "series": [ { "day": 0, "usdEarned": "0", "swaps": 0 } ],
  "totalUsdEarned": "0"
}

venue is "motofun" | "deployer". deployer marks coins from the retired launch contract; new coins are always motofun. Both surfaces deposit into the same CreatorFeeVault, so this is a label for display, never a branch in the claim path. A coin is "motofun" if and only if it has a curve row on this chain. An unknown address returns 200 with empty arrays, never 404.

GET /creator/token/{token} does not carry venue.

GET /launchpad/token/{address}/logo serves a coin's logo as bytes.

  • 200: image bytes, Content-Type: image/png or image/webp, tier STATIC, ETag, X-Content-Type-Options: nosniff. 304 on a matching If-None-Match
  • 400 { "error": "invalid token address" }, 404 { "error": "no logo" }, both private, no-store
  • On a local miss the API proxies once from the moto.fun backend, persists the result, and serves it locally from then on. Accepted payloads are PNG or WebP, non-empty, at most 256 KB and 512x512. A bounded negative cache stops address cycling turning this into an unmetered amplifier
  • The POST on the same path is a separate signed-upload route that a moto.fun coin cannot use, which is why the proxy exists

Both logo routes are excluded from /openapi.json by design: one serves bytes, the other takes a multipart body. Every /motofun/* route is in the spec, on a flag-off deploy as well, because the flag decides whether a route answers, not whether it exists.


PART 2 - The moto.fun app backend

Base <motofunUrl>/backend-fun/<chain-slug>. Every path below is relative to that.

Conventions

  • No auth on any read. POST /intents is gated by an EIP-712 signature, not by a key. The /admin/* group is operator-gated and answers 401 without a credential
  • ETags on JSON 200 responses, weak (W/"<hex>"), over the serialized body. If-None-Match gets a 304 with no body, carrying the same ETag and Cache-Control. No Last-Modified. No ETag on a redirect, on the PNG routes, or on any non-200
  • Pagination on two lists only. GET /tokens and GET /graduated/feed page with a signed, opaque cursor and a limit. A forged, edited or stale cursor answers 400 { "error": "bad cursor" } and an out-of-range limit 400 { "error": "bad limit" }. Every other list has a fixed cap and no paging
  • Error body is { "error": "<string>" }, except GET /share/{address}, which answers text/plain on error
  • Status codes in use: 200, 201, 302, 304, 400, 401, 403, 404, 409, 413, 429, 500, 503. 304 answers a matching If-None-Match. 302 is the image route's redirect to the CDN. 403 is a banned party or a taken-down coin, both on POST /intents
  • Body limit 256 KB; over it returns 413 { "error": "request body too large" }
  • Unhandled errors become 500 { "error": "internal error" }; the stack is never sent to the client

Rate limits. Per-IP token buckets, each over a 10-minute window with continuous refill. Each cap is env-configurable; the values below are the shipping defaults, not a contract.

LimiterEnv varDefaultApplies to
Intent writesINTENTS_PER_IP_PER_10MIN5POST /intents only
BoardBOARD_READS_PER_IP_PER_10MIN6000GET /tokens
Expensive readsREADS_PER_IP_PER_10MIN3000GET /pve/value, GET /status, GET /graduated/feed, GET /wallet/{address}/trades-today, GET /fees/history/{wallet}, GET /wallet/{address}/positions
CreatorsCREATORS_TOP_READS_PER_IP_PER_10MIN3000GET /creators/top
Coin detailTOKEN_DETAIL_READS_PER_IP_PER_10MIN30000GET /tokens/{address}, and one token per GET /tokens/{address}/snapshot
QuotesQUOTE_READS_PER_IP_PER_10MIN28800GET /tokens/{address}/quote. A second, smaller budget covers quotes that miss the cache, refused with 429 { "error": "too many new quotes" }

Every other public read is unlimited, /health included. Read-limit body is { "error": "rate limited" }. SSE is capped separately at 2000 global (503 over it) and 40 per IP (429 over it) by default, both env-configurable, and both refusals carry Retry-After.

GET /tokens, GET /pve/value and GET /creators/top answer a refusal with Retry-After in seconds and Cache-Control: no-store. The remaining throttled routes send a bare 429 with no Retry-After, so do not depend on the header being present. Under a continuous-refill bucket Retry-After is the refill time for one token, about a second at these caps, and it is floored at 1 so it is never 0.

Cache headers are set per route from a table covering every route, and the coverage is enforced by a test that fails the build when a route has no entry.

TierWire valueRoutes
no-storeno-store/health, /health/live, /version, /intents/precheck, POST /intents, /stream, every /admin/*, /wallet/{address}/fee-reroutes, /wallet/{address}/trades-today, /wallet/{address}/positions, /fees/history/{wallet}
Live revalidateno-cache/config, /intents/{address}, /tokens, /tokens/{address}, /tokens/{address}/candles, /tokens/{address}/holders, /trades/recent, /stats
Short windowpublic, max-age=10, stale-while-revalidate=20/creators/top
Short windowpublic, max-age=30, stale-while-revalidate=30/pve/value
Unfurlpublic, max-age=300, stale-while-revalidate=600/share/{address}
Deprecatedpublic, max-age=300/tickers/{symbol}/available
Handler-ownedpublic, max-age=86400 (CDN redirect), public, max-age=300 (inline)/tokens/{address}/image
Handler-ownedpublic, max-age=30/tokens/{address}/card.png

no-cache stores the body and revalidates before every reuse; it is not no-store. The wallet money routes are no-store rather than no-cache deliberately, so a claimable balance or a day's trades cannot be served out of a shared cache to the wrong screen.

Two rules decide what a non-2xx gets, in this order. A no-store route stays no-store whatever it answers, because that tier is applied before the status is looked at and nothing softens it. On every other route, a handler that set its own Cache-Control keeps it; only when the handler set none does an error get downgraded to no-cache, so a 404 or a 429 cannot inherit that route's positive max-age.

Server-side caches: /tokens 5s, /stats 15s, /creators/top 20s, per-coin curve state 3s live with a 60s stale-serve, PvE value 45s, share card 30s, the ETH/USD rate 60s, a creator's launch nonce 30s.

Response caps:

RouteCap
/tokens coins / pending500 / 100
/tokens/{address} recentTrades50
/tokens/{address}/candles2000 buckets, plus one seed row
/tokens/{address}/holders10
/trades/recent trades / creations20 / 6 (last 24h)
/creators/top25
/wallet/{address}/trades-today50
/fees/history/{wallet}200
/wallet/{address}/positionsuncapped, dust-filtered

Response shapes are open, not closed

/tokens, /tokens/{address} and /intents/{address} select whole database rows, so a schema migration adds fields automatically. Treat every field list below as "at least these fields". Do not write a decoder that rejects unknown keys.


GET /config

Static per deploy. No database read, no RPC. No parameters. Always 200.

{
  "chainId": 1,
  "addresses": {
    "curve": "0x…",
    "lens": "0x…",
    "tokenImplementation": "0x…",
    "pveVault": "0x…",
    "weth": "0x…",
    "moto": "0x…",
    "creatorFeeRegistry": "0x…",
    "creatorFeeVault": "0x…"
  },
  "eip712": { "name": "MotoswapLaunchpad", "version": "1" },
  "motofunEnabled": true,
  "publicOpen": true,
  "launchOpensAt": null
}

addresses.curve is the curve's proxy. creatorFeeRegistry and creatorFeeVault are read off the curve after boot and serve null until that read lands: null means "not known yet", not the zero address. publicOpen is an operator switch that gates the app's launch-pending screen and nothing else; every route answers normally while it is false. launchOpensAt is an ISO 8601 UTC string or null, and null means no opening time was given to this deploy.

addresses.pveVault is the zero address when PvE is not live on this chain, a declared value, never an omitted key. motofunEnabled: false means the service is up but no curve is deployed here.


GET /health

Operational. Unauthenticated by design. Do not point an integration at it as a data source.

{
  "ok": true,
  "chainId": 1,
  "curve": "0x…",
  "indexer": {
    "lastIndexedBlock": "8123456",
    "headBlock": "8123459",
    "lagBlocks": 3,
    "lastSuccessfulTickAgoMs": 1200,
    "reorgCount": 0,
    "lastReorgAgoMs": null,
    "wedged": null
  },
  "frozenCoins": {
    "count": 0, "hiddenCount": 0, "oldestSeconds": null,
    "stuck": false, "maxSeconds": 300, "tokens": ["0x…"]
  },
  "keeper": {
    "enabled": true, "address": "0x…", "balanceWei": "12000000000000000",
    "lastPassAt": 1756000000000, "lastPassAgeMs": 4000,
    "lastError": null, "stalled": false, "missing": false
  },
  "pveRecordFailures": { "count": 0, "tokens": [] },
  "indexerErrors": {
    "count": 0, "recentAgoMs": null,
    "recent": [{ "txHash": "0x…", "logIndex": 3, "eventName": "Trade", "blockNumber": 8123400 }],
    "maxAgeSeconds": 3600
  }
}

lastIndexedBlock and headBlock are decimal strings; lagBlocks is a number.

On a chain with no curve deployed, each section is replaced by a disabled string and the response carries "mode": "pre-curve", still with 200.

Alert on ok, not on the status code

The HTTP code is 200 or 503 and covers only wedge, tick age and lag. The ok field is stricter: it also requires no stuck graduations, a live keeper, and no recent indexer errors. 200 with "ok": false is a real, reachable state.


GET /health/live

The liveness probe, and the path the platform healthcheck points at. One trivial check, no database fold, no RPC, exempt from load shedding. No parameters. Always 200. Tier no-store, because a liveness answer served from a cache reports a dead process as alive to the one reader that can restart it.

{ "ok": true, "commit": "a1b2c3d" }

Poll this rather than /health if all you need is up-or-down. /health is a deep status fold over the database and is far more expensive to answer, but it is not rate-limited: an uptime probe of it is never charged against any bucket.


GET /version

Build provenance: which build is serving right now. No parameters. Always 200. Tier no-store, for the same reason as above inverted, a cached copy would report the previous commit after a deploy and confirm a roll that did not take.

{ "commit": "a1b2c3d", "builtAt": "2026-01-01T00:00:00.000Z", "dirty": false, "branch": "master", "chainId": 1 }

Use it to confirm a deploy actually landed, and to pin which build a bug report came from.


GET /tokens

The board. With no parameters it answers the whole board in one body, up to 500 coins and 100 pending, cached 5s, and that is the form to poll.

ParameterTypeDefaultNotes
limitinteger50 once limit or cursor is sent1 to 100, else 400 { "error": "bad limit" }
cursorstringnoneThe previous body's nextCursor, verbatim. Signed and bound to the q it was issued for, else 400 { "error": "bad cursor" }. Treat that as "restart the scan": a cursor can stop verifying across a backend restart
qstringnoneCase-insensitive search, trimmed, at most 64 characters, else 400 { "error": "bad q" }

Every body carries nextCursor: a string when the page came back full, null at the end. Pages run newest coin first. 429 with Retry-After when the board bucket is empty.

{
  "tokens": [
    {
      "address": "0x…", "creator": "0x…", "name": "…", "symbol": "…",
      "metadata_hash": "0x…", "status": 1,
      "pair_weth": null, "pair_moto": null,
      "created_block": 123, "created_at": 1755990000,
      "hidden": 0, "pve": 0, "frozen_at": null, "announced": 0,
      "pve_credited_at": null, "fee_recipient": null,
      "frozen_block": null, "graduated_block": null, "pve_credited_block": null,
      "pair_moto_block": null, "unfrozen_block": null, "grad_mcap_usd": null,
      "prev_frozen_at": null, "prev_frozen_block": null, "prev_unfrozen_block": null,
      "trades24h": 4,
      "last_price_x18": "1350000000",
      "last_trade_ts": 1755999000,
      "holder_count": 12,
      "has_image": 1,
      "volume24h": "410000000000000000"
    }
  ],
  "pending": [
    {
      "address": "0x…", "creator": "0x…", "submitter": "0x…",
      "fee_recipient": "0x…", "name": "…", "symbol": "…",
      "metadata_hash": "0x…", "nonce": "0", "deadline": 1756000000,
      "has_image": 0
    }
  ],
  "ethUsd": 3421.55
}
  • status: 0 None, 1 Trading, 2 Frozen, 3 Graduated
  • volume24h: exact decimal wei string. last_price_x18: decimal string
  • has_image is advisory. It is a cheap pattern probe that skips hash verification, so an occasional 404 from the image route is expected
  • holder_count is the true net-positive holder count, netted of PvE fills
  • pending[] is filtered against a live on-chain nonce so dead intents drop out, and includes intents up to one hour past their deadline so a card can show an expired end state
  • ethUsd here is never null; it falls back to a configured rate
  • Hidden coins are excluded throughout

GET /tokens/{address}

One coin. Fires a live curve read on every request, cached 3s.

Pending branch (an intent exists, no coin yet):

{ "pending": true, "intent": { "…every intents column…", "metadata_json": "{…}" }, "ethUsd": 3421.55 }

Materialized branch: every column from /tokens above, plus:

{
  "metadata": { "description": "…", "image": "data:image/png;base64,…" },
  "curve": {
    "realEth": "1200000000000000000",
    "tokenReserve": "800000000000000000000000000",
    "status": 1,
    "buysPaused": false,
    "paramsId": 0,
    "priceX18": "1350000000",
    "bondingProgress": "420000000000000000",
    "frozenAtOnChain": null
  },
  "curveLive": true,
  "curveReadAt": 1756000000123,
  "recentTrades": [
    {
      "tx_hash": "0x…", "log_index": 3, "token": "0x…", "trader": "0x…",
      "is_buy": 1, "eth_amount": "100000000000000000",
      "token_amount": "1000000000000000000000",
      "price_x18": "1350000000", "protocol_fee": "1000000000000000",
      "creator_fee": "500000000000000", "block_number": 8123400,
      "timestamp": 1755999000, "eth_amount_gwei": 100000000,
      "creator_fee_gwei": 500000, "token_amount_milli": 1000000,
      "fed": 0
    }
  ],
  "stats": {
    "trades": 42,
    "volume_wei": "4100000000000000000",
    "creator_fees_wei": "20500000000000000",
    "volume24h_wei": "410000000000000000"
  },
  "pveTokensWei": "0", "pveEthWei": "0",
  "pveLaunchTokensWei": "0", "pveLaunchEthWei": "0",
  "pveFedTokensWei": "0", "pveFedEthWei": "0",
  "devHoldsWei": "0",
  "firstBuyer": "0x…",
  "frozenAt": null,
  "sellsOpenAt": null,
  "pveRecordFailed": null,
  "creatorFeeRoutedWei": null,
  "ethUsd": 3421.55
}
  • metadata is a parsed object or null. Null means the stored blob's hash disagrees with the on-chain commitment. It is never served unverified
  • curve.priceX18 is served for every coin, graduated ones included, with no status gate: status sits beside it. For status 3 it is the graduation price, frozen, equal to the coin's price when its reserve reached the floor. The live price of a graduated coin is its pair's. curve.paramsId is the launch parameter set the coin was stamped with
  • curve is null when the chain read failed and no recent good state exists. curveLive: false marks both that case and a stale-but-served state. A throttled RPC renders as curveLive: false, never as a 404
  • recentTrades[] selects whole rows. fed is a 0/1 flag marking a buy whose whole fill escrowed to the PvE vault and which is not the coin's first buy
  • frozenAt and sellsOpenAt are non-null only while the coin is actually frozen, and both come from the same chain read so they cannot disagree. sellsOpenAt = frozenAt + SELL_REOPEN_DELAY
  • pveRecordFailed is null or { "tokensWei": "…", "at": 1755999000 }
  • creatorFeeRoutedWei is null (never happened) or a decimal wei string
  • devHoldsWei is always present as "0" rather than omitted

Statuses: 200, 404 { "error": "not found" }.


GET /tokens/{address}/candles

ParamInTypeDefaultBounds
addresspathaddress-lowercased, not regex-validated
resolutionqueryinteger seconds300clamped to [60, 86400]
fromqueryunix secondsnow - 86400raised to the window floor, then aligned down

Response is a bare array, up to 2001 rows:

[
  { "bucket": 1755993600, "low": 1300000000, "high": 1400000000,
    "trades": 6, "volume": "410000000000000000",
    "open": "1310000000", "close": "1395000000" }
]

The exact rules, including that empty buckets produce no row and fill-forward is the client's job, are on realtime and candles. In short: UTC epoch-aligned buckets, a 2000-slot window anchored to the coin's last trade rather than to wall clock, one seed row prepended, low/high as numbers and open/close as optional strings, volume as an exact wei string summing both sides, and [] with 200 for a coin that never traded.


GET /tokens/{address}/holders

{
  "holders": [ { "trader": "0x…", "net_wei": "1000000000000000000000" } ],
  "holderCount": 40,
  "pveVaultWei": "500000000000000000000"
}

holders is the top 10 by net position. holderCount is the true total, not the page size. pveVaultWei is a decimal wei string or null when nothing was banked. Positions are netted of same-transaction PvE fills, so the escrow does not read as a whale. Always 200.


GET /tokens/{address}/image

The creator's picture as real bytes, so og:image works.

200: raw image body. Content-Type is one of image/png, image/jpeg, image/gif, image/webp; plus cache-control: public, max-age=300, x-content-type-options: nosniff, and content-security-policy: default-src 'none'. SVG is deliberately refused, as is any non-data URL. Missing or unverified metadata gives 404 { "error": "no image" }.

GET /tokens/{address}/card.png

A 1200x630 share card. address is regex-validated here, 400 { "error": "bad address" }. 200 is PNG bytes with cache-control: public, max-age=30. 404 when no coin exists. Images are re-checked against the on-chain hash before embedding; anything that fails degrades to a ticker-letters placeholder.

GET /share/{address}

An HTML unfurl page for crawlers, with a meta-refresh bounce to the app for humans.

ParamInNotes
addresspathOn failure: text/plain 400 bad address
refqueryOptional referral, forwarded only if it is a valid address

200 is text/html with og:title, og:description, og:image, twitter:card, and a refresh to the app at /coin/<address>?chain=<slug>. 404 is text/plain.


GET /status

The public health answer, for telling an outage from your own network. No parameters. /health is the operator document; this is the short one.

{ "state": "ok", "checks": [ { "name": "…", "state": "ok", "detail": "…" } ], "notice": null }

notice is null or an object an operator has set, never a bare string:

{ "notice": { "text": "…", "severity": "warning", "setAt": 1755990000 } }

severity is "info", "warning" or "critical", and setAt is unix seconds. Render notice.text; printing the object gives [object Object]. Treat state and each check's state as opaque labels to display, and do not build logic on the checks names, which are not a stable list. Spends the expensive-reads bucket.


GET /tokens/{address}/quote

A lens quote served by the backend, so a client does not need its own RPC. side is buy or sell. amount is a decimal wei string: ETH in for a buy, coin in for a sell.

{ "side": "buy", "tokensOut": "…", "grossUsed": "…", "refund": "…", "quotedAmount": "…" }

A sell answers ethOut in place of the three buy fields. 400 on a malformed address, side or amount. 502 { "error": "quote unavailable" } when the chain read failed. 429 on either of its two budgets, with Retry-After. It is a convenience over LaunchpadLens.quoteBuy and quoteSell, which stay the canonical source and are what to use before sending a transaction.


GET /tokens/{address}/snapshot

The coin page in one body: { token, holders }, plus candles and quote when their parameters are sent. token is exactly the GET /tokens/{address} body and holders exactly the GET /tokens/{address}/holders body, built by the same code behind the same caches, so the field lists for those routes apply unchanged. It spends one token on each bucket whose data it carries. Use it when you would otherwise make those requests together; keep the separate routes when you need one of them.


GET /graduated/feed

Graduated coins, newest graduation first. Built for listing sites.

ParameterTypeDefaultNotes
limitinteger100At least 1, clamped to 200. A non-number answers 400 { "error": "bad limit" }
cursorstringnoneThe previous body's nextCursor. Signed; a bad one answers 400 { "error": "bad cursor" }
{
  "coins": [
    {
      "chainId": 1, "token": "0x…", "name": "…", "symbol": "…", "creator": "0x…",
      "pair": "0x…", "pairMoto": "0x…",
      "graduatedAt": 1755990000, "graduatedBlock": 123,
      "logoUrl": "…", "url": "…"
    }
  ],
  "nextCursor": null
}

pair is the TOKEN/WETH pool and pairMoto the TOKEN/MOTO pool, the same two addresses the Graduated event carries. Hidden coins are left out. 429 with Retry-After when rate limited.


GET /trades/recent

The live ticker strip. No parameters, no rate limit. Always 200.

{
  "trades": [
    { "tx_hash": "0x…", "log_index": 3, "token": "0x…", "trader": "0x…",
      "is_buy": 1, "eth_amount": "100000000000000000",
      "timestamp": 1755999000, "symbol": "DOGE",
      "block_number": 8123400, "fed": 0 }
  ],
  "creations": [
    { "token": "0x…", "creator": "0x…", "symbol": "DOGE",
      "timestamp": 1755990000, "block_number": 8123000 }
  ]
}

20 newest trades by (block_number DESC, log_index DESC). 6 newest creations, only from the last 24 hours. Hidden coins excluded.


GET /stats

No parameters. Cached 15s. Always 200.

{
  "coins": 128, "graduated": 9,
  "volumeWei": "412000000000000000000",
  "creatorFeesWei": "2060000000000000000",
  "pveTokensWei": "0", "pveEthWei": "0",
  "pveUnvaluedCount": 0,
  "ethUsd": 3421.55
}

The three *Wei fields are exact decimal strings. ethUsd is nullable here: a null means no real price was available, never a fabricated one.

GET /creators/top

No parameters. Cached 20s. Always 200.

{
  "rows": [
    { "creator": "0x…", "earned_wei": "1200000000000000000",
      "trades": 84, "coins": 3,
      "top": { "address": "0x…", "name": "Doge", "symbol": "DOGE", "status": 3 } }
  ],
  "totalWei": "2060000000000000000",
  "ethUsd": 3421.55
}

Folded per creator, not per coin, capped at 25. top is that creator's highest-earning coin. ethUsd here is not nullable.

GET /pve/value

What the PvE escrow is currently worth on this chain. Memoised 45s and coalesced across callers.

{
  "chainId": 1, "ethWei": "3200000000000000000",
  "curvePriced": 12, "poolPriced": 3, "unpriced": 1,
  "asOf": 1756000000, "ethUsd": 3421.55
}

A graduated coin with a WETH pair is priced off pool reserves; everything else off its last indexed trade price. A coin that cannot be priced is counted in unpriced rather than silently zeroed. ethUsd is nullable.


GET /wallet/{address}/positions

Read-rate-limited. address is regex-validated.

{
  "positions": [
    { "token": "0x…", "symbol": "DOGE", "name": "Doge", "status": 1,
      "last_price_x18": "1350000000",
      "net_wei": "1000000000000000000000",
      "eth_in_wei": "100000000000000000",
      "bought_wei": "1200000000000000000000" }
  ]
}

Uncapped, dust-filtered, ordered by position value descending. Cost basis is apportioned by the PvE net ratio. Hidden coins excluded.

GET /wallet/{address}/trades-today

Read-rate-limited. The wallet's trades since the last 00:00 UTC Points drop. Deliberately carries no point values and no per-trade deltas.

{
  "count": 7,
  "dayStart": 1755993600,
  "trades": [
    { "txHash": "0x…", "token": "0x…", "name": "Doge", "symbol": "DOGE",
      "isBuy": true, "fed": 0,
      "ethAmount": "100000000000000000", "timestamp": 1755999000 }
  ]
}

count is the true day total; trades is capped at 50. dayStart is computed from the server clock, not chain time.

GET /wallet/{address}/fee-reroutes

Not rate-limited. Every coin this creator launched whose creator fee was rerouted to the Collector, in one request.

{ "reroutes": { "0xtoken…": "1500000000000000" } }

A map of coin address to cumulative rerouted wei. {} is the normal case.

GET /fees/history/{wallet}

Read-rate-limited. Lifetime creator-fee claims. The recipient matched is whoever the vault actually paid, which is not necessarily the launch signer.

{
  "lifetime": { "0xquoteasset…": "4200000000000000000" },
  "claims": [
    { "txHash": "0x…", "token": "0x…", "name": "Doge", "symbol": "DOGE",
      "quoteAsset": "0x…", "amountWei": "1200000000000000000",
      "timestamp": 1755999000 }
  ]
}

lifetime is keyed by quote asset, summed in BigInt rather than in SQL to keep wei precision. claims is capped at 200, newest first. Carries no live accrued balances: read those from the vault on chain.


GET /intents/precheck

A read-only advisory answered before the wallet signature prompt. It peeks the rate-limit window without consuming a slot.

ParamInRequiredValidation
creatorqueryyesaddress, else 400 { "error": "bad creator address" }
symbolquerynotrimmed and lowercased, checked against the reserved list
{ "throttled": false, "reserved": false, "slotsFree": 3 }

slotsFree fails open to the full cap on an RPC failure. Statuses 200, 400.

POST /intents

Creates a gasless launch intent. Signature-gated and IP-rate-limited at 5 per 10 minutes. The chain re-verifies at materialization, so the backend never holds launch authority.

FieldTypeRequiredDefaultValidation
creatoraddressyes-address regex
submitteraddressnozeroZero means anyone may submit
feeRecipientaddressnozeroRefused if it is the curve or the PvE vault
namestringyes-1 to 48 characters
symbolstringyes-^[A-Za-z0-9]{1,16}$, and not reserved
saltinteger stringyes-must parse as a BigInt
nonceinteger stringyes-must equal the on-chain launchNonce, else 409
deadlineinteger stringyes-a safe integer, in the future, at most 7 days out
signaturehex stringyes-EIP-712 recovery must equal creator
metadataany JSONno{}serialized at most 20,000 characters

The EIP-712 type, field order load-bearing:

LaunchIntent(address creator, address submitter, string name, string symbol,
             bytes32 metadataHash, uint256 salt, uint256 nonce, uint256 deadline,
             address feeRecipient)

Domain: { name, version, chainId, verifyingContract }, with name and version from the served /config.eip712 and verifyingContract the served curve. metadataHash is keccak256 of the exact serialized metadata JSON.

201:

{ "predictedAddress": "0x…", "metadataHash": "0x…" }

Other statuses: 400 on any validation failure, including bad signature. 409 on a nonce mismatch (the message names the value to use) or a salt that already launched a coin. 429 on the IP throttle or the per-creator live-intent cap. 503 when moto.fun is not live on this chain, or when the chain cannot be reached to read the nonce.

Re-posting the same (creator, salt) upserts, preserving the original created_at and any moderation state.

GET /intents/{address}

A pending launch's signed payload, verbatim, because it is what a submitter must replay on chain.

{
  "predicted_address": "0x…", "creator": "0x…", "submitter": "0x…",
  "fee_recipient": "0x…", "name": "…", "symbol": "…", "symbol_lower": "…",
  "metadata_hash": "0x…", "metadata_json": "{…}",
  "salt": "123", "nonce": "0", "deadline": 1756000000,
  "signature": "0x…", "created_at": 1755990000, "materialized": 0
}

Every field except metadata_json travels verbatim. metadata_json is a string or null, nulled when its hash does not match the on-chain commitment. Hidden intents 404.

GET /tickers/{symbol}/available - DEPRECATED

Ticker exclusivity was removed from the contract. This always answers { "available": true } for a syntactically valid symbol, and { "available": false, "reason": "1-16 letters or numbers" } otherwise. Always 200. Do not integrate against it.


GET /stream

Server-Sent Events. Documented in full on realtime. Summary: a firehose with no subscription protocol, payloads of exactly { type, token }, event types created, trade, frozen, unfrozen, graduated (plus two retired ones), a ping every 25 seconds, the SSE connection caps described above (2000 global and 40 per IP by default), and no replay: no id:, no Last-Event-ID, no buffer.


Not for integration

  • The /admin/* group: moderation, an operator identity read, the audit log, metadata override read and write and delete, ban and unban, and the ban list. Nine routes. All operator-gated, all no-store, 401 without a credential. All nine share the same operator prologue, so all nine answer 503 when no operator token is configured, which means the whole group is off by default. Shapes are deliberately not documented here. Never document the credential, never integrate against any of it

Indexing model, both services

Both indexers are polling log indexers over the curve address.

Events consumed. The app backend indexes TokenCreated, Trade, FloorReached, Graduated, PveFill, PveRecorded, PveRecordFailed, FeeRecipientSet, CreatorFeeRoutedToCollector, plus Claimed from the CreatorFeeVault. The Motoswap API indexes TokenCreated, Trade, FloorReached, PveFill, Graduated, and recognises and skips everything else on the curve.

Reorg discipline. The app backend scans to head - confirmations (default 3), detects reorgs by comparing the stored canonical block hash rather than by height, rewinds max(4 x confirmations, 64) blocks inside one transaction, and purges every block-scoped table above the fork point before re-deriving. Over-rewinding is free because every insert is idempotent on (tx_hash, log_index). Past a 200-block cumulative rewind it wedges deliberately and stops, reporting it on /health, until a human clears it.

The Motoswap API keeps no derived accumulator for moto.fun: status folds on read and volume sums surviving rows, so a rollback is a range delete with nothing to rebuild.

Graduation market cap is snapshotted once per graduation and never re-priced. A graduation reorged away clears it.

Poison logs are caught per log, recorded, and skipped rather than wedging the cursor, which is why /health alerts on the recency of those rows rather than only on their count.

On this page