spotify
2026 — A Go library that finds songs on Spotify and plays them for each user of your app
01 Playing for someone else
To put a song on for someone from your own app, Spotify needs their permission once, and after that every request needs a short-lived access token made from the refresh token you kept. Then come the quirks, like a 404 that means there is no device to play on, or playlists that changed shape in 2026.
spotify does all of that. You hand it a store for the refresh tokens and an authenticator you built with your app's credentials, so the library never reads or assembles your client secret itself. Every call after that takes the user ID your app already has, whether that is a row ID, an email or a tenant, and the library never looks inside it.
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)02 Connecting once
Before anything works, the user approves your app on Spotify, and Spotify sends them back to a callback on your server, because the library runs no server of its own. AuthURL makes the link to send them to, and Connect trades the code from the callback for a refresh token and saves it under their user ID.
Make state a short signed token carrying the user ID and an expiry, since it is how the callback knows whose it is and a raw ID is easy to guess. Connect also checks the scopes the user actually granted, so unticking one on the consent screen fails right there with a ScopeError that lists what is missing, instead of as a 403 on playback weeks later.
Connected tells you whether to show a Connect Spotify button, and Disconnect deletes the token on your side. The grant 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)
}03 One row per user
A TokenStore is three methods, and its one rule is that getting or deleting the token of a user who has none returns ErrNotConnected. Two come ready-made. The postgres package keeps one row per user in spotify_tokens and creates the table with Migrate, and the firestore package keeps one document per user with the user ID as its document ID.
There is no token cache. Every call reads the row and trades the refresh token for a fresh access token, which costs a round trip, and that is fine because the library was built to be an AI agent's tools, called a few times per request and not 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
}04 Finding and playing
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 user needs a first page and not the whole catalog.
Search sends no market, so its results are global. Asking for the user's own market needs the user-read-private scope, and nothing else here needs it.
Play is one request for every way of starting music, and the fields you 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 active device. A device that is open but idle still shows up in Devices, so you can play on it by ID or move the session onto it with TransferPlayback, queue and position included.
tracks, err := client.SearchTracks(ctx, userID, "Bohemian Rhapsody")
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{})05 When it can't
The errors worth branching on are matched with errors.Is. ErrNotConnected means there is no row, so the user goes through Connect. ErrNoActiveDevice means Spotify has nowhere to play, ErrPremiumRequired means the account can't control playback, and ErrRateLimited means Spotify wants you to back off. Spotify's own error stays in the chain under each of them.
Spotify keeps the real cause in a reason field that zmb3 throws away, and a 403 or a 404 can each mean two things, so no active device and Premium required are recognized by their message. A message it doesn't recognize gets no sentinel at all, so the error is plain but never wrong.
A grant the user revoked on Spotify's side gets no sentinel either. The refresh fails with oauth2's invalid_grant and the row stays, so Connected keeps saying true. Catch it yourself 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 err06 Playlists after the rename
In 2026 Spotify renamed a playlist's tracks to items and moved its listing from /tracks to /items. zmb3/spotify v2.4.3 still reads the old shape, so through it every playlist comes back empty.
So the three playlist calls skip zmb3 and read the Web API with net/http, and a failed response is decoded into zmb3's own error type, so they fail exactly like every other call. Removed tracks, podcast episodes and the nulls 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"`
}07 What it leaves to you
It runs no HTTP server, so the callback lives behind your own routing, auth and TLS. It doesn't paginate, and it doesn't encrypt tokens either, so a store that needs encryption at rest wraps another TokenStore.
It doesn't save a rotated refresh token, because the Authorization Code flow it uses doesn't rotate one and only PKCE does. And so far it only covers what an agent needs to find and play music.
08 From ADK
agentkit turns this client into tools for Google's Agent Development Kit from a module of its own, so nothing here imports an agent framework. Tools gives an agent eleven of them, search_tracks, my_playlists, playlist_tracks, now_playing, my_devices, play, transfer_playback, pause, skip_next, skip_previous and set_volume, each with a description telling the model when and how to use it.
Every tool acts for the user of the ADK session, and the four sentinels reach the model rewritten as what to do next, so no active device becomes a note to ask the human to open Spotify somewhere or check my_devices.
import spotifyadk "go.trueardian.com/agentkit/spotify/adk"
tools, err := spotifyadk.Tools(client)spotify
Searches Spotify and plays on a user's device from nothing but their stored refresh token