If you have written anything against the Spotify Web API in the last two years, some of
your code is quietly wrong right now — and the failure mode is not an exception. It is a
function that returns [] and looks fine.
I hit this building two headless tools against the API. Everything below was measured
against the live API, not read in a changelog.
First: there were two events, and everyone conflates them
Almost every "Spotify API is broken" post on the internet blames one date for both. I did
this myself, in a README, and had to correct it before publishing. They are unrelated:
27 November 2024 — removals. /recommendations, /audio-features,
/audio-analysis, /related-artists, and the 30-second preview URLs. This is when the
data died. Every "build a playlist generator with audio features" tutorial dates from
before this and cannot work.
February 2026 — renames and trims. Nothing was deleted for good reasons here; things
moved, and limits tightened. This is when working code started failing without saying so.
If you are debugging a missing energy or tempo field, that is the 2024 event and it is
never coming back. If you are debugging an endpoint that used to work last year, read on.
The rename that costs you an afternoon
The paths moved:
| Was | Now |
|---|---|
GET /playlists/{id}/tracks |
GET /playlists/{id}/items |
GET /users/{id}/playlists |
GET /me/playlists |
| library operations | /me/library |
That part is annoying but loud — you get an error, you fix the URL, you move on.
The part that isn't loud: the row field renamed too.
Inside each row of the response, item["track"] became item["item"]. And here is the
bit that makes it expensive:
"track"did not disappear. It survives as a boolean.
So this code does not crash:
# looks fine, has always worked, now returns nothing
tracks = []
for row in resp["items"]:
t = row.get("track")
if not t or not t.get("id"): # <-- t is now `True`
continue # True.get() never runs; `not True` is False...
tracks.append(t["id"]) # ...so this raises, or your lenient
# version skips every row silently
Depending on how defensive your reader is, you get one of two outcomes:
- a strict reader raises
AttributeError: 'bool' object has no attribute 'get' - a lenient reader — the common case, because everyone wraps this in a
try— concludes the playlist is empty
An empty playlist is not an error. Your sync job runs, reports success, and writes
nothing. That is the whole problem with this particular change: it converts a rename into
a silent data loss.
The fix is trivial once you know:
t = row.get("item") or row.get("track")
if not isinstance(t, dict):
continue
Artist genres are gone if you are in Development Mode
This one has no announcement I could find, and it is absolute:
- batch
GET /artists?ids=...returns 403 - single
GET /artists/{id}returns an artist object with nogenreskey at all
Note that second one carefully. It is not "genres": []. The field is absent. So a
.get("genres", []) gives you an empty list and you tag every artist unknown without
ever noticing the capability is gone.
If you were tagging or grouping by genre, that is dead for dev-mode apps. In my case the
only tag still derivable from the remaining metadata was release decade, so decade became
the default and genre tagging now raises an explicit error rather than silently labelling
the whole library unknown. Fail loudly on a capability you no longer have. Do not
let it degrade into a plausible-looking result.
The limits also moved, quietly
- search
limitis capped at 10. It was 50. Requests for more do not error — you just get 10, so pagination logic built on 50 silently under-fetches. -
popularityis stripped from responses. - Premium is now required for playback.
- Development Mode user allowlists dropped from 25 to 5.
That last one is worth dwelling on if you were planning to ship something. Between a
5-user allowlist, Premium being mandatory, and Extended Quota gated behind 250k MAU,
there is no path from "weekend project" to "product" here any more. Self-hosted script is
the realistic ceiling. Better to know that before you build a billing page.
403 means "removed", not "you lack a scope"
Removed endpoints answer 403, not 404. This one is already documented in several
places, so I will keep it short — but the practical rule saves real time:
Before you go re-reading the scopes documentation, check whether the path still
exists. A 403 on the Spotify API is far more often a dead endpoint than a permissions
problem.
The one that actually hurt: the quota window re-arms while you are throttled
This is the finding I would most want to have known in advance.
When you hit the daily quota you get a 429 with a Retry-After. I measured one at
82,510 seconds — roughly 23 hours. Fine. But then I kept making calls, and watched
Retry-After grow back toward 24 hours instead of counting down.
A client that retries in a loop can pin your app in a permanent 429 it never escapes.
Every retry pushes the window out again. The naive exponential-backoff loop you would
normally reach for is exactly the wrong tool.
What works is a circuit breaker that stops calling entirely:
# on 429: record an ABSOLUTE wake-up time and make no further calls until then
blocked_until = time.time() + int(resp.headers.get("Retry-After", 3600))
save(blocked_until) # persist it — see below
Two traps in that ten-line fix, both of which I shipped wrong the first time:
- Persist it, or a restart undoes it. If the process restarts it will happily call Spotify on boot and re-arm the window.
-
Persist the absolute timestamp, not the remaining seconds. I wrote an absolute
epoch on save and read back remaining seconds on load. So a restored value of
3600was interpreted as an epoch in 1970, the breaker computed a negative remaining time, concluded it was unthrottled, and called Spotify immediately — reproducing the exact bug it existed to prevent. The round trip has to agree with itself.
Also worth saying plainly: what exhausted my quota was bulk harvesting — paginating
120 deep across 40+ queries — not normal use. The architectural answer was to stop calling
the API on the hot path at all. A background thread tops up a local index inside a
token-bucket budget, and the user-facing action reads only from that index. It now serves
from 6,559 locally indexed tracks and makes zero API calls in the interaction path.
If your app calls Spotify in response to a user action, that is the design to move away
from.
The short version
- Two separate events. 2024 removed the audio data; 2026 moved things and tightened limits.
-
row["track"]→row["item"], andtrackis now a boolean that will convince a tolerant reader your playlist is empty. - Artist
genresis absent, not empty, for dev-mode apps. - Search
limitsilently caps at 10. - 403 usually means the path is gone, not that your scopes are wrong.
- Never retry a 429 in a loop. The window re-arms. Break the circuit and persist an absolute timestamp.
The two tools this came out of are MIT and on GitHub:
setlisted (headless playlist sequencing —
its test suite is fully offline, because the daily quota is genuinely exhaustible) and
spin-that-dice (the local-index
design described above).
I also wrote the whole thing up properly as a
6-page PDF, with a
2-page cheat sheet of just the renames if
that is all you need. Everything technical above is in the free repos too — the PDF just
saves you assembling it.
Top comments (0)