Spotify playlist tracker API: track placements daily

Use a Spotify playlist tracker API to snapshot public playlists daily, diff them and see when a track enters or leaves. Python code, costs and limits.

By Scrape Everything team · · 12 min read

TL;DR: A Spotify playlist tracker API reads a public playlist's tracks as JSON so you can save a daily snapshot and diff it against the last one. That diff tells you which songs entered or left. With Scrape Everything, each read is one POST /v1/spotify/playlist call for 1 credit, with no Spotify developer app or OAuth.

This is about placement monitoring for artists and labels, not logging what you listen to. Below: a daily snapshot script in Python, a pitching overlap check, release-date enrichment, a no-code route, costs and limits.

What is Spotify playlist monitoring?

Spotify playlist monitoring is the practice of reading the same set of public playlists on a schedule and recording how their track lists change over time. The output is a plain log: track X entered playlist Y on date Z at position 12, and left on date W.

That log answers what a release team asks the week after a single drops:

  • Did the song land on any of the curator playlists we pitched?
  • How long did it stay, and did it climb or sink?
  • Which playlists in our genre keep adding artists who sound like ours?
  • When it disappeared, was it cut, or did it just slide down a long list?

Spotify for Artists already shows which playlists your own tracks are on and the streams they bring. Keep using it. What it doesn't give you is a history of other people's playlists, or a removal log you can join against your pitch list. A tracker fills both gaps.

Which option fits: Spotify for Artists, the Web API or a scraper API?

OptionSpotify editorial playlistsRemoval historyOther artists' placementsSetup
Spotify for ArtistsYour own tracks onlyNoNoAccess to the artist profile
Spotify Web API (app created after 2024-11-27)Not readableOnly if you diff snapshots yourselfYes, on playlists the app can readPremium account, developer app, access token
Scrape Everything playlist endpointReadable, it's a public pageYes, by diffing dailyYesAPI key, 1 credit per read

Music analytics subscriptions such as Chartmetric also offer playlist monitoring. If you already pay for one, use it. The script below is for teams who want raw data in their own database, or only care about 20 or 30 lists.

Can I track Spotify editorial playlists with the official API?

Not with a new app. On November 27, 2024, Spotify restricted several Web API endpoints for new apps. Apps created after that date, and apps still in development mode without a pending extension request, can no longer read algorithmic or Spotify-owned editorial playlists. Developers on the Spotify Community forums describe 404s when they try.

That hurts, because editorial lists are usually the placements a label cares about most. The playlist in every example below, 37i9dQZF1DXcBWIGoYBM5M, is Today's Top Hits. Our endpoint reads the public playlist page, so editorial playlists work like any other public one.

Why diff at all, when Get Playlist Items returns an added_at timestamp per item? For playlists your app can read, added_at gives entry dates. It never tells you when a track left, because a removed track is simply gone from the response. Diffing snapshots is the only way to catch removals, whichever API you use.

How do I fetch a public Spotify playlist with an API?

Send the playlist link, a spotify:playlist: URI or the 22-character ID to the playlist endpoint. One of url, id or playlist is required.

bash
curl -X POST https://api.scrapeeverything.com/v1/spotify/playlist \
  -H "Authorization: Bearer $SCRAPEEVERYTHING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"}'

The response comes in the standard envelope (success, data, meta). This is the shape, trimmed to one track, with placeholder values where real titles and names would be:

json
{
  "success": true,
  "data": {
    "id": "37i9dQZF1DXcBWIGoYBM5M",
    "link": "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
    "title": "Example Playlist",
    "owner": "example_curator",
    "image_url": "https://i.scdn.co/image/example",
    "total_duration_ms": 10512000,
    "track_count": 50,
    "tracks": [
      {
        "id": "0exampleTrackId0000001",
        "title": "Example Track",
        "artists_text": "Example Artist, Example Feature",
        "duration_ms": 201000,
        "is_explicit": false,
        "preview_url": "https://p.scdn.co/mp3-preview/example",
        "link": "https://open.spotify.com/track/0exampleTrackId0000001"
      }
    ]
  },
  "meta": {
    "request_id": "req_example000000000000",
    "scraper": "spotify.playlist",
    "cached": false,
    "credits_charged": 1,
    "credits_remaining": 99
  }
}

Two fields matter more than they look. track_count is the number of tracks in this response, capped at about 50. It isn't the playlist's full length. And tracks[].id is nullable in the schema, so key your diffs on id but skip items that don't have one.

