Docs API
API Overview
Who serves buxx.me/api, how versions and auth work, and the response conventions every endpoint on this site follows — or doesn't.
buxx.me/api/* looks like one API. It is served by two Cloudflare Workers,
versioned three different ways depending on when an endpoint was written, and
does not agree with itself on how it shapes an error. This page is the map —
each convention says which endpoints actually follow it, because several
don’t.
Who answers a request
| Environment | What serves /api/*, /v2/*, /oauth* |
|---|---|
Production (buxx.me) |
Cloudflare route patterns point directly at the site-api Worker. The public site Worker never sees the request. |
| Preview / deploy builds | site’s [...path].ts catch-alls forward the request to site-api over the API service binding (src/lib/http/api-service-proxy.ts). |
Local dev (astro dev) |
site proxies over plain HTTP to API_DEV_ORIGIN (default https://buxx.me, or a local wrangler dev site-api via bun dev:api). |
site-api is a separate, private repository — it owns D1, KV, R2, queues,
crons, the Telegram webhook, and every concrete handler under /api, /v1,
/v2, /notify, /admin, and /oauth. site never re-implements a
handler, it only forwards. That split is deliberate: site-api is where
secrets and write access live, so keeping it a separate deploy target is the
actual public/private security boundary, not just a code-organization
choice.
Path forms: /api is a prefix, not a directory
Every path in this reference is written the way you call it on buxx.me — with
the /api prefix. Inside site-api that prefix does not exist: the Worker
strips a leading /api at ingress (normalizeApiIngressRequest) before its
router sees the request, so buxx.me/api/footer is handled by the route file
that also answers api.buxx.me/footer.
| Origin | How to call footer |
|---|---|
buxx.me (canonical) |
https://buxx.me/api/footer |
api.buxx.me |
https://api.buxx.me/footer — the whole hostname is the API, so no prefix |
admin.buxx.me |
Admin portal and /admin/* only; public pages redirect back to buxx.me |
Both prefixed and bare forms work on either host — the strip is unconditional —
but use the prefixed form on buxx.me and the bare form on api.buxx.me.
Mixing them (api.buxx.me/api/footer) resolves, and is a coin-flip against a
future ingress change.
Versioning: three generations, one worker
| Prefix | What it is | Status |
|---|---|---|
/api/v1/mood* |
The live Telegram-mirror reader. Talks to t.me on every miss, cached at the edge in seconds. |
Stable, used as the freshness fallback |
/api/v2/* |
The current generation: D1-backed archive reads, KV-backed stats, admin, notify, OAuth. | Stable for mood, moods, notify; /v2/posts* is a disabled placeholder — it 404s with {"error":{"code":"not_found"}} until the ENABLE_POSTS_API flag ships |
/api/moods, /api/comments, unversioned /musickit/token, /ghost/webhook |
Pre-/v2 routes kept alive as aliases (LEGACY_*_PATH in @bunizao/contracts/routes) |
Stable, but new integrations should use the /v2 path where one exists |
There is no Accept-based or header-based version negotiation — the version
is the URL. A route that has both a legacy and a /v2 form serves the same
data through two paths; pick /v2 unless you specifically need the live
Telegram mirror’s freshness.
Auth
Four tiers, and most of the public JSON surface is the first one:
- None.
mood,moods,comments,oembed.json, the SVG badges, RSS,health,ping. Anyone can call these; they’re rate-limited, not gated. - Turnstile token.
notify/subscribeandnotify/manage/requestrequire a Cloudflare Turnstile token in the body (turnstileToken,cfTurnstileResponse, orcaptchaToken— any one field, or thecf-turnstile-responseheader) before the handler runs at all. A missing or failing token returns400; Turnstile itself being unreachable returns503, not400— a client should treat those differently. - Bearer token in the URL.
notify/confirm,notify/unsubscribe, andnotify/manage(GET/PATCH) take a single-purpose?token=issued by email. It authorizes one subscriber’s own record, nothing else, and most of these routes render an HTML result page rather than JSON — see Notify API. - Admin session / Cloudflare Access. Everything under
/admin/*and the OAuth hub. Out of scope for this reference — see Auth and OAuth hub.
There is no API-key tier. Nothing on the public surface accepts a long-lived bearer credential from a third party.
Rate limits
Every rate-limited route (which is nearly all of them) answers with the same four headers, success or failure:
X-RateLimit-Limit: 180
X-RateLimit-Remaining: 180
X-RateLimit-Reset: 1755900000
X-RateLimit-Mode: <observability|durable>
X-RateLimit-Reset is a Unix timestamp in seconds, not a delta.
Read X-RateLimit-Mode before you trust the other three. It reports which
limiter answered, and only one of the two actually enforces anything:
| Mode | Behavior |
|---|---|
durable |
A single strongly-consistent counter backed by a Durable Object. Really counts, really rejects. |
observability |
Counts nothing and rejects nothing. The headers are computed from the route’s configured limit and emitted for measurement; X-RateLimit-Remaining always equals X-RateLimit-Limit, and every request is admitted. |
Today durable is used by exactly three endpoints — notify/manage’s PATCH,
notify/manage/email, and notify/manage/delete — the three places where
double-admitting would let a caller race their own state or put mail in an
inbox. Everything else on the surface runs in observability mode.
So the per-route limits below describe the intended budget and the numbers
you will see in the headers, not a wall you will hit. A 429 from any route
outside those three is currently unreachable, and client code that only handles
429 for backpressure is, in practice, unprotected. Do not read the absence of
429s as licence to poll hard — the mode can be switched per route without
notice, and the underlying resources (Ghost, GitHub, Telegram, D1) have their
own limits that this surface does not shield you from.
A request that does exceed a durable limit gets 429 plus
Retry-After: <seconds>; the body is either {"error":"Too Many Requests"} or
plain text depending on the route (see
Error shapes).
Limits are per-route, not a single account-wide budget:
| Route family | Window | Max | Enforced |
|---|---|---|---|
moods, v2/mood (normal) |
60s | 180 | No |
moods, v2/mood with ?fresh=1 (bypasses cache) |
60s | 30 | No |
v2/mood/search |
60s | 30 | No |
v2/moods/live-counts, v1/mood/meta |
60s | 240 | No |
v2/listening, writing, footer, github/contributions |
60s | 60 | No |
musickit/token |
60s | 30 | No |
oembed.json |
60s | 120 | No |
static/* (media proxy, on the site Worker) |
60s | 240 | No |
webhooks/ghost |
60s | 30 | No |
notify/subscribe |
10 min | 120 | No |
notify/manage/request |
10 min | 30 | No |
notify/manage GET |
10 min | 120 | No |
notify/confirm, notify/unsubscribe |
10 min | 30 | No |
notify/change-email, notify/delete-record |
10 min | 30 | No |
notify/manage PATCH |
10 min | 60 | Yes |
notify/manage/email |
60 min | 5 | Yes |
notify/manage/delete |
60 min | 5 | Yes |
Error shapes — there are two
This is the sharpest edge on the whole surface: the mood-feed family and everything else disagree about what an error body looks like.
Mood feed / detail / comments / stats (mood-api-routes.ts,
v2/mood/stats.ts) return a nested object:
{ "error": { "code": "mood_not_found", "message": "Mood document was not found." } }
Notify, search, and everything built on jsonError()
(lib/http/json-response.ts) return a flat string, sometimes with an extra
code field bolted on:
{ "error": "Invalid JSON body" }
{ "error": "Turnstile verification failed", "code": "verify_unavailable" }
Check typeof body.error before reading .message or .code off it — one
family’s error is a string, the other’s is an object. There is no
version-wide error schema in @bunizao/contracts; each feature owns its own
shape.
Caching tiers
Three different Cache-Control policies show up across the surface, and
which one a route uses tells you how stale a response can be:
| Policy | Meaning | Example routes |
|---|---|---|
no-store, max-age=0 |
Never cached, anywhere. Visitor-specific, or a write. | notify/*, edge, ?fresh=1 on any mood route |
public, max-age=0, s-maxage=N |
Not cached by the browser; cached at the Cloudflare edge for N seconds. |
v2/mood (30s latest / 300s history), v2/moods/live-counts (60s), v2/mood/search (300s) |
public, max-age=N, stale-while-revalidate=M |
Cacheable by the browser too. | v2/mood/stats (300s, then stale-served for up to an hour while it refreshes) |
?fresh=1 (or probe=1) on any mood route forces no-store and skips the
edge cache read on that one request — use it for a freshness check, not for
routine polling, since it also drops you into the tighter ?fresh rate
limit bucket (30/min instead of 180/min).
CORS
Only four things on site-api set Access-Control-Allow-Origin: the two
Telegram media/image proxies, the Telegram webhook, and the oEmbed
html/embed-widget response (lib/embed-response.ts, both *). Every JSON
endpoint documented in Mood API and
Notify API — mood, v2/mood, search, stats,
live-counts, notify/* — sets no CORS header at all. fetch() from
browser JS on another origin will be blocked by the browser even though the
same request works fine from curl or a server. If you need this data in a
page hosted elsewhere, use the oEmbed endpoint, proxy the
request through your own backend, or ask for the route to be added to the
CORS allowlist rather than routing around it client-side.