Docs API
Mood API
The mood feed, detail, comments, search, stats, and live-counts endpoints — every parameter, cache tier, and error code, read straight from site-api.
Mood has two independent read paths that happen to return the same shape.
/v2/mood* reads the D1 archive — what mood pages render by default.
/v1/mood* reads live from the Telegram channel — the freshness fallback,
and the only source for a post that hasn’t been archived yet. Both are owned
by site-api; see API Overview for the version and
auth conventions referenced below, and
Mood dev/prod source split for when each
one is actually in play.
Feed
GET /api/v2/mood
GET /api/v1/mood
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit |
integer | 20 | Clamped to 1–100 server-side. Out-of-range values are silently clamped, not rejected. |
before |
string (post id) | — | Cursor: posts older than this id. Must match ^\d{1,20}$ or the request 400s. |
after |
string (post id) | — | Cursor: posts newer than this id. Same validation as before. |
tag |
string | — | Filters to one mood tag; normalized through normalizeMoodTag. |
fresh |
boolean flag | false |
Any value other than 0/false/no/off counts as true. Forces no-store and skips the edge cache read (see rate limit note below). |
probe |
boolean flag | false |
Returns {"latestId": "..."} instead of a page — a cheap way to check for new posts without paying for the full payload. |
probe=image |
— | — | Returns `{“latestImage”: {…} |
Response body:
{
"posts": [
{
"id": "4821",
"datetime": "2026-08-20T09:14:00.000Z",
"tag": "daily",
"previewText": "...",
"previewHtml": "...",
"media": [],
"mediaHtml": "",
"needsDetailPage": false,
"forwardedFrom": null,
"quote": null,
"reactions": [],
"commentsCount": 3
}
],
"channel": { "...": "ContentChannelSummary" }
}
before/after and limit are hashed into the edge cache key, so identical
requests share one cache entry. A request with no cursor (the “latest” page)
caches for 30s; a request with before/after (paging through history)
caches for 300s, since history doesn’t change once written.
Errors: 400 {"error": "Invalid cursor parameter"} for a malformed
before/after. 503 {"error":{"code":"mood_repository_unavailable", ...}}
if the D1/live binding isn’t configured. 500 {"error":{"code":"mood_feed_failed", ...}} on an unhandled repository
error. Note the shape difference — see
Error shapes.
Detail
GET /api/v2/mood/{id}
GET /api/v1/mood/{id}
Both paths run the same handler against different repositories: v2 reads the
D1 archive, v1 reads the live Telegram mirror. The response shape is
identical, so a client can retry v1 on a v2 miss without branching.
Same fresh flag as the feed (also accepts probe as a bypass synonym here).
Returns the single post document. 404 {"error":{"code":"mood_not_found","message":"Mood document was not found."}}
for a missing or not-yet-archived id — that’s the case where falling back to
/api/v1/mood (or waiting for the archive backfill) makes sense. Successful
responses cache at the edge for 60s; ?fresh=1 bypasses both the cache read
and the cache write.
Comments
GET /api/v2/mood/{id}/comments
GET /api/v1/mood/{id}/comments
Same archive/live split as detail above, same response shape on both.
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit |
integer | 20 | Same 1–100 clamp as the feed. |
before |
string | — | Opaque comment cursor from a previous page’s nextBefore. Not validated against a pattern — pass through what the API gave you. |
fresh |
boolean flag | false |
Bypasses cache, same semantics as the feed. |
{
"comments": [
{ "id": "1", "author": "...", "datetime": "...", "content": "...", "reactions": [] }
],
"hasMore": true,
"nextBefore": "1"
}
60s edge cache when not bypassed. Same mood_id_required (400) /
mood_not_found (404) / mood_comments_failed (500) error family as detail.
Live counts
GET /api/v2/moods/live-counts?ids=4821,4820,4819
Batches comment/reaction counts for posts already rendered from the archive
— this is what keeps an archive-rendered page’s counts from going stale
without re-fetching the whole post. ids is a comma-separated list, max 30,
each matching ^\d{1,20}$; anything else is a 400. Missing or unknown ids
come back as {"commentsCount": null, "reactions": null} rather than being
omitted, so a client can zip the response against its request list
positionally. 60s edge cache, keyed on the sorted id set so out-of-order
requests for the same ids still hit.
{ "counts": { "4821": { "commentsCount": 3, "reactions": [] }, "4820": { "commentsCount": null, "reactions": null } } }
Live meta (v1)
GET /api/v1/mood/meta?ids=4821,4820
The same idea against the live Telegram mirror instead of the archive: max 50 ids, same digit-string validation, 30s edge cache. Returns an array, not an object keyed by id:
[{ "id": "4821", "reactions": [], "commentsCount": 3 }]
commentsCount: null means the count is genuinely unknown (the Telegram
window didn’t include it and backfill couldn’t resolve it) — a client should
keep its last-known count rather than treating null as zero.
Search
GET /api/v2/mood/search?q=keyword
| Parameter | Type | Default | Notes |
|---|---|---|---|
q |
string | — | Required, 2–64 chars after whitespace collapsing. Control characters reject the request. |
limit |
integer | 10 | Clamped 1–20 — a tighter ceiling than the feed’s 100. |
Runs against a D1 FTS index; matched terms come back pre-wrapped in <mark>
inside an HTML-escaped snippet, so the field is safe to inject directly:
{ "results": [{ "id": "4821", "datetime": "...", "snippet": "...<mark>keyword</mark>...", "tags": [], "sentiment_label": "calm" }] }
400 {"error": "Invalid q parameter"} for a query outside the length bounds
or containing control characters. 300s edge cache, keyed on the lowercased
query + limit. Tightest rate limit on the whole mood surface: 30 requests per
60s.
Stats
GET /api/v2/mood/stats
No parameters — one precomputed snapshot (activity buckets, sentiment
timeline, streaks, media-type totals) read straight from KV, refreshed by a
background job rather than computed per-request. 503 {"error":{"code":"mood_stats_unavailable"},"unavailable":true} if the
snapshot hasn’t been generated yet — this is a legitimate steady state right
after a deploy, not necessarily an outage. Successful responses are
browser-cacheable: public, max-age=300, stale-while-revalidate=3600, the
only mood endpoint that sets stale-while-revalidate.