API Overview

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:

  1. None. mood, moods, comments, oembed.json, the SVG badges, RSS, health, ping. Anyone can call these; they’re rate-limited, not gated.
  2. Turnstile token. notify/subscribe and notify/manage/request require a Cloudflare Turnstile token in the body (turnstileToken, cfTurnstileResponse, or captchaToken — any one field, or the cf-turnstile-response header) before the handler runs at all. A missing or failing token returns 400; Turnstile itself being unreachable returns 503, not 400 — a client should treat those differently.
  3. Bearer token in the URL. notify/confirm, notify/unsubscribe, and notify/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.
  4. 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:

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

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