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.
# /health is the only route callable without a code
curl https://api.mochi.meme/healthEndpoint summary
| Method | Path | Parameters | Returns |
|---|---|---|---|
| GET | /health | — | Liveness, cursor, post count |
| GET | /strategies | — | List of valid strategies |
| GET | /candidates | strategy, limit, community, viewer | { strategy, ids } |
| GET | /posts | ids, viewer | { posts } |
| GET | /communities | q, viewer, limit, offset | { communities } |
| GET | /communities/:name | viewer | { community } or 404 |
| GET | /profile/:address | limit | { 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 | /search | q, limit | { results } |
| GET | /notifications/:address | limit | { notifications } |
| GET | /replies/:id | limit | { ids } |
| GET | /thread/:id | — | { ids } |
| POST | /gate | form code, next | 303 + set-cookie |
| GET | /gate/status | — | { open } (200/401) |
| POST | /gate/out | — | 303, clears cookie |
| GET | /drip/:address | — | Faucet conditions |
| POST | /drip | JSON address | Sends MON |
| POST | /media | multipart file | { cid, uri } |
/health
Open route. live is the signal to trust:
{
"ok": true,
"reachable": true,
"live": true,
"tailAgeMs": 1200,
"posts": 4213,
"cursor": 63379890,
"head": 63412000,
"blocksSinceLastEvent": 32110
}| Field | Meaning |
|---|---|
reachable | Whether the latest head block can be read |
live | Tail just touched the chain within the heartbeat threshold |
tailAgeMs | How long since the tail touched the chain; null if never |
cursor | Last block with an event. Does not advance when the chain is quiet |
head | Live-read head block, null if unreachable |
blocksSinceLastEvent | head − 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
{ "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.
| Parameter | Default | Notes |
|---|---|---|
strategy | recent | An invalid name also falls back to recent |
limit | 500 | Clamped to 1…1000 |
community | — | Required with strategy=community |
viewer | — | Required with strategy=joined |
The five strategies:
| Strategy | Source | Order |
|---|---|---|
recent | All root posts (parentId = "0") | Newest first |
popular | Root posts with a non-empty community | Newest first |
trending | Root posts within 24 hours | Most active likes first, then newest |
community | Root posts in the community community | Newest first |
joined | Root posts in communities viewer has joined | Newest 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.
{ "strategy": "popular", "ids": ["1042", "1039", "1031"] }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).
| Parameter | Default | Notes |
|---|---|---|
ids | — | Example ids=1,2,3 |
viewer | — | Address, to fill viewerVote |
Each element in posts:
| Field | Type | Meaning |
|---|---|---|
id | string | On-chain id |
community | string | Community label, "" if none |
author | string | Author address |
handle | string | null | Current handle |
text | string | Content |
createdAt | number | Unix seconds |
mediaURI | string | "" or content address (ipfs://…) |
parentId | string | "0" for a root post, otherwise the parent post |
replyCount | number | Number of direct replies |
authorIndex | number | Which post by the author (1-based) |
likeCount | number | Number of likers, raw |
dislikeCount | number | Number of dislikers, raw |
viewerVote | "up" | "down" | null | Requires viewer; null when there is no vote |
authorKarma | number | Author's postKarma + commentKarma |
{
"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.
| Parameter | Default | Notes |
|---|---|---|
q | "" | Prefix match, case-insensitive |
viewer | — | To fill joined |
limit | 100 | Clamped 1…100 |
offset | 0 |
GET /communities/:name returns one community or 404 { "error": "no such community" }.
| Field | Type | Meaning |
|---|---|---|
name | string | Community name (lowercase) |
creator | string | Creator address |
metadataURI | string | Metadata URI |
createdAt | number | Unix seconds |
memberCount | number | Number of active members |
postCount | number | Number of root posts in the community |
joined | boolean | Whether viewer has joined |
/profile/:address
address must be 0x + 40 hex characters, otherwise 400.
| Parameter | Default | Notes |
|---|---|---|
limit | 50 | Clamped 1…200, applies to both ids and commentIds |
{
"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
| Endpoint | Returns | Notes |
|---|---|---|
/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.
/search
limit defaults to 8, clamped 1…25. Addresses are searched in full; names are searched by prefix, ordered by post count descending.
{ "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.
| Field | Type | Meaning |
|---|---|---|
kind | "reply" | "like" | "dislike" | Notification type |
actor | string | Who did it |
actorHandle | string | null | That person's handle |
postId | string | null | The affected post |
replyId | string | null | The comment, when kind=reply |
text | string | null | Related content |
block | number | Block, 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/:idreturns direct children, oldest first.limitdefaults to200, clamped 1…500./thread/:idreturns all descendants, flattened, so the client can rebuild the tree itself. At most 2000 ids.
{ "ids": ["1050", "1049", "1051"] }Comments are never ranked; they are read chronologically.
Gate
| Endpoint | Use |
|---|---|
POST /gate | Form code and next; sets the cookie then 303 to next |
GET /gate/status | { "open": true } 200, or { "open": false } 401 |
POST /gate/out | Clears the cookie, 303 to /gate/ |
next accepts only internal paths; //evil.com is treated as /.
Faucet
GET /drip/:address reports eligibility without spending anything:
{ "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.