Docs API
Listening API
The now-playing track behind the home page — how the Last.fm read is cached, why a response can be real or a hardcoded demo, and how playback events are reported back.
One read endpoint and one write endpoint. The read returns whatever the site thinks is currently playing; the write is how the site’s own audio player reports what a visitor did with it.
The read endpoint always returns a track. It has no empty state and no
404 — when Last.fm is unconfigured or unreachable it serves a hardcoded
demo track with a 200. Read the source field before you trust the
content.
Now playing
GET /api/v2/listening
No parameters, no auth. Rate limit: 60 requests / 60s (advertised — see Rate limits).
{
"track": {
"id": "1888707290",
"appleCatalogId": "1888707290",
"catalogId": "1888707290",
"title": "ALL THE LOVE",
"artist": "Kanye West & Andre Troutman",
"collection": "BULLY",
"appleMusicUrl": "https://music.apple.com/tw/album/all-the-love/1888707282?i=1888707290&l=en-GB",
"artworkUrl": "https://is1-ssl.mzstatic.com/.../600x600bb.jpg",
"thumbUrl": "https://is1-ssl.mzstatic.com/.../100x100bb.jpg",
"accent": null,
"previewUrl": "https://audio-ssl.itunes.apple.com/.../mzaf_....m4a",
"year": "2026",
"genre": "Hip-Hop/Rap",
"releaseKind": "album",
"trackNumber": "4",
"trackCount": "18",
"sourceUrl": "https://music.apple.com/tw/album/all-the-love/1888707282?i=1888707290&l=en-GB",
"isNowPlaying": true,
"playedAt": ""
},
"configured": true,
"source": "lastfm"
}
Every scalar track field is a string except isNowPlaying (boolean) and
releaseKind ("album" | "single"). The numeric-looking ones —
trackNumber, trackCount, year — are strings, not numbers. playedAt is
"" when the track is playing right now rather than a past scrobble, so treat
empty as “now”, not as missing.
accent is either null or a server-selected colour:
{ "hue": 229.6, "chromaLight": 0.037, "chromaDark": 0.037 }
The Worker extracts it once from Apple artwork and caches the result for a
week. null is a deliberate instruction to render the neutral foreground —
usually because the cover is monochrome — not a missing field and not a signal
to sample the image again in the browser.
The identifiers come from Apple Music, not Last.fm: Last.fm supplies the artist and title, and the handler resolves that pair against Apple’s catalog to get artwork, a preview stream, a linkable URL, and the artwork palette used as a bounded fallback when image extraction is unavailable.
Read source before rendering
configured and source together tell you which of three things happened,
and all three are a 200:
configured |
source |
What it means |
|---|---|---|
true |
"lastfm" |
Real data. A live scrobble, or a cache hit under 30s old. |
true |
"fallback" |
Last.fm is configured but the fetch threw. You are looking at the demo track. |
false |
"fallback" |
Last.fm is not configured on this deployment at all. Demo track. |
The demo track is a real, complete, plausible-looking track object. Nothing
about its shape marks it as filler — if you render the response without
checking source, a Last.fm outage silently turns into a confident claim that
someone is listening to a specific Kanye West song. Check source === "lastfm"
before presenting it as fact.
cacheTtlSeconds exists internally but is not in the response body; it
only sets the s-maxage below.
Caching
Cache-Control: public, s-maxage=<0..30>, stale-while-revalidate=300
There is no max-age, so a browser never caches this — only the Cloudflare
edge does. s-maxage is not a constant: it is the remaining life of the
Worker-Cache entry the response was built from, clamped to 0..30. A response
served from a 25-second-old entry advertises s-maxage=5. That keeps the edge
TTL and the internal TTL from stacking into a 60-second staleness window.
Behind the endpoint sits a Worker Cache entry (listening:current, 30s) plus a
single-flight promise, so concurrent misses collapse into one Last.fm round
trip rather than a thundering herd.
Errors
| Status | Body | When |
|---|---|---|
429 |
{"error":"Too Many Requests"} |
Over the limit. Currently unreachable — this route runs in observability mode. |
500 |
{"error":"Listening data unavailable"} |
Only if the handler itself throws. An upstream failure does not reach here; it returns 200 with source:"fallback". |
405 |
Method Not Allowed (plain text) |
Any method other than GET. |
Both error responses switch to Cache-Control: no-store, max-age=0.
The legacy alias
GET /api/listening → 308 → /api/v2/listening
/api/listening is a 308 to LISTENING_PATH. The redirect helper re-adds the
/api prefix when the request came in on buxx.me or www.buxx.me, so the
Location is a path the same host actually serves. On api.buxx.me there is no
prefix in play and api.buxx.me/listening redirects to
api.buxx.me/v2/listening.
One hop either way, but 308 preserves the method and a client that does not
follow redirects sees nothing. Call /api/v2/listening directly.
Report a playback event
POST /api/v2/analytics/listening
What the site’s own player calls as someone plays the preview clip. Rate
limit: 600 requests / 60s. This is a same-origin endpoint, not a public
ingest — see Analytics API for the
Origin/Referer check that gates it, the 4096-byte body cap, and the
bot-user-agent rule that silently drops an event with a 204.
{
"playbackId": "b7f1c4e2-9a3d-4f8b-9c21-6d0e5a7b8c9d",
"visitorId": "a-stable-anonymous-id",
"sessionId": "optional",
"action": "progress",
"trackId": "1888707290",
"trackTitle": "ALL THE LOVE",
"trackArtist": "Kanye West & Andre Troutman",
"pagePath": "/",
"surface": "home",
"listenedMs": 18400,
"mediaTimeMs": 18400,
"durationMs": 29000,
"requestCount": 1,
"playCount": 1,
"pauseCount": 0,
"seekCount": 0,
"completed": false
}
Required: playbackId (a v1–v8 UUID), visitorId (8 characters or more),
trackTitle, pagePath (must start with /), plus action and surface
from the sets below.
action:play_request|play|progress|pause|seek|completesurface:home|blog|mood|components|other
Every *Ms value is clamped to 0–43200000 (12 hours) and rounded;
requestCount, playCount, pauseCount, and seekCount are clamped to
0–1000. Out-of-range numbers are pinned to the bound, not rejected, so a
malformed duration degrades the data instead of failing the request.
Success is 200 {"status":"ok"}, or a bare 204 with no body when the event
was accepted and deliberately dropped (bot user agent). Errors are flat
strings — 403 {"error":"origin_rejected"}, 413 {"error":"body_too_large"},
400 {"error":"invalid_playback_id"} and friends. The full code list is in
Analytics API.