Your YouTube Video Metadata Audit Needs a Row for Every ID

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

A YouTube metadata audit starts with the IDs you intended to check. Join returned records to that list, so an ID with no row stays unresolved instead of disappearing from the report. Save field states and observation times before comparing titles, descriptions or other metadata across runs.

I founded Serpent API, whose documented 50-ID video lookup is used here. The October 4 source review, October 5 check of official YouTube alternatives, and illustrative fixtures support the workflow; the programs have not been verified against an authenticated production response.

This is an audit of a selected set, not a census of YouTube. The Python and JavaScript programs below implement the published Serpent video-details contract and use illustrative output. These examples have not been verified against an authenticated production response. If you need search, channel discovery or playlist sampling first, use the broader YouTube API guide; this page begins after you have the IDs.

What should an audit report actually say?

Make the requested IDs the denominator. For each ID, preserve one of two record states: a matched row or unresolved. Within a matched row, keep each audited field's value and a separate known/unknown state. That prevents a chart from treating an unavailable count as a genuine zero, and prevents a short answer from silently shrinking the audit population.

  1. Choose and deduplicate IDs. Keep the selected list as the denominator.
  2. Request batches of at most 50. Send each batch as comma-separated ids.
  3. Join returned rows by ID. Keep requested IDs without a matching row unresolved.
  4. Classify empty fields and record the snapshot. Preserve field states and the observation time.
Conservative interpretation of the documented Serpent video fields
FieldExample empty valueAudit treatment
description, publishedAt, categoryId""Unknown; do not report the video as lacking a description, date or category.
tags[]Unknown; not proof that the creator set no tags.
durationSeconds, viewCount, likeCount, commentCount0Unknown in this conservative report; a genuine zero is possible, but indistinguishable from the documented empty value here.
Requested ID with no returned rowNo rowUnresolved for this run; do not label deleted or private.

The delivery block can describe a short response, but the audit still computes its own requested-versus-matched set. A full response may have no delivery block. The output order is not the input order, so comparing array positions would silently attach one video's metrics to another ID.

A row of video ID tokens joins to several returned metadata tiles by shape, with a clear blank row retained for a missing ID
Join by video ID, not response position. This diagram shows the audit state for an illustrative short answer, not a measured delivery rate.

Step 1: choose IDs and make the workload explicit

Collect IDs from your own catalog, saved watch URLs or a separate discovery step, then deduplicate them. Keep the original input and an observation timestamp. For a daily audit of 100 selected IDs, each run needs at least two 50-ID calls. Thirty daily runs mean 3,000 requested ID observations and at least 60 calls, before any investigation of unresolved IDs. This is arithmetic, not a measured result rate.

If an ID list comes from a playlist, note the playlist's observed coverage separately. A selected-ID audit cannot prove playlist membership or detect every upload. The playlist monitoring workflow addresses that separate question.

Step 2: run the Python audit

Install requests, set SERPENT_API_KEY and a comma-separated VIDEO_IDS environment variable, then run this as python audit_youtube.py. It accepts more than 50 IDs because it makes separate batches. A failed batch remains unresolved in the report; records from other batches still appear.

import json
import os
from datetime import datetime, timezone

import requests

URL = "https://apiserpent.com/api/social/youtube/video"
KEY = os.environ["SERPENT_API_KEY"]
IDS = list(dict.fromkeys(
    value.strip() for value in os.environ["VIDEO_IDS"].split(",")
    if value.strip()
))
if not IDS:
    raise SystemExit("Set VIDEO_IDS to one or more video IDs")

EMPTY = {
    "description": "", "publishedAt": "", "categoryId": "",
    "tags": [], "durationSeconds": 0, "viewCount": 0,
    "likeCount": 0, "commentCount": 0,
}


def field_states(row):
    return {
        field: {"value": row.get(field),
                "state": "unknown" if row.get(field) is None or row.get(field) == empty else "known"}
        for field, empty in EMPTY.items()
    }


def audit(ids):
    matched = {}
    batch_errors = []
    for start in range(0, len(ids), 50):
        batch = ids[start:start + 50]
        try:
            response = requests.get(
                URL, params={"ids": ",".join(batch)},
                headers={"X-API-Key": KEY}, timeout=60,
            )
            body = response.json()
            if response.status_code == 404 and isinstance(body, dict) and body.get("error") == "Video not found":
                continue  # No matched row; keep these IDs unresolved.
            response.raise_for_status()
            if not isinstance(body, dict) or body.get("success") is not True or not isinstance(body.get("results"), list):
                raise ValueError("Unexpected parsed response shape")
            for row in body["results"]:
                if isinstance(row, dict) and row.get("id") in batch:
                    matched[row["id"]] = row
        except (requests.RequestException, ValueError) as exc:
            batch_errors.append({"batch_start": start, "message": str(exc)})

    return {
        "observed_at": datetime.now(timezone.utc).isoformat(),
        "requested_count": len(ids),
        "matched_count": len(matched),
        "unresolved_ids": [video_id for video_id in ids if video_id not in matched],
        "videos": [
            {"id": video_id, "title": matched[video_id].get("title"),
             "fields": field_states(matched[video_id])}
            for video_id in ids if video_id in matched
        ],
        "batch_errors": batch_errors,
    }


print(json.dumps(audit(IDS), indent=2, ensure_ascii=False))

The parsed-result check matters: an HTTP success alone does not establish that the expected results array arrived. The script keeps a matched row only when its id belongs to that batch. When a batch has a request or JSON error, batch_errors makes the gap visible without discarding other batches. Keep that report in a restricted location if your selected IDs are sensitive.

Step 3: use the JavaScript version if that fits your stack

This Node.js 18+ script uses the same denominator and field rules. Save as audit_youtube.mjs, set the same two environment variables, and run node audit_youtube.mjs.

const key = process.env.SERPENT_API_KEY;
const ids = [...new Set((process.env.VIDEO_IDS || "")
  .split(",").map(id => id.trim()).filter(Boolean))];
if (!key || ids.length === 0) throw new Error("Set SERPENT_API_KEY and VIDEO_IDS");

const empty = {
  description: "", publishedAt: "", categoryId: "",
  tags: [], durationSeconds: 0, viewCount: 0,
  likeCount: 0, commentCount: 0,
};
const sameEmpty = (value, marker) =>
  Array.isArray(marker) ? Array.isArray(value) && value.length === 0 : value === marker;
const fieldsFor = row => Object.fromEntries(
  Object.entries(empty).map(([field, marker]) => [field, {
    value: row[field] ?? null,
    state: row[field] == null || sameEmpty(row[field], marker) ? "unknown" : "known",
  }])
);

const matched = new Map();
const batchErrors = [];
for (let start = 0; start < ids.length; start += 50) {
  const batch = ids.slice(start, start + 50);
  const url = new URL("https://apiserpent.com/api/social/youtube/video");
  url.searchParams.set("ids", batch.join(","));
  try {
    const response = await fetch(url, {
      headers: {"X-API-Key": key}, signal: AbortSignal.timeout(60000),
    });
    const body = await response.json();
    if (response.status === 404 && body.error === "Video not found") continue;
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    if (body.success !== true || !Array.isArray(body.results)) {
      throw new Error("Unexpected parsed response shape");
    }
    for (const row of body.results) {
      if (row && batch.includes(row.id)) matched.set(row.id, row);
    }
  } catch (error) {
    batchErrors.push({batch_start: start, message: String(error)});
  }
}

const report = {
  observed_at: new Date().toISOString(),
  requested_count: ids.length,
  matched_count: matched.size,
  unresolved_ids: ids.filter(id => !matched.has(id)),
  videos: ids.filter(id => matched.has(id)).map(id => ({
    id, title: matched.get(id).title ?? null,
    fields: fieldsFor(matched.get(id)),
  })),
  batch_errors: batchErrors,
};
console.log(JSON.stringify(report, null, 2));

Both examples err on the side of unknown. A missing field in an unexpected response shape is also unknown. If an audit needs to distinguish a verified zero from an unavailable count, this contract alone cannot make that distinction.

What does the report look like?

The following shortened JSON is illustrative; it was written to show the classification, not captured from production. Imagine two requested IDs, with only one matching row and a viewCount empty value:

{
  "requested_count": 2,
  "matched_count": 1,
  "unresolved_ids": ["SECOND_ID"],
  "videos": [{
    "id": "FIRST_ID",
    "title": "Example video title",
    "fields": {
      "viewCount": {"value": 0, "state": "unknown"},
      "description": {"value": "An example description", "state": "known"},
      "tags": {"value": [], "state": "unknown"}
    }
  }],
  "batch_errors": []
}

The actual scripts output additional field states and an observed_at timestamp. Store reports as dated JSON files or rows keyed by (video_id, observed_at). Compare only fields marked known in both observations. A value moving from known to unknown is a coverage change, not evidence that the video's metric fell to zero.

Two dated film frames compared field by field, with an unknown field shaded separately from a real change
A change is reportable only when the field is known at both times. The cells are a logic example, not real video observations.

Which API fits this exact audit?

The comparison below is for 100 selected public video IDs once a day for 30 days, or 3,000 requested ID observations. It compares published request units and field contracts, not response quality. Prices and docs were checked October 4, 2026; verify them again before budgeting production use.

Same selected-ID workload, different billing units and fields
OptionHow the 3,000 ID observations map to requestsPublished cost model and useful distinctionLimit to plan around
YouTube Data API video details methodAt least 60 calls if IDs are grouped 50 per call.1 quota unit per call, so at least 60 units. Google documents quota units rather than a cash price. Choose resource parts such as snippet, statistics and contentDetails.Some owner-only parts need authorization; public fields depend on selected parts.
Serpent YouTube Video APIAt least 60 calls at the documented 50-ID ceiling.Default $0.20 per 1,000 calls: $0.012 for 60 calls, before extra checks. Growth/Scale list $0.02/$0.01 per 1,000 with qualifying deposits. Public detail rows use a fixed set of fields.Missing rows and documented empty values remain unknown; authenticated parsed delivery for this workflow has not been verified.
SerpApi YouTube Video APIIts documented v parameter selects one video, so the workload is 3,000 video queries.Starter $25/month for 1,000 searches; Developer $75/month for 5,000 covers 3,000 within the listed allowance. Its video response also documents related videos and comments.The monthly plan is not a marginal per-video rate; fields differ from both other APIs.

The official API is a strong choice when you want YouTube's own resource model, selected parts or authorized owner data. Serpent's documented batch shape makes a selected public-ID audit compact and cheap on its published rate card, provided its fields meet your audit needs. SerpApi may suit work that also needs its video-page fields. None of these published pages supplies a measured completeness rate for this particular list, and the request counts above do not imply one. For a broader provider landscape, see the YouTube API provider comparison.

How should you schedule and review the audit?

Run at the cadence your decisions require. Persist each run's ID set, timestamp, matched rows, unresolved IDs, unknown-field counts and errors. In a dashboard, show a coverage panel before any metric chart: “requested 100; matched 94; view counts known 83” is a safer summary than a single average built from whichever rows happened to arrive. These numbers are an example of a dashboard label, not a measured run.

For a change report, compare the same video ID and field across two accepted observations. If Monday has a known title and Friday has a different known title, report a title change with both observation times. If Monday has a known view count and Friday returns 0, label Friday's count unknown under this contract; do not report a loss of views. If Friday has no row, mark the video unresolved and retain Monday's value as a historical observation, not a current value. These are illustrative reporting rules, not observed video events.

Earlier snapshotLater snapshotSafe report
Matched ID; title knownSame ID; different known titleTitle changed between observation times
View count known0 or missing countCurrent count unknown; no numeric change calculated
Matched IDID unresolved or batch errorCoverage gap; investigate before any deletion or privacy claim

Investigate an unresolved ID using an appropriate source and your own access rights before classifying it. Do not automatically declare deletion or privacy changes from this response alone. For upload discovery and a bounded playlist sample, use the playlist monitoring article; for channel-level changes, the channel tracking walkthrough covers a different denominator.

FAQ

How many YouTube video IDs can I audit in one Serpent request?

The documented video-details request accepts at most 50 comma-separated IDs. Split larger lists into chunks of 50 or fewer and reconcile each returned row by its ID.

Does a missing video row mean the video was deleted?

No. A missing row is unresolved in that snapshot. It does not by itself establish deletion, privacy status or a permanent absence.

Does a zero view count prove that a video has no views?

No. Serpent's documented empty value for viewCount is 0, so a conservative audit labels 0 unknown unless another trusted source confirms the real count.

Which API is best for a video metadata audit?

Use YouTube's official video details method when you want its selected resource parts and quota model; consider Serpent for public detail rows batched up to 50 IDs per call; consider SerpApi when its individual-video response fields, such as related videos, fit the job. Verify field coverage and current pricing before choosing.

Build a selected-video audit

Start with the documented video endpoint, verify parsed rows by ID, and keep unknown values visible in your report.

Explore the YouTube API

Sources and verification

Verification note: sources and rate cards were checked October 4, 2026. The output above is illustrative, and the scripts follow the documented response shape; no authenticated production run for these selected IDs is shown.

Related Posts