The full parameter list and response schema are on the Spotify Playlist Scraper API page. The track, artist and album endpoints are on the Spotify scraper API hub.

How do I track songs on Spotify playlists over time?

The pattern is boring on purpose. Boring survives a 6 a.m. cron job.

  1. Keep a plain list of the playlists you care about: pitch targets, genre staples, a few editorial ones.
  2. Once a day, fetch each playlist and write its track IDs and positions to a dated JSON file.
  3. Load the most recent earlier snapshot, compute the set difference both ways and write "entered" and "left" rows.
  4. Filter those rows for the artists or track IDs you own and send that slice to Slack, email or a sheet.
  5. Schedule it, for example with cron: 0 6 * * * cd /home/you/tracker && python3 tracker.py >> tracker.log 2>&1.

Here's tracker.py. It runs on Python 3.10+ with pip install requests and your key in SCRAPEEVERYTHING_API_KEY.

python
import json
import os
import time
from datetime import date
from pathlib import Path

import requests

BASE = "https://api.scrapeeverything.com/v1/spotify"
KEY = os.environ["SCRAPEEVERYTHING_API_KEY"]
SNAP_DIR = Path("snapshots")
CAP = 50  # the playlist endpoint returns about 50 tracks per read

PLAYLISTS = [
    "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
    # add your pitch targets here
]

def call(action: str, body: dict) -> dict | None:
    for attempt in range(4):
        r = requests.post(
            f"{BASE}/{action}",
            headers={"Authorization": f"Bearer {KEY}"},
            json=body,
            timeout=60,
        )
        if r.status_code in (429, 500, 502, 503, 504):
            # 429 is not charged and 5xx failures are refunded, so backing off is safe
            time.sleep(2 ** attempt)
            continue
        if r.status_code == 404:
            # final answer (gone or not public), and it is charged: drop it from your list
            print(f"skip {body}: {r.json().get('code')}")
            return None
        r.raise_for_status()
        return r.json()["data"]
    raise RuntimeError(f"gave up on {body}")

def snapshot(day: date) -> dict:
    out = {}
    for url in PLAYLISTS:
        try:
            p = call("playlist", {"url": url})
        except (RuntimeError, requests.RequestException) as e:
            print(f"error {url}: {e}")  # one bad playlist shouldn't cost you the day
            continue
        if p is None:
            continue
        out[p["id"]] = {
            "title": p["title"],
            "owner": p["owner"],
            "capped": p["track_count"] >= CAP,
            "tracks": {
                t["id"]: {"title": t["title"], "artists": t["artists_text"], "pos": i}
                for i, t in enumerate(p["tracks"], start=1)
                if t.get("id")
            },
        }
    SNAP_DIR.mkdir(exist_ok=True)
    (SNAP_DIR / f"{day}.json").write_text(json.dumps(out, indent=2))
    return out

def previous_snapshot(day: date) -> dict | None:
    older = sorted(f for f in SNAP_DIR.glob("*.json") if f.stem < day.isoformat())
    return json.loads(older[-1].read_text()) if older else None

def diff(today: dict, before_all: dict) -> list[dict]:
    rows = []
    for pid, pl in today.items():
        before = before_all.get(pid, {}).get("tracks", {})
        after = pl["tracks"]
        for tid in after.keys() - before.keys():
            rows.append({"event": "entered", "playlist": pl["title"], "track_id": tid, **after[tid]})
        for tid in before.keys() - after.keys():
            event = "out of top 50" if pl["capped"] else "left"
            rows.append({"event": event, "playlist": pl["title"], "track_id": tid, **before[tid]})
    return rows

if __name__ == "__main__":
    today = date.today()
    current = snapshot(today)
    prev = previous_snapshot(today)
    if prev is None:
        print("First run: baseline saved, diffs start next run.")
    else:
        for row in diff(current, prev):
            print(f'{row["event"]:>13}  {row["playlist"]}  |  #{row["pos"]} {row["title"]} by {row["artists"]}')

The second run prints something like this (for "left" rows, the number is the last known position):

text
      entered  Example Playlist  |  #12 Example Track by Example Artist
out of top 50  Example Playlist  |  #49 Older Track by Another Artist

We key on track ID rather than title because one song can exist as a remaster, a sped-up edit and a clean version, and you don't want those collapsing into one row. We write the snapshot before diffing, so a crash in the diff never loses the day. A missed cron run just means the next diff compares against the latest file that exists.

