A YouTube Playlist Looks Shorter. Should You Send an Alert?

By Anurag Pathak, Founder, Serpent API··12 min read

To see whether a YouTube playlist changed, compare video IDs from two dated snapshots. A short or incomplete second capture should stay provisional: an unseen ID is not proof of removal. This guide shows how to preserve the last accepted baseline, report additions and possible removals, and choose between a sampled lookup and the official paginated API.

I founded Serpent API, one of the playlist options compared here. The October 4 source review, October 5 check of official YouTube alternatives, and illustrative example fixtures support the workflow; authenticated production delivery and full playlist coverage have not been established.

This guide gives a complete Python and JavaScript snapshot script, shows the labels a diff should produce, and compares the official YouTube API and a named playlist actor for the same monitoring task. The response examples and budget worksheet are illustrative; authenticated parsed delivery for this workflow has not been verified.

What can a playlist snapshot actually tell you?

The documented GET /api/social/youtube/playlist request accepts a playlist ID (or URL) and video_count from 1 to 50. Its videos[] rows contain video details, including ID, title, channel, description and best-effort statistics. total_results counts returned rows; it is not the playlist's total membership. A delivery block can accompany a short response when you explicitly ask for a count. The documented contract is the basis for the examples below.

A bounded observation window cuts through a long scrolling playlist ribbon
One request covers an observation window, not the whole playlist. Rows that do not resolve can make the returned list shorter.

Compare unique IDs to detect first appearances in your sample. A playlist can repeat a video ID; this response does not establish a stable position for each returned detail row. A newly observed ID is not necessarily a new upload: it may have moved into the first 50 entries. Likewise, an unseen ID may have moved past the window, become private or unavailable, or failed to resolve. To verify a specific membership change, inspect the playlist through a source with the coverage and permissions your task requires.

How do you build a repeatable snapshot?

  1. Choose a public playlist. Copy the ID after list= in its URL, and set a monitoring cadence.
  2. Fetch and validate a sample. Request video_count=50; check the parsed playlist ID, videos[], returned row count and each row's video ID.
  3. Save a timestamped snapshot. Persist the playlist ID, observed rows and any delivery note so the next run has a comparable baseline.
  4. Compare unique IDs. Report newly observed and not observed today. Do not label the latter “removed.”

Set SERPENT_API_KEY and YOUTUBE_PLAYLIST_ID in your environment. Keep the key out of source control. The Python version needs pip install requests; the JavaScript version uses Node.js 18 or later.

Complete Python example

Save as playlist_watch.py and run it twice, at different times, in the same directory. It refuses malformed parsed results instead of saving them as an empty playlist.

import json
import os
from datetime import datetime, timezone
from pathlib import Path

import requests

KEY = os.environ["SERPENT_API_KEY"]
PLAYLIST_ID = os.environ["YOUTUBE_PLAYLIST_ID"]
STATE = Path("playlist-state.json")
PROVISIONAL = Path("playlist-provisional.json")
URL = "https://apiserpent.com/api/social/youtube/playlist"


def capture():
    response = requests.get(
        URL,
        params={"playlist_id": PLAYLIST_ID, "video_count": 50},
        headers={"X-API-Key": KEY},
        timeout=60,
    )
    response.raise_for_status()
    data = response.json()
    videos = data.get("videos")
    if (data.get("playlist_id") != PLAYLIST_ID
            or not isinstance(videos, list)
            or data.get("total_results") != len(videos)
            or any(not isinstance(v, dict) or not isinstance(v.get("id"), str)
                   or not v["id"] for v in videos)):
        raise ValueError("Playlist response has no usable video sample")

    return {
        "playlist_id": PLAYLIST_ID,
        "observed_at": datetime.now(timezone.utc).isoformat(),
        "rows": [{"id": v["id"], "title": v.get("title", "")}
                 for v in videos],
        "delivery": data.get("delivery"),
    }


