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?
| Option | Spotify editorial playlists | Removal history | Other artists' placements | Setup |
|---|---|---|---|---|
| Spotify for Artists | Your own tracks only | No | No | Access to the artist profile |
| Spotify Web API (app created after 2024-11-27) | Not readable | Only if you diff snapshots yourself | Yes, on playlists the app can read | Premium account, developer app, access token |
| Scrape Everything playlist endpoint | Readable, it's a public page | Yes, by diffing daily | Yes | API 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.
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:
{
"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.
- Keep a plain list of the playlists you care about: pitch targets, genre staples, a few editorial ones.
- Once a day, fetch each playlist and write its track IDs and positions to a dated JSON file.
- Load the most recent earlier snapshot, compute the set difference both ways and write "entered" and "left" rows.
- Filter those rows for the artists or track IDs you own and send that slice to Slack, email or a sheet.
- 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.
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):
entered Example Playlist | #12 Example Track by Example Artist
out of top 50 Example Playlist | #49 Older Track by Another ArtistWe 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):
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:
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:
| Endpoint | Input | What it returns | Credits |
|---|---|---|---|
spotify.playlist | Playlist link, URI or ID | Title, owner, cover, total duration, up to ~50 tracks | 1 |
spotify.track | Track link, URI or ID | Structured artists array, release_date, is_explicit, is_playable, has_video, cover | 1 |
spotify.artist | Artist link, URI or ID | Name, image, top tracks (~10) with durations and preview URLs | 1 |
spotify.album | Album link, URI or ID | Title, artists, release date, cover, full track list | 1 |
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:
npx skills add scrapeeverything/skills --skill spotify-artist-and-playlist-researchThen 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:
| Pack | Price | Credits | Per 1k credits | Monthly cost at 30 playlists/day | Months covered at 30/day |
|---|---|---|---|---|---|
| Builder | $39 | 25,000 | $1.56 | $1.40 | 27 |
| Growth | $119 | 100,000 | $1.19 | $1.07 | 111 |
| Scale | $449 | 500,000 | $0.90 | $0.81 | 555 |
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_daysout. Evencache_days: 1can 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.comlinks,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.
owneris 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.