Mood API

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:

JSON
{
  "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.
JSON
{
  "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.

JSON
{ "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:

JSON
[{ "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.

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:

JSON
{ "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.