The daily read deliberately doesn't send cache_days. The costs section explains why.

Errors come back with a stable code field. A 404 such as NOT_FOUND or PRIVATE_ACCOUNT means the playlist is gone or no longer public. That's final, and unlike 5xx failures a 404 is charged, so take the playlist off your list instead of retrying it every morning. Which errors are billed is listed in the API error reference.

You can run all of this against five playlists for 20 days on the 100 free credits you get at signup.

How do I find overlap between playlists for pitching?

Playlist pitching research is mostly pattern matching. If five indie curators all added the same three artists this quarter, those curators share a taste. An artist who sounds like those three has a better shot with them than with a random list.

The snapshots on disk already hold what you need. Add this to tracker.py (it reuses json, date and SNAP_DIR):

python
from collections import Counter
from itertools import combinations

snap = json.loads((SNAP_DIR / f"{date.today()}.json").read_text())

# shared tracks between every pair of playlists
for (a, pa), (b, pb) in combinations(snap.items(), 2):
    shared = pa["tracks"].keys() & pb["tracks"].keys()
    if shared:
        print(f'{pa["title"]} x {pb["title"]}: {len(shared)} shared tracks')

# artists that appear on the most playlists
artist_hits = Counter()
for pl in snap.values():
    names = {n.strip() for t in pl["tracks"].values() for n in t["artists"].split(",")}
    artist_hits.update(names)
print(artist_hits.most_common(15))

Splitting artists_text on commas is a shortcut that breaks on the occasional artist with a comma in their name. If that matters, call spotify.track and read its structured artists array instead.

Pitching Spotify's own editors runs through Spotify for Artists, not any API. Spotify's guide to pitching music to playlist editors says to pitch an unreleased song at least 7 days before release, one song at a time, and that a pitch doesn't guarantee a placement. A tracker is how you find out afterwards whether it landed. The overlap check above is for the independent curators and label playlists you pitch directly.

How do I tell a new-music playlist from a catalog playlist?

Check how old tracks are when they get added. A playlist that adds songs within weeks of release is a new-music list. One that adds three-year-old songs is a mood or catalog list. You pitch them very differently, and the playlist endpoint alone can't tell them apart.

The track endpoint can, through release_date. Run it only on the "entered" rows, with cache_days: 14, since a track's release date doesn't change and repeat lookups inside the window cost 0 credits:

python
def days_since_release(track_id: str) -> int | None:
    t = call("track", {"id": track_id, "cache_days": 14})
    if not t or not t.get("release_date"):
        return None
    try:
        return (date.today() - date.fromisoformat(t["release_date"][:10])).days
    except ValueError:
        return None

prev = previous_snapshot(date.today())
for row in diff(snap, prev or {}):
    if row["event"] == "entered":
        age = days_since_release(row["track_id"])
        print(f'{row["playlist"]}: {row["title"]} added {age} days after release')

Our rule of thumb: if most adds over a month are under 30 days old, treat it as a new-music list and pitch before release. That threshold is our opinion, not a Spotify rule.

The four Spotify endpoints, all at 1 credit per call:

EndpointInputWhat it returnsCredits
spotify.playlistPlaylist link, URI or IDTitle, owner, cover, total duration, up to ~50 tracks1
spotify.trackTrack link, URI or IDStructured artists array, release_date, is_explicit, is_playable, has_video, cover1
spotify.artistArtist link, URI or IDName, image, top tracks (~10) with durations and preview URLs1
spotify.albumAlbum link, URI or IDTitle, artists, release date, cover, full track list1

spotify.artist doesn't return monthly listeners, followers or genres, so plan your report around what's in that table.

Can I do this without code?

Yes, two ways.

The first is the spotify-artist-and-playlist-research Agent Skill, a SKILL.md workflow for Claude Code, Cursor, Codex, Gemini CLI and other agents that support the format. It covers artist top tracks, playlist audits, overlap and a pitching shortlist. Install it with:

bash
npx skills add scrapeeverything/skills --skill spotify-artist-and-playlist-research

Then ask "Audit these 5 Spotify playlists and tell me which fit our artist" and paste the links. Setup is on the Agent Skills integration page.

The second is n8n, with the n8n-nodes-scrapeeverything community node. A daily tracker there is a Schedule Trigger, the Scrape Everything node set to Spotify playlist, a Code node that compares against the previous run, and a Slack or email node for the "entered" rows. See the n8n integration guide and n8n's docs on installing community nodes. The same endpoints are also tools in the Scrape Everything MCP server.