def main():
    previous = json.loads(STATE.read_text()) if STATE.exists() else None
    if previous and previous.get("playlist_id") != PLAYLIST_ID:
        raise ValueError("State file belongs to another playlist")
    current = capture()
    now_ids = {row["id"] for row in current["rows"]}
    print(f"Observed {len(current['rows'])} rows at {current['observed_at']}")
    if current["delivery"]:
        print("Short-response note:", current["delivery"].get("note", "see delivery"))
    if previous:
        before_ids = {row["id"] for row in previous["rows"]}
        print("Newly observed:", sorted(now_ids - before_ids))
        print("Not observed today (not confirmed removed):",
              sorted(before_ids - now_ids))
    else:
        print("Baseline saved; run again to compare.")
    if (previous and len(current["rows"]) < len(previous["rows"])
            and os.environ.get("ACCEPT_LOWER_COUNT") != "1"):
        PROVISIONAL.write_text(json.dumps(current, indent=2) + "\n")
        print("Lower row count: review provisional file and playlist; "
              "accepted baseline was not replaced.")
        return
    STATE.write_text(json.dumps(current, indent=2) + "\n")


if __name__ == "__main__":
    main()

Complete JavaScript example

Save as playlist-watch.mjs and run with node playlist-watch.mjs. It applies the same parsed-result checks and keeps the same state format as the Python example.

import { readFile, writeFile } from "node:fs/promises";

const key = process.env.SERPENT_API_KEY;
const playlistId = process.env.YOUTUBE_PLAYLIST_ID;
if (!key || !playlistId) throw new Error("Set both API key and playlist ID");
const stateFile = "playlist-state.json";
const provisionalFile = "playlist-provisional.json";

async function capture() {
  const url = new URL("https://apiserpent.com/api/social/youtube/playlist");
  url.searchParams.set("playlist_id", playlistId);
  url.searchParams.set("video_count", "50");
  const response = await fetch(url, { headers: { "X-API-Key": key },
    signal: AbortSignal.timeout(60_000) });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const data = await response.json();
  const videos = data.videos;
  if (data.playlist_id !== playlistId || !Array.isArray(videos) ||
      data.total_results !== videos.length ||
      videos.some(v => !v || typeof v.id !== "string" || !v.id)) {
    throw new Error("Playlist response has no usable video sample");
  }
  return {
    playlist_id: playlistId,
    observed_at: new Date().toISOString(),
    rows: videos.map(v => ({ id: v.id, title: v.title ?? "" })),
    delivery: data.delivery ?? null
  };
}

let previous = null;
try { previous = JSON.parse(await readFile(stateFile, "utf8")); }
catch (error) { if (error.code !== "ENOENT") throw error; }
if (previous && previous.playlist_id !== playlistId) {
  throw new Error("State file belongs to another playlist");
}
const current = await capture();
const nowIds = new Set(current.rows.map(v => v.id));
console.log(`Observed ${current.rows.length} rows at ${current.observed_at}`);
if (current.delivery) console.log("Short-response note:",
  current.delivery.note ?? "see delivery");
if (previous) {
  const beforeIds = new Set(previous.rows.map(v => v.id));
  console.log("Newly observed:", [...nowIds].filter(id => !beforeIds.has(id)));
  console.log("Not observed today (not confirmed removed):",
    [...beforeIds].filter(id => !nowIds.has(id)));
} else {
  console.log("Baseline saved; run again to compare.");
}
if (previous && current.rows.length < previous.rows.length &&
    process.env.ACCEPT_LOWER_COUNT !== "1") {
  await writeFile(provisionalFile, JSON.stringify(current, null, 2) + "\n");
  console.log("Lower row count: review provisional file and playlist; " +
    "accepted baseline was not replaced.");
} else {
  await writeFile(stateFile, JSON.stringify(current, null, 2) + "\n");
}

How should you read the difference?

Suppose yesterday's observed IDs were A, B, C, D and today's were B, C, D, E. The script reports E as newly observed and A as not observed today. It makes no claim about whether A is still somewhere in the playlist. These IDs and the following output are illustrative, not results of a production test.

Observed 4 rows at 2026-10-04T09:00:00+00:00
Newly observed: ['E']
Not observed today (not confirmed removed): ['A']
Two dated piles of video ID tiles overlap
A set difference gives two useful labels, but neither one establishes the full history or membership of a playlist.

