spotify
2026 — A Go library that finds songs on Spotify and plays them for each user of your app
01 Problem
Many apps want to do something with their users' Spotify, like an app that plays a song when asked or an AI agent that picks music. Each of them has to set up the same things before the first song plays. It has to link each user's Spotify account, which means sending them to Spotify's consent screen, the page where they allow the app, and keeping the refresh token Spotify hands back, a long-lived key for that user. Before every request it has to turn that key into an access token, a short-lived one. It also has to work around Spotify itself, which reports some failures with a status code that also means something else and changed the shape of its playlists in 2026.
Written inside each app, that setup is repeated in every codebase, and every fix, like the one for Spotify's playlist change, has to be made again in each of them. It also sits in the middle of code that is about something else, a chat, a music feature, or an agent.
02 Solution
go.trueardian.com/spotify is an open source Go library that does that setup once, a library being code other programs import rather than a program that runs on its own. An app gives it a place to keep its users' credentials and an authenticator built with the app's own Spotify credentials, so the library never handles the app's secret, and then calls it with the user ID it already has, whether a row ID, an email, or a tenant. Linking, refreshing, and Spotify's quirks stay inside the library, so a new app gets them with one import, a fix reaches every app with one upgrade, and each app keeps its own code about what it does.
It began on 16 June 2026 as a small web service on AWS Lambda, with its own handlers to get music and to play it. That same day I removed the server, kept only the Spotify part, rebuilt it on zmb3/spotify, an open source Go library for Spotify, and published it as a library, so an app can use it without running another service.
auth := spotifyauth.New(
spotifyauth.WithClientID(clientID),
spotifyauth.WithClientSecret(clientSecret),
spotifyauth.WithRedirectURL("https://you.example/spotify/callback"),
spotifyauth.WithScopes(spotify.RequiredScopes...),
)
client := spotify.New(store, auth)03 Linking user accounts
Before anything works, the user approves the app on Spotify's consent screen, and Spotify sends them back to an address on the app's own server, the callback, because the library runs no server of its own. AuthURL makes the link to the consent screen, and Connect trades the code Spotify puts in the callback for a refresh token and saves it under the user's ID.
The link carries a value called state that Spotify hands back untouched, and it is how the callback knows whose approval this is. A raw user ID there is easy to guess, so the example below signs it with an expiry.
Scopes are the permissions an app asks for, like controlling playback, and the consent screen lets the user untick some of them. Connect reads what was actually granted, so a missing scope fails right there with a ScopeError that names it, instead of as a refusal on the first song weeks later.
Connected tells the app whether to show a Connect Spotify button, and Disconnect deletes the token on the app's side. The permission itself stays on the user's Spotify account until they remove it there.
state := signJWT(userID, 5*time.Minute)
http.Redirect(w, r, client.AuthURL(state), http.StatusFound)
userID, err := verifyJWT(r.URL.Query().Get("state"))
if err != nil {
http.Error(w, "bad state", http.StatusBadRequest)
return
}
err = client.Connect(ctx, userID, r.URL.Query().Get("code"))
var scopeErr *spotify.ScopeError
if errors.As(err, &scopeErr) {
reconnect(w, r, scopeErr.Missing)
}04 Managing user credentials
A TokenStore is any place that can get, save, and delete a refresh token by user ID, and its one rule is that a user with no token comes back as ErrNotConnected. Two come with the library. The postgres package keeps one row per user in a table called spotify_tokens and creates the table with Migrate, and the firestore package keeps one document per user in Firestore, Google's document database.
Nothing is cached. Every call reads the token and asks Spotify for a fresh access token, one extra round trip, and that is on purpose. The library was built for the tools of an AI agent, functions a language model asks to run, which call it a few times per request from a person and never hundreds of times a second. In return nothing is ever stale, so a Disconnect or a reconnect counts on the very next call.
type TokenStore interface {
GetRefreshToken(ctx context.Context, userID string) (string, error)
SaveRefreshToken(ctx context.Context, userID, refreshToken string) error
DeleteRefreshToken(ctx context.Context, userID string) error
}05 Search and playback
SearchTracks, SearchPlaylists, UserPlaylists, and PlaylistTracks each return the first page and stop, which is 10 results for a search, 20 of the user's playlists, and 50 tracks of a playlist, because one request from a person needs a first page and not the whole catalog.
Search sends no market, the country Spotify uses to leave out what a user cannot play there. Asking for the user's own market needs one more permission, user-read-private, which nothing else here needs, and asking for it without that permission made every search fail with Insufficient client scope until 4 July 2026. So search now runs worldwide, and a track that cannot play in the user's country fails later, at Play.
Every track, playlist, album, and artist has a URI, the address Spotify gives it. Play is one request for every way of starting music, and the fields that are set decide what happens. A track URI plays that track alone, a playlist, album, or artist URI plays the whole thing, and a ContextURI with a track URI starts at that track inside the playlist, so next and previous stay in it. An empty PlayRequest resumes whatever is loaded.
DeviceID picks where it plays, and empty means the device that is active. A device that is open but idle still shows up in Devices, so a caller can play on it by ID or move the session onto it with TransferPlayback, queue and position included.
tracks, err := client.SearchTracks(ctx, userID, "Cigarettes")
err = client.Play(ctx, userID, spotify.PlayRequest{URI: tracks[0].URI})
err = client.Play(ctx, userID, spotify.PlayRequest{URI: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M"})
inside, err := client.PlaylistTracks(ctx, userID, "37i9dQZF1DXcBWIGoYBM5M")
err = client.Play(ctx, userID, spotify.PlayRequest{
ContextURI: "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
URI: inside[3].URI,
})
err = client.Play(ctx, userID, spotify.PlayRequest{})06 Error handling
When a call fails, a program wants to know what to do next. So the library gives a name to each failure a caller can act on, checked with errors.Is, Go's way of asking whether an error is a known one. ErrNotConnected means the user has no token and has to go through Connect. ErrNoActiveDevice means Spotify has nowhere to play, ErrPremiumRequired means the account cannot control playback, and ErrRateLimited means Spotify wants the caller to slow down. Spotify's own error stays inside each of them.
Two of them Spotify does not say plainly. A 404, the status that usually means not found, is also how Spotify says no device is open to play on, and a 403, the status for forbidden, is also how it says the account is not Premium. Spotify puts the real reason in a field that zmb3 throws away, so the library reads the message instead. A message it does not recognize gets no name at all, so the error is plain but never wrong.
One case has no name. When a user removes the app from their Spotify account, the next refresh fails with invalid_grant, the error OAuth gives for a key that no longer works, and the token stays in the store, so Connected keeps saying true. The caller has to catch it and Disconnect the user so the button comes back.
err = client.Play(ctx, userID, spotify.PlayRequest{URI: tracks[0].URI})
var retrieveErr *oauth2.RetrieveError
switch {
case errors.Is(err, spotify.ErrNotConnected):
return askToConnect(userID)
case errors.As(err, &retrieveErr) && retrieveErr.ErrorCode == "invalid_grant":
if err := client.Disconnect(ctx, userID); err != nil {
return err
}
return askToConnect(userID)
case errors.Is(err, spotify.ErrNoActiveDevice):
return askToOpenSpotify(userID)
case errors.Is(err, spotify.ErrPremiumRequired):
return explainPremium(userID)
case errors.Is(err, spotify.ErrRateLimited):
return retryLater(err)
}
return err07 Playlist API change
In 2026 Spotify renamed a playlist's tracks to items and moved its track list from /tracks to /items, where the old address now answers 403. zmb3/spotify v2.4.3 still reads the old shape, so through it a person's own playlists came back empty and none of their tracks could be listed.
Since 4 July 2026 the three playlist calls skip zmb3 and ask Spotify directly. A failed answer is still decoded into zmb3's own error type, so these calls fail exactly like every other call. Removed tracks, podcast episodes, and the empty entries Spotify pads search pages with are skipped.
type playlistObject struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ExternalURLs map[string]string `json:"external_urls"`
Items struct {
Total int `json:"total"`
} `json:"items"`
}08 Limits
It runs no web server, so the callback lives behind the app's own routing and security. It returns only the first page of anything, it does not encrypt tokens, so a store that needs encryption wraps another TokenStore, and it covers only what an agent needs to find and play music.
It also does not save a new refresh token when Spotify sends one back. Only PKCE, the way of signing in made for apps that cannot keep a secret, replaces the refresh token on use, and this library uses the way made for apps with a server, which keeps the same one.
Spotify itself also limits every app that uses it. Only Premium accounts can control playback, and an app stays in development mode, open only to accounts added by hand, until Spotify grants it extended access, which it gives only to organizations with at least 250,000 monthly users.
09 Use cases
go.trueardian.com/agentkit, an open source library that turns services into tools for Google's Agent Development Kit, wraps this client in eleven tools from a module of its own, so this library imports no agent framework. Each tool acts for the user in the agent's session, and the named errors reach the model rewritten as what to do next.
Yori, an AI agent that plays music on request, uses those tools. Tracing its conversations in early July 2026 showed it telling people it could not start a song inside a playlist and keep next and previous there. Spotify allows that when it receives the playlist and the song together, but Play took a single URI and sent one or the other, so on 5 July Play changed to take a PlayRequest, where ContextURI carries the playlist and URI the song inside it. TransferPlayback came with it, to move a session that is already playing onto an idle device with its queue and position.
Avagenc Chat, a group chat with AI agents, links each person's Spotify account through this library and keeps their credentials in Firestore with its firestore package.
import spotifyadk "go.trueardian.com/agentkit/spotify/adk"
tools, err := spotifyadk.Tools(client)spotify
Any Go app searches Spotify and plays music for its users with one import, with linking, credentials, and Spotify's quirks already handled