Analytics API

Docs API

Analytics API

Seven routes that split cleanly in two — same-origin write endpoints the site's own pages call, and Cloudflare Access-gated read endpoints only the admin portal can reach.

Seven routes, two halves that behave nothing alike:

  • Writes (event, v2/analytics/listening) are open to anyone whose request looks like it came from a page on buxx.me. No token, no session — an Origin/Referer check and a bot filter.
  • Reads (events, summary, article/{slug}) are gated by Cloudflare Access only. An admin session cookie does not open them.
  • Newsletter pixels (newsletter/open, newsletter/click) authenticate the event, not the caller, with an HMAC token minted into the email.

Nothing here is a general-purpose analytics ingest. The write endpoints exist so the site can measure itself, and the origin gate is what keeps them from becoming a public write surface into D1.

The same-origin gate

Both write endpoints start with the same check, and it rejects more than you might expect:

  1. Collect Origin and Referer. If both are absent, reject — this is the opposite of the usual CSRF pattern, where a missing Origin is treated as same-origin. A bare curl with no headers gets 403.
  2. A localhost / 127.0.0.1 origin is accepted only when the request itself arrived on localhost / 127.0.0.1. You cannot claim a local origin against production.
  3. Otherwise the origin must exactly match https://buxx.me, https://www.buxx.me, or the deployment’s own PUBLIC_SITE_URL / SITE_URL.

Failure is 403 {"error":"origin_rejected"}.

This is an anti-spam measure, not a security boundary — Origin is a header and a non-browser client can send whatever it likes. It stops casual drive-by writes; it does not stop a determined one.

After the gate, both endpoints apply the same two limits: the raw body must be 4096 bytes or fewer (413 {"error":"body_too_large"}), and a request whose User-Agent matches bot|spider|crawl|slurp|preview|facebookexternalhit|whatsapp|telegrambot is accepted and discarded204, no body, nothing written. A 204 is not an error; it means “understood, deliberately not recorded”.

Record a reading event

POST /api/analytics/event

Rate limit: 600 / 60s. What the blog reader posts as someone scrolls a post.

JSON
{
  "eventId": "b7f1c4e2-9a3d-4f8b-9c21-6d0e5a7b8c9d",
  "slug": "some-post-slug",
  "visitorId": "a-stable-anonymous-id",
  "sessionId": "optional",
  "dwellMs": 42000,
  "scrollDepth": 0.62,
  "completed": false,
  "referrer": "https://news.ycombinator.com/"
}

Required: eventId (a v1–v8 UUID), slug (no /, no control characters), visitorId (8 characters or more). dwellMs is clamped to 07200000 (2 hours) and scrollDepth to 01.

completed is computed, not just accepted: it is true if you send completed: true or if scrollDepth >= 0.9. Sending completed: false alongside a scroll depth of 0.95 still stores true.

referrer falls back to the request’s own Referer header when omitted.

Repeat posts merge, they don’t duplicate

The write is an upsert keyed on eventId, and the numeric columns merge with max()dwell_ms, scroll_depth, and completed only ever go up. So the intended client pattern is to generate one eventId per page view and post it repeatedly as the reader progresses. Posting a lower dwell or scroll value later is a no-op, which also means you cannot correct an inflated number by re-sending.

Alongside the body, the server records what it can see for itself: IP, country, region, city, ASN and AS org, Cloudflare colo, user agent, parsed browser / OS / device type, platform, and language. None of that comes from the payload, so a client cannot spoof it — and cannot suppress it either.

Responses: 200 {"status":"ok"} stored, 204 dropped as a bot, or a flat error — 400 {"error":"invalid_event_id"}, invalid_slug, invalid_visitor_id, invalid_body, invalid_json; 403 origin_rejected; 413 body_too_large; 500 {"error":"analytics_event_failed"}.

Record a playback event

POST /api/v2/analytics/listening

Same gate, same caps, 600 / 60s. The payload and its enums are documented with the player itself — see Listening API. Its unhandled failure code is listening_analytics_event_failed.

Reads are Cloudflare Access only

GET /api/analytics/events?limit=50
GET /api/analytics/summary?days=30
GET /api/analytics/article/{slug}?days=30

All three call requireCloudflareAccessIdentity and answer 401 {"error":"unauthorized"} without it. This is worth stating plainly because it is the one place on the surface where the two admin gates diverge: the admin session cookie that opens /api/admin/* does not open these. They need a Cloudflare Access JWT (cf-access-jwt-assertion). If a request works against /api/admin/subscribers and 401s here, that is why — see Internal Endpoints.

limit and days are read with Number() and fall back to 50 / 30 when the result is not finite, then clamped inside the query layer — limit to 1-200, days to 1-365. An out-of-range value is silently clamped rather than rejected, so ?days=100000 returns 365 days and no error. article/{slug} additionally returns 400 {"error":"slug_required"} for an empty slug.

Response bodies are admin-facing aggregates and are not specified here, on the same grounds as the rest of Internal Endpoints.

Note there is no 405 on any of these: only GET is exported, so any other method falls through to Astro’s router and returns a bare 404. That is true of the write endpoints too — GET /api/analytics/event is a 404, not a 405.

Newsletter open and click tracking

GET /api/analytics/newsletter/open?t={token}
GET /api/analytics/newsletter/click?t={token}

These are embedded in outgoing email, so they are built to be harmless when anything goes wrong. t is an HMAC token signed with EMAIL_NOTIFY_SECRET carrying the event type, email type, message and campaign ids, subscriber hash, and — for a click — the destination URL. The token authenticates the event; there is no caller auth, because the caller is a stranger’s mail client.

Both fail open, and neither ever reports an error:

Route Always returns On a missing, invalid, expired, or wrong-type token
newsletter/open 200, a 43-byte transparent GIF, Content-Type: image/gif, Cache-Control: no-store, max-age=0 The same pixel. A broken token is indistinguishable from a good one.
newsletter/click 302 Redirects to PUBLIC_SITE_URL / SITE_URL, or /blog as a last resort, instead of the intended target.

A D1 write failure is caught and logged on both — the pixel still renders and the click still redirects. The reasoning is that a tracking failure must never show a broken image in someone’s inbox or strand them on an error page from a link they clicked in good faith.

The click redirect target comes out of the signed token, never from a query parameter, so this is not an open redirect: minting a new destination requires EMAIL_NOTIFY_SECRET.

Since these are GETs in email, expect inflated counts from mail clients and security scanners that prefetch links and images. Neither route runs the user-agent bot filter that the write endpoints use, so nothing strips those out. The recorder does drop an event whose subscriberCreatedAt does not match the stored subscriber, which catches replayed tokens from a since-deleted record but not a scanner following a live link.