How to Use Spotify’s API to Build a Podcast Tool
I built Margin, a podcast note-taking app, entirely on top of Spotify’s Web API. Along the way I learned the things that the official documentation doesn’t tell you, the gotchas that cost me hours, and the patterns that just work.
This is the long, technical guide I wish I’d had when I started. If you’re building a podcast tool, annotation app, recommendation engine, clip extractor, anything, and you’ve decided Spotify is the platform, this post will save you a week.
Code samples are in Swift (because that’s what Margin is written in) but the concepts translate cleanly to Python, TypeScript, Kotlin, etc.
What you can actually do with Spotify’s API
Quick orientation. The Spotify Web API in 2026 lets your app:
- Authenticate a Spotify user via OAuth (PKCE flow for native apps)
- Read the user’s library, recent listening history, currently playing track or episode
- Control playback, pause, resume, seek, skip, on the user’s active device
- Read metadata for any podcast, episode, playlist, or track
- Read the user’s saved shows and episodes
- Write to the user’s library (with appropriate scopes), save/remove shows, add to playlists
What you can not do:
- Access the actual audio file. Spotify never lets you download or stream the raw audio.
- Read other users’ listening data without their auth.
- Subscribe to webhook-style events. There is no real-time push API; you poll.
Almost everything you’d want to build for podcasts is possible within these constraints. The audio-file restriction is the biggest limitation, and it’s why apps like Margin use Apple’s on-device transcription on the user’s own voice notes rather than transcribing podcast audio directly.
Step 1: Create a Spotify Developer app
Go to developer.spotify.com/dashboard. Sign in with your Spotify account. Create an app.
You’ll need to fill in:
- App name and description
- Redirect URIs, at least one. For native iOS, this should be a custom URL scheme like
margin://callback. For web apps, a real URL likehttps://yourapp.com/auth/callback. - Which APIs you’ll use, check “Web API” at minimum.
After creating the app, you’ll get a Client ID and a Client Secret.
⚠️ Important security note: for native apps, never ship the client secret. You’ll use the PKCE flow (which doesn’t require a secret) for native apps. The secret is for server-side use only.
Step 2: PKCE OAuth flow (for native apps)
PKCE, “Proof Key for Code Exchange”, is the OAuth flow designed for apps that can’t safely store a secret. Like every native app ever.
The flow is:
- Generate a code verifier (random string, 43-128 chars)
- Hash it with SHA-256 and base64-encode to get the code challenge
- Open the user’s browser (or in-app Safari) to Spotify’s
/authorizeendpoint with the code challenge - User logs in and grants permissions
- Spotify redirects back to your app via the redirect URI with an authorization
code - Your app exchanges the code + verifier for an access token + refresh token
- Use the access token for API calls. Refresh it every ~50 minutes.
Here’s the Swift code I use in Margin, slightly simplified:
import CryptoKit
import Foundation
import AuthenticationServices
struct SpotifyAuth {
static let clientID = "YOUR_CLIENT_ID"
static let redirectURI = "margin://callback"
static let scopes = "user-read-playback-state user-modify-playback-state user-read-currently-playing"
static func generateCodeVerifier() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return Data(bytes).base64URLEncodedString()
}
static func codeChallenge(for verifier: String) -> String {
let data = Data(verifier.utf8)
let hash = SHA256.hash(data: data)
return Data(hash).base64URLEncodedString()
}
static func authorizeURL(verifier: String) -> URL {
var components = URLComponents(string: "https://accounts.spotify.com/authorize")!
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID)
URLQueryItem(name: "response_type", value: "code")
URLQueryItem(name: "redirect_uri", value: redirectURI)
URLQueryItem(name: "code_challenge_method", value: "S256")
URLQueryItem(name: "code_challenge", value: codeChallenge(for: verifier))
URLQueryItem(name: "scope", value: scopes)
]
return components.url!
}
}
extension Data {
func base64URLEncodedString() -> String {
return base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
To exchange the authorization code for tokens after the redirect:
func exchangeCode(_ code: String, verifier: String) async throws -> TokenResponse {
var request = URLRequest(url: URL(string: "https://accounts.spotify.com/api/token")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let params = [
"grant_type": "authorization_code"
"code": code
"redirect_uri": SpotifyAuth.redirectURI
"client_id": SpotifyAuth.clientID
"code_verifier": verifier
]
request.httpBody = params
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" }
.joined(separator: "&")
.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
struct TokenResponse: Decodable {
let access_token: String
let refresh_token: String
let expires_in: Int
}
Don’t forget to register the URL scheme in your Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>margin</string>
</array>
</dict>
</array>
And handle the callback in your App or SceneDelegate:
.onOpenURL { url in
guard url.scheme == "margin"
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { return }
// exchange the code for tokens here
}
Common gotchas at this stage:
- The redirect URI must exactly match what you registered in the Spotify dashboard, including the trailing slash (or lack of one).
- Scopes are case-sensitive and space-separated.
- The code verifier must be the same one you used to generate the challenge, store it between launching auth and handling the callback.
Step 3: Refresh tokens
Access tokens expire in 60 minutes. You’ll need to refresh them before they expire.
func refreshToken(_ refreshToken: String) async throws -> TokenResponse {
var request = URLRequest(url: URL(string: "https://accounts.spotify.com/api/token")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let params = [
"grant_type": "refresh_token"
"refresh_token": refreshToken
"client_id": SpotifyAuth.clientID
]
request.httpBody = params
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" }
.joined(separator: "&")
.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
I refresh proactively at 50 minutes (10 minutes before expiry). This avoids the race condition where a request fires after expiry but before refresh completes.
Step 4: The currently-playing endpoint
This is the magic endpoint for podcast tools. It tells you what the user is listening to right now.
func currentlyPlaying() async throws -> CurrentlyPlaying? {
var request = URLRequest(url: URL(string: "https://api.spotify.com/v1/me/player/currently-playing?additional_types=episode")!)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
// 204 means nothing is playing
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 204 {
return nil
}
return try JSONDecoder().decode(CurrentlyPlaying.self, from: data)
}
The important note: additional_types=episode is required if you want to get podcast episodes, not just tracks. Without it, the API treats podcast playback as if nothing is playing.
The response includes:
item.id, the episode IDitem.name, episode titleitem.show.name, show nameitem.show.id, show IDitem.duration_ms, total lengthprogress_ms, current playback position in msis_playing, boolean
For a notes app, the critical fields are the episode ID, show name, and progress_ms (which is the timestamp you anchor notes to).
Polling cadence: I poll currently-playing every 3 seconds when Margin is in the foreground. Less aggressive polling causes the displayed episode to lag behind reality. More aggressive polling burns through your rate limit.
Step 5: Playback control
The three endpoints you’ll use most:
// Pause
func pause() async throws {
var request = URLRequest(url: URL(string: "https://api.spotify.com/v1/me/player/pause")!)
request.httpMethod = "PUT"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
_ = try await URLSession.shared.data(for: request)
}
// Resume
func resume() async throws {
var request = URLRequest(url: URL(string: "https://api.spotify.com/v1/me/player/play")!)
request.httpMethod = "PUT"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
_ = try await URLSession.shared.data(for: request)
}
// Seek to specific position (milliseconds)
func seek(toMs ms: Int) async throws {
var request = URLRequest(url: URL(string: "https://api.spotify.com/v1/me/player/seek?position_ms=\(ms)")!)
request.httpMethod = "PUT"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
_ = try await URLSession.shared.data(for: request)
}
These require the user-modify-playback-state scope.
Critical caveat: Spotify’s playback control endpoints only work for Premium users. Free Spotify accounts can be read (currently-playing works) but cannot be controlled. You need to handle this gracefully in your app, Margin shows a “Spotify Premium required” message when free users hit the capture button.
You can check if a user has Premium via the /me endpoint, which returns a product field that’s either "premium" or "free".
Step 6: Episode metadata
To get full info on an episode (description, publish date, full duration, show name):
func episode(id: String) async throws -> Episode {
var request = URLRequest(url: URL(string: "https://api.spotify.com/v1/episodes/\(id)?market=US")!)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(Episode.self, from: data)
}
The market parameter is annoying but important, without it, you’ll get “episode not available in market” errors for some users.
Step 7: Deep linking back into Spotify
If you want users to jump from your app back into Spotify at a specific moment, use the deep-link URL:
spotify://episode/<EPISODE_ID>?t=<SECONDS>
For example: spotify://episode/abc123def456?t=1234
This opens Spotify and jumps to second 1,234 of the episode. Margin uses this for the “Jump back to moment” button on each note.
The Spotify app must be installed; if it isn’t, falls back to the web player at https://open.spotify.com/episode/....
Common pitfalls and how to avoid them
A condensed list of things that cost me hours:
1. Forgetting additional_types=episode on currently-playing. This single missing parameter cost me about a day. Without it, podcasts don’t show up. Always include it.
2. Polling too aggressively. Spotify rate-limits at roughly 180 requests per minute per user. Burning that ceiling means your app stops working until the limit resets. 3-second polling is the right cadence.
3. Assuming the user is Premium. Build the Premium-required UX path early. About 40% of Spotify users worldwide are free; if your app requires playback control, you need to message this clearly.
4. Treating the API as real-time. It’s not. The currently-playing endpoint can lag by 1-2 seconds. Don’t build features that require sub-second sync.
5. Hardcoding scopes. Spotify occasionally adds new scopes. If you need a feature, check the latest scope list, they evolve more often than you’d think.
6. Skipping the Retry-After header on 429s. When rate-limited, the response includes a Retry-After header in seconds. Respect it.
7. Misusing the device_id parameter. Most playback endpoints accept an optional device_id. If omitted, they target the user’s “active” device. This is usually what you want. Specifying the wrong device ID causes mysterious 404s.
What I’d build with this if I were starting in 2026
The Spotify API + iOS + on-device AI stack is genuinely powerful in 2026. A few app ideas that I think are underbuilt:
- A podcast clip extractor, let users export 30-second snippets with auto-transcript. Snipd does this, but the design space is wide open.
- A podcast highlights sharer, Twitter/X has reduced reach for native podcast content. An app that produces beautifully-designed shareable cards from podcast moments is unbuilt.
- A podcast personal-recommendation engine, using your listening history (via the API) to suggest specific episodes, not shows. This is harder than it sounds because Spotify’s recommendation API for podcasts is weak.
- A note-taking layer. This is what I built. It’s the simplest and most useful.
If you’re a developer reading this and thinking about building on Spotify, the platform is unusually open and the audience is large. The barrier is auth + the gotchas above. Past those, the API is clean.
Closing thoughts
Spotify’s Web API is the unsung hero of indie podcast tooling in 2026. It’s the reason Margin, Snipd, and a half-dozen other apps can exist on top of someone else’s player. Apple Podcasts has no equivalent, which is why the third-party ecosystem on Apple is essentially zero.
If you’re a developer thinking about building a podcast tool: build on Spotify. Use PKCE for auth. Handle the Premium-required edge cases. Don’t forget additional_types=episode. Cache aggressively. Refresh tokens proactively.
And, self-serving plug, if you want to see one example of what a finished app looks like on this stack, Margin is mine. The whole thing is a single-developer, ~25-file iOS app built on the patterns above. Happy to answer questions if you’re building something similar.
Selinay
Note taking for podcasts.
Press and hold to capture a thought. Margin auto-pauses Spotify, transcribes your voice, and pins your note to the exact moment in the episode that triggered it.
Get early access →