We'd use the skill for one-off audits before a pitch round, and the script or n8n for anything that has to run every day with nobody watching. An agent is great at "which of these fit our artist" and wasteful at "fetch the same 30 lists every morning."

What does a Spotify playlist tracker API cost?

Each live playlist read costs 1 credit. Thirty playlists a day is 900 credits a month. Credits come in one-time packs that never expire:

PackPriceCreditsPer 1k creditsMonthly cost at 30 playlists/dayMonths covered at 30/day
Builder$3925,000$1.56$1.4027
Growth$119100,000$1.19$1.07111
Scale$449500,000$0.90$0.81555

With enrichment, say 10 new tracks a day looked up once each, you're at roughly 1,200 credits a month, and the Builder pack lasts about 20 months.

Prices are on the pricing page, and the pricing calculator handles your own endpoint mix. Failed requests are refunded automatically.

When should I use cache_days?

cache_days lets the API return a stored result up to that many days old, and a cache hit costs 0 credits. It takes whole days from 1 to 14. Without it, every request is live, which is exactly what you want for data you're trying to catch moving.

  • Daily playlist snapshot: leave cache_days out. Even cache_days: 1 can hand you yesterday's result, and your diff then reports no changes on a day when there were some.
  • Track, album and artist lookups: use cache_days: 14. A release date won't change between Tuesday and next Monday.

If you pull the same playlists several times in one afternoon for an audit, cache_days: 1 is fine. Matching rules and the X-Cache header are covered in the caching docs.

What are the limits of Spotify playlist scraping?

Know these before you promise a label a dashboard.

  • Public playlists only. Private playlists are not supported.
  • About 50 tracks per read. On a 200-track playlist, an add at position 120 won't show up, and a track that slides from 48 to 55 looks like it left. The script labels those cases "out of top 50" when the playlist hit the cap. For most curator playlists, the top 50 is the part listeners hear anyway.
  • No playlist follower counts and no stream counts from these endpoints.
  • The playlist endpoint accepts open.spotify.com links, spotify: URIs and IDs. Short links (spotify.link, spoti.fi) aren't in that list, and the track endpoint explicitly rejects them, so resolve them first.
  • Scrape Everything is an unofficial API, not affiliated with or endorsed by Spotify. It reads what a logged-out visitor sees.
  • owner is a curator's display name. Stored, it may count as personal data under GDPR, so keep it only where needed. Our post on whether scraping public social media data is legal covers terms of service and personal data (it isn't legal advice).

If your registered app can read the playlists you need, the official Spotify Web API is the first-party option. Our Spotify API alternative guide compares the two in full.

FAQ

Can I get notified when my song is added to a Spotify playlist?

Yes, if the playlist is public and you check it on a schedule. Snapshot it daily, diff against the previous snapshot and filter the "entered" rows for your track IDs, then send that list to Slack or email. The API answers requests rather than pushing events, so a scheduled check is the method.

How do I see when a song was added to a Spotify playlist?

In the Spotify app you can sort a playlist by date added. The official Web API returns added_at per item for playlists your app can read. With a daily tracker, the entry date is the first snapshot that contains the track, and you also get the date it left.

How often should I check playlists for changes?

Once a day is enough for most curator playlists, at 1 credit per playlist, and it pins the exact day a track went in or out. Hourly checks mostly buy duplicate snapshots.

Does the Spotify playlist scraper return every track in a long playlist?

No. It returns up to about 50 tracks per read, and track_count is the number returned, not the playlist's total length. For longer playlists, your diff covers the first ~50 positions. Because the tracks array is in playlist order, you get each track's position too.

Can I track Spotify editorial playlists?

Yes, if they're public. Web API apps created after November 27, 2024 can't read Spotify-owned editorial playlists. The Scrape Everything playlist endpoint reads the public page, so lists such as Today's Top Hits work like any other public playlist.

How much does it cost to monitor 30 playlists a day?

30 credits a day, or about 900 a month, before enrichment. The $39 Builder pack (25,000 credits) covers about 27 months of that. Track lookups with cache_days: 14 are free on repeat.

Start with one playlist

Pick the playlist you pitched hardest last release, run the script against it today and again tomorrow. The first diff will tell you whether this is worth automating for your whole roster.

Get 100 free credits

More from the blog

Start with 100 free credits.

Continue with Google, create an API key and send your first request in under a minute.

Get 100 free credits