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:

What you can not do:

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:

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:

  1. Generate a code verifier (random string, 43-128 chars)
  2. Hash it with SHA-256 and base64-encode to get the code challenge
  3. Open the user’s browser (or in-app Safari) to Spotify’s /authorize endpoint with the code challenge
  4. User logs in and grants permissions
  5. Spotify redirects back to your app via the redirect URI with an authorization code
  6. Your app exchanges the code + verifier for an access token + refresh token
  7. 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:


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:

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:

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

[Try Margin]

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 →