Skip to content

Indexer API

Base URL: https://api.mochi.meme

The indexer returns candidate lists and content. It does not rank: every score is an eth_call to the contracts. See System architecture.

Authentication

Every route sits behind the private gate, except /health and /gate*. A valid request must carry the mochi_gate cookie issued by POST /gate. The cookie is verified with HMAC over the same secret the CloudFront Function uses.

CORS allows only the origins in WEB_ORIGINS (https://mochi.meme, https://www.mochi.meme, and localhost during development). Requests carry credentials; * is not allowed because the cookie is attached.

bash
# /health is the only route callable without a code
curl https://api.mochi.meme/health

Endpoint summary

MethodPathParametersReturns
GET/healthLiveness, cursor, post count
GET/strategiesList of valid strategies
GET/candidatesstrategy, limit, community, viewer{ strategy, ids }
GET/postsids, viewer{ posts }
GET/communitiesq, viewer, limit, offset{ communities }
GET/communities/:nameviewer{ community } or 404
GET/profile/:addresslimit{ profile, ids, commentIds }
GET/by-handle/:handle{ address } or 404
GET/by-handle/:handle/:n{ id } or 404
GET/by-author/:address/:n{ id } or 404
GET/searchq, limit{ results }
GET/notifications/:addresslimit{ notifications }
GET/replies/:idlimit{ ids }
GET/thread/:id{ ids }
POST/gateform code, next303 + set-cookie
GET/gate/status{ open } (200/401)
POST/gate/out303, clears cookie
GET/drip/:addressFaucet conditions
POST/dripJSON addressSends MON
POST/mediamultipart file{ cid, uri }

/health

Open route. live is the signal to trust:

json
{
  "ok": true,
  "reachable": true,
  "live": true,
  "tailAgeMs": 1200,
  "posts": 4213,
  "cursor": 63379890,
  "head": 63412000,
  "blocksSinceLastEvent": 32110
}
FieldMeaning
reachableWhether the latest head block can be read
liveTail just touched the chain within the heartbeat threshold
tailAgeMsHow long since the tail touched the chain; null if never
cursorLast block with an event. Does not advance when the chain is quiet
headLive-read head block, null if unreachable
blocksSinceLastEventhead − cursor. High is normal, not lag

Reading it correctly

cursor stands still on a quiet chain while the indexer is running fine. Trust live, not the block gap.

/strategies

json
{ "strategies": ["recent", "popular", "trending", "community", "joined"] }

/candidates

Selects the posts an algorithm will consider. Comments are always excluded from every strategy: a comment detached from its thread is a fragment, and the ranking contract has no way to know that.

ParameterDefaultNotes
strategyrecentAn invalid name also falls back to recent
limit500Clamped to 1…1000
communityRequired with strategy=community
viewerRequired with strategy=joined

The five strategies:

StrategySourceOrder
recentAll root posts (parentId = "0")Newest first
popularRoot posts with a non-empty communityNewest first
trendingRoot posts within 24 hoursMost active likes first, then newest
communityRoot posts in the community communityNewest first
joinedRoot posts in communities viewer has joinedNewest first

popular skips posts with no community label; posts are immutable, so filtering is the only honest way to exclude them from this one feed.

json
{ "strategy": "popular", "ids": ["1042", "1039", "1031"] }
bash
curl -H 'Cookie: mochi_gate=...' \
  'https://api.mochi.meme/candidates?strategy=trending&limit=200'

/posts

Returns content for a list of ids. ids is a comma-separated numeric string, at most 1000 ids (the excess is truncated).

ParameterDefaultNotes
idsExample ids=1,2,3
viewerAddress, to fill viewerVote

Each element in posts:

FieldTypeMeaning
idstringOn-chain id
communitystringCommunity label, "" if none
authorstringAuthor address
handlestring | nullCurrent handle
textstringContent
createdAtnumberUnix seconds
mediaURIstring"" or content address (ipfs://…)
parentIdstring"0" for a root post, otherwise the parent post
replyCountnumberNumber of direct replies
authorIndexnumberWhich post by the author (1-based)
likeCountnumberNumber of likers, raw
dislikeCountnumberNumber of dislikers, raw
viewerVote"up" | "down" | nullRequires viewer; null when there is no vote
authorKarmanumberAuthor's postKarma + commentKarma
json
{
  "posts": [
    {
      "id": "1042",
      "community": "monad",
      "author": "0xabc...",
      "handle": "alice",
      "text": "m/monad first post",
      "createdAt": 1726000000,
      "mediaURI": "",
      "parentId": "0",
      "replyCount": 3,
      "authorIndex": 5,
      "likeCount": 14,
      "dislikeCount": 2,
      "viewerVote": "up",
      "authorKarma": 220
    }
  ]
}

/communities

Lists communities in alphabetical order, matching a name prefix.

ParameterDefaultNotes
q""Prefix match, case-insensitive
viewerTo fill joined
limit100Clamped 1…100
offset0

GET /communities/:name returns one community or 404 { "error": "no such community" }.

FieldTypeMeaning
namestringCommunity name (lowercase)
creatorstringCreator address
metadataURIstringMetadata URI
createdAtnumberUnix seconds
memberCountnumberNumber of active members
postCountnumberNumber of root posts in the community
joinedbooleanWhether viewer has joined

/profile/:address

address must be 0x + 40 hex characters, otherwise 400.

ParameterDefaultNotes
limit50Clamped 1…200, applies to both ids and commentIds
json
{
  "profile": {
    "address": "0xabc...",
    "handle": "alice",
    "posts": 5,
    "comments": 12,
    "joined": 1726000000,
    "votes": 9,
    "postKarma": 220,
    "commentKarma": 40
  },
  "ids": ["1042", "1010"],
  "commentIds": ["1050", "1049"]
}

ids are root posts (Posts), commentIds are comments (Comments). postKarma and commentKarma are weighted caches; the official number remains PostRegistry.karmaOf, read directly from the chain. joined is the time of the first post, not the time the handle was registered.

Handle and post addressing

EndpointReturnsNotes
/by-handle/:handle{ "address": "0x…" }Handle resolved live, can change owner
/by-handle/:handle/:n{ "id": "1042" }The n-th post of the current handle
/by-author/:address/:n{ "id": "1042" }The n-th post by address, does not drift

n must be an integer ≥ 1. Because a handle can change owner, the address is what never drifts; addressing /posts by id is the most durable.

limit defaults to 8, clamped 1…25. Addresses are searched in full; names are searched by prefix, ordered by post count descending.

json
{ "results": [{ "address": "0xabc...", "handle": "alice", "posts": 5 }] }

/notifications/:address

address must be correctly formatted, otherwise 400. limit defaults to 50, clamped 1…200. Your own actions are excluded.

FieldTypeMeaning
kind"reply" | "like" | "dislike"Notification type
actorstringWho did it
actorHandlestring | nullThat person's handle
postIdstring | nullThe affected post
replyIdstring | nullThe comment, when kind=reply
textstring | nullRelated content
blocknumberBlock, used for sorting and estimating time

Sorted by block descending. Like/dislike carry no timestamp in the event, so the client estimates time from block height.

/replies/:id and /thread/:id

id must be a numeric string, otherwise 400.

  • /replies/:id returns direct children, oldest first. limit defaults to 200, clamped 1…500.
  • /thread/:id returns all descendants, flattened, so the client can rebuild the tree itself. At most 2000 ids.
json
{ "ids": ["1050", "1049", "1051"] }

Comments are never ranked; they are read chronologically.

Gate

EndpointUse
POST /gateForm code and next; sets the cookie then 303 to next
GET /gate/status{ "open": true } 200, or { "open": false } 401
POST /gate/outClears the cookie, 303 to /gate/

next accepts only internal paths; //evil.com is treated as /.

Faucet

GET /drip/:address reports eligibility without spending anything:

json
{ "balance": "0.42", "lowWater": "0.15", "target": "0.5", "nextAt": 0, "eligible": false }

POST /drip with JSON { "address": "0x…" } sends MON if the balance is below lowWater, topping up to target, at most once every 24 hours. Success returns { ok, hash, sent, nextAt }. Blocked returns 429 with nextAt; an empty faucet or an unresponsive transaction returns 503.

Media upload

POST /media accepts multipart with a file field (PNG, JPEG, WebP, or GIF, at most 5 MB) and returns { "cid": "bafy…", "uri": "ipfs://bafy…" }. Only the CID goes on-chain; the image bytes live on IPFS.

Documentation for Monad testnet. It describes what is actually running.