Keep the delivery note and row count with each snapshot. A short response may reflect a small public playlist or an incomplete observation. If the row count drops, both scripts save that observation to playlist-provisional.json and leave the last accepted baseline intact. Review the returned IDs, delivery note and playlist directly before sending an alert. If you decide the smaller observed window should become the next comparison baseline, rerun with ACCEPT_LOWER_COUNT=1; that accepts a new observation, not a confirmed removal. Keep dated provisional files in a durable store if you need an audit trail. Missing best-effort fields such as an empty publishedAt or a viewCount of 0 are unknown, not verified zero. The scripts compare IDs only, so uncertain statistics cannot create false change alerts.

Which API fits this monitoring task?

The choices below were checked against the providers' own pages on . They differ in coverage, returned fields and billing units; the prices are not interchangeable per video.

OptionSame task: observe a public playlistCoverage and important differencePublished billing unit
YouTube Data APIplaylistItems.list returns playlist items; use video details method if video details are needed.Up to 50 items per page, with page tokens for longer playlists. Owner-only data depends on authorization and permissions.1 quota unit for each listed method call; these are quota units, not a cash price.
Serpent APIOne playlist request returns resolved video-detail rows from the first observation window.At most the first 50 entries; no public cursor. A returned list can be shorter than requested.Published YouTube rate card: $0.20 / $0.02 / $0.01 per 1,000 calls at Default / Growth / Scale; higher tiers have qualifying deposits.
Dami Studio playlist actor on ApifyRuns a playlist extraction that can traverse longer lists.Actor-specific returned fields differ from a video-detail response; inspect its output before choosing it for a metadata audit.Actor listing: $0.40 per 1,000 returned video rows, not per request.

If you need a complete long-playlist walk, the official API's page tokens are the direct documented choice. If your job is repeated observation of a known public playlist's first window with detail rows, Serpent offers a single-call shape. If you want an actor workflow and row-based billing, inspect the named actor's current output. The broader YouTube API provider comparison covers other tasks; this table is deliberately about playlist monitoring.

What would a daily monitoring budget look like?

Illustrative worksheet, not measured delivery: 10 playlists observed once daily for 30 days make 300 observation runs. At one Serpent call per run, the published Default rate gives 300 × $0.20 / 1,000 = $0.06 in request charges, excluding storage and extra detail calls. If each run returns 50 billable rows, the named Apify actor's listed row rate gives 15,000 × $0.40 / 1,000 = $6. Those rows do not promise the same fields. On the official API, one 50-item playlist call and one 50-ID video-details call per run would use 600 method quota units, not $600 or a stated cash cost.

Recheck prices, deposits, actor output and account permissions before committing to a schedule. For a playlist with more than 50 entries, Serpent's documented endpoint does not provide a way to buy complete traversal by asking for more calls with a cursor; choose a paginated source when that coverage is required.

Frequently asked questions

Does an unseen video mean it was removed from the playlist?

No. It was not observed in the latest resolved sample. It may have moved outside the first 50 entries, become unavailable, or been omitted from a short response. Check the playlist through an appropriate source before claiming removal.

Can Serpent paginate past the first 50 playlist entries?

The documented playlist endpoint accepts video_count up to 50 and does not expose a public cursor. Use the official YouTube playlistItems.list page tokens when your task requires traversal beyond that window.

Why can the response contain fewer videos than requested?

A playlist can contain fewer public videos, and not every entry necessarily resolves into a returned video row. Inspect videos, total_results and any delivery block; do not turn a short sample into a removal event.

How often should I capture a playlist snapshot?

Choose a cadence that matches the changes you need to notice and your request budget. A daily run is an illustrative starting point, not a measured ideal; more frequent polling costs more calls and still does not prove full membership.

Build a playlist observation job

Start with one public playlist, save two snapshots, and review the parsed rows before scheduling alerts.

Read the YouTube API docs

Also see: YouTube API complete guide · channel monitoring guide

Sources and checks

Related Posts