Track Competitor YouTube Channels with an API (2026)

By Serpent API Team · · 10 min read

To track competitor YouTube channels programmatically, you need three API calls: a channel lookup for subscriber and upload stats, a batch video-details call for per-video view velocity, and a search call to discover channels you don't know about yet. This guide assembles those three into a complete tracker — snapshot, diff, report — in about 100 lines of Python. Every request and every output below was run against the live Serpent API YouTube endpoints on July 18, 2026, tracking three real coffee-gear channels.

Our YouTube API complete guide sketches competitor analysis as a use case in a few lines; this post is the full working build — with the state file, the diffing, and two honest data quirks we hit that will shape how you design yours.

The three endpoints, and what they actually return

The tracker uses the three social data API endpoints for YouTube. The fields listed here aren't copied from marketing pages — they're what the live responses contained in the July 2026 runs for this post:

Endpoint Role in the tracker Key fields verified live
/api/social/youtube/channel Per-competitor snapshot subscriberCount, viewCount, videoCount, publishingFrequency, daysSinceLastVideo, recentVideos[] with full per-video stats
/api/social/youtube/video Velocity refresh, up to 50 IDs per call viewCount, likeCount, commentCount, engagementRate, duration, tags
/api/social/youtube/search Channel discovery + keyword radar type=channel results; with details=true, video results carry stats too

Authentication is one header (X-API-Key), and the channel endpoint accepts an @handle, a full URL, or a raw channel ID — the response always includes the canonical channelId, so you never have to hunt for it manually. Full parameter tables live in the API docs.

Step 1: discover who to track

If you already know your competitors, skip ahead. If you're mapping a niche, search for channels instead of videos:

import requests

r = requests.get(
    "https://apiserpent.com/api/social/youtube/search",
    params={"q": "coffee gear reviews", "type": "channel", "num": 5},
    headers={"X-API-Key": API_KEY}, timeout=60,
)
for c in r.json()["results"]:
    print(c["position"], c["title"], c["channelId"])

Real output from the July 18 run (592 ms):

1 Seattle Coffee Gear UC9VhfQi-D-7morDdwjVcJPA
2 Cool Coffee Gear UCsKF52zvkkcx7PZWPr1KvoA
3 Coffee Machine Ratings UCtxLVfWVq4YWp9SRQqXbTlA
4 Espresso Machine Reviews UCbY1W3kB1KX9tI8E9MDK06w
5 BrewSmart Reviews UCgL8vjVreCKnetzb_MQVrvQ

Step 2: the tracker — snapshot, diff, velocity

The complete script. Each run snapshots every competitor channel, saves it to a JSON state file, and diffs against the previous run: subscriber movement, new uploads (new video IDs), and view growth on videos present in both snapshots.

"""yt_tracker.py — track competitor YouTube channels with Serpent API."""
import json
import os
import sys
from datetime import datetime, timezone

import requests

API = "https://apiserpent.com/api/social/youtube"
KEY = os.environ["SERPENT_API_KEY"]
STATE_FILE = "yt_snapshots.json"

COMPETITORS = ["@jameshoffmann", "@SeattleCoffeeGear", "@LanceHedrick"]


def get(path, **params):
    r = requests.get(f"{API}/{path}", params=params,
                     headers={"X-API-Key": KEY}, timeout=60)
    r.raise_for_status()
    data = r.json()
    if not data.get("success"):
        sys.exit(f"API error on /{path}: {data}")
    return data


def snapshot_channel(handle):
    """One channel snapshot: headline stats + its 10 most recent uploads."""
    ch = get("channel", handle=handle, include_videos="true", video_count=10)
    return {
        "channelId": ch["channelId"],
        "title": ch["title"],
        "subscriberCount": ch["subscriberCount"],
        "viewCount": ch["viewCount"],
        "videoCount": ch["videoCount"],
        "publishingFrequency": ch.get("publishingFrequency"),
        "daysSinceLastVideo": ch.get("daysSinceLastVideo"),
        "videos": {
            v["id"]: {
                "title": v["title"],
                "publishedAt": v["publishedAt"],
                "viewCount": v.get("viewCount"),
                "engagementRate": v.get("engagementRate"),
            }
            for v in ch.get("recentVideos", [])
        },
    }


def refresh_tracked_videos(video_ids):
    """Batch-refresh stats for up to 50 videos in ONE call."""
    if not video_ids:
        return {}
    data = get("video", ids=",".join(sorted(video_ids)[:50]))
    return {v["id"]: v for v in data["results"]}


def main():
    previous = {}
    if os.path.exists(STATE_FILE):
        previous = json.load(open(STATE_FILE))

    now = datetime.now(timezone.utc).isoformat(timespec="seconds")
    current = {"takenAt": now, "channels": {}}

    for handle in COMPETITORS:
        snap = snapshot_channel(handle)
        current["channels"][handle] = snap
        prev = previous.get("channels", {}).get(handle)

        print(f"\n=== {snap['title']} ({handle})")
        print(f"    subscribers {snap['subscriberCount']:,} | "
              f"total views {snap['viewCount']:,} | "
              f"videos {snap['videoCount']:,} | "
              f"cadence {snap['publishingFrequency']} | "
              f"last upload {snap['daysSinceLastVideo']}d ago")

        if not prev:
            print("    first run — baseline recorded")
            continue

        sub_delta = snap["subscriberCount"] - prev["subscriberCount"]
        view_delta = snap["viewCount"] - prev["viewCount"]
        print(f"    since {previous['takenAt']}: "
              f"subs {sub_delta:+,} | channel views {view_delta:+,}")

        new_ids = set(snap["videos"]) - set(prev["videos"])
        for vid in sorted(new_ids,
                          key=lambda i: snap["videos"][i]["publishedAt"]):
            v = snap["videos"][vid]
            print(f"    NEW UPLOAD: {v['title']} ({v['publishedAt'][:10]})")

        # view velocity on videos present in BOTH snapshots
        common = set(snap["videos"]) & set(prev["videos"])
        fresh = refresh_tracked_videos(common)
        movers = []
        for vid in common:
            then = prev["videos"][vid].get("viewCount")
            live = fresh.get(vid, {}).get("viewCount")
            if then is not None and live is not None and live - then > 0:
                movers.append((live - then, snap["videos"][vid]["title"]))
        for gained, title in sorted(movers, reverse=True)[:3]:
            print(f"    +{gained:,} views since last run: {title[:60]}")

    json.dump(current, open(STATE_FILE, "w"), indent=1)
    print(f"\nSnapshot saved to {STATE_FILE} at {now}")


if __name__ == "__main__":
    main()

First run — the baseline, exactly as printed on July 18, 2026:

=== James Hoffmann (@jameshoffmann)
    subscribers 2,540,000 | total views 385,732,204 | videos 404 | cadence 1 video/month | last upload 1d ago
    first run — baseline recorded

=== Seattle Coffee Gear (@SeattleCoffeeGear)
    subscribers 693,000 | total views 432,968,650 | videos 2,590 | cadence 5 videos/week | last upload 2d ago
    first run — baseline recorded

=== Lance Hedrick (@LanceHedrick)
    subscribers 438,000 | total views 58,198,005 | videos 369 | cadence 2 videos/week | last upload 1d ago
    first run — baseline recorded

That single call per channel already answers the strategic questions: Hoffmann posts monthly to 2.5M subscribers, Seattle Coffee Gear floods the zone with five uploads a week, Lance Hedrick sits in between at two. publishingFrequency, daysSinceLastVideo and the per-video stats all arrive because of include_videos=true — without it you get the headline counts only.

What the second run revealed (two honest quirks)

Here's the diff output from a second run one minute later:

=== James Hoffmann (@jameshoffmann)
    subscribers 2,540,000 | total views 385,732,204 | videos 404 | cadence 1 video/month | last upload 1d ago
    since 2026-07-18T09:58:12+00:00: subs +0 | channel views +0
    +1 views since last run: Wendougee: The Most Interesting Manufacturer in China?

Two things in that output should shape your design, and neither is documented anywhere we could find:

The batch endpoint takes up to 50 comma-separated IDs per request and bills it as one call, so refreshing all thirty tracked videos across three competitors costs one request, not thirty. The engagementRate it returns is (likes + comments) / views as a percentage — we checked it against the raw numbers: 4,417,626 views, 124,576 likes, 6,720 comments → 2.97, exactly as the field reported.

Over a week of daily runs, the interesting comparisons fall out of the state file for free: which competitor's uploads accelerate fastest in their first 48 hours, whether a channel's engagementRate holds up as view counts climb (a rough signal of how loyal their audience is versus how algorithm-driven), and whether daysSinceLastVideo starts stretching — often the earliest public signal that a rival is slowing down or shifting formats.

Step 3: the keyword radar (optional third angle)

Channel tracking tells you what competitors did; a keyword search tells you who's winning the topics you care about. Add details=true and each result carries stats without a second call:

r = requests.get(
    "https://apiserpent.com/api/social/youtube/search",
    params={"q": "espresso machine review", "num": 5,
            "order": "viewCount", "details": "true",
            "published_after": "2026-06-01T00:00:00Z"},
    headers={"X-API-Key": API_KEY}, timeout=60,
)

Top result in the live run: James Hoffmann's Philips Baristina review — 676,213 views, 3.06 engagement rate, published June 29. If he weren't on your competitor list yet, this is how you'd find out he should be. order=date flips the same call into a new-upload alert for any topic.

Scheduling and what it costs

The script is stateless between runs except for its JSON file, so any scheduler works — cron, GitHub Actions, an n8n workflow, a task queue. Pick the cadence that fits how you use the data (given the rounding behavior above, daily is where channel-level deltas become meaningful; per-video velocity is worth measuring at whatever rhythm you review it), and size any parallelism to your account's documented allocation — the rate-limit comparison covers how those allocations work across providers.

Cost is easy to reason about because every YouTube request is one flat-priced call: a daily three-competitor run is 3 channel calls + 1 batch refresh = 4 calls, about 120 per month. At the entry-tier price of $0.20 per 1,000 YouTube requests (live pricing — it drops to $0.02 and $0.01 per 1,000 at higher tiers), that's about two cents a month; a 50-competitor daily tracker with keyword radar still lands around 2,000 calls, roughly $0.40. There's no daily quota to budget against, and the 10 free requests on a free account cover this entire tutorial.

Why not the official YouTube Data API?

You can build the same tracker on Google's official Data API v3, and it remains the right tool if you need write access (uploading, playlists, comments) or private channel analytics via OAuth. For read-only public tracking, its friction is well known: a Google Cloud project, OAuth credential management, and a 10,000-unit daily quota in which a single search costs 100 units — with increases needing an application Google can deny. Our side-by-side comparison walks the full trade-off table, and the older YouTube data API guide shows the same lookups done both ways.

The pattern here extends past YouTube, too — the same snapshot-and-diff loop works for an Instagram profile tracker or a cross-platform video search monitor, all on the same key.

FAQ

Can I track competitors without the official API's quota?

Yes — for public read-only data you only need an API key. No Google Cloud project, no OAuth, no daily quota wall, and YouTube requests start at $0.20 per 1,000.

How do I find a competitor's channel ID?

Pass their @handle or channel URL to the channel endpoint — the response includes the canonical channelId. For niches you're mapping, type=channel search returns IDs directly.

Why did subscriber counts show +0 between runs?

They're rounded public figures that move in steps over days. Measure short-term momentum with per-video view counts via the batch endpoint — in our runs those ticked up within a single minute.

How many videos can one call refresh?

Up to 50 IDs per video-details request, billed as one call.

What does engagementRate mean?

(Likes + comments) ÷ views × 100. Verified against live numbers in this post: 124,576 likes + 6,720 comments on 4,417,626 views = 2.97.

Start tracking your competitors today

Every call in this build ran against the live API before publishing. The whole tutorial fits inside the free tier — 10 requests, no card.

Start Free — 10 Requests, No Card

Explore: YouTube API · API Docs · Pricing

References

Related Posts