sociallinks.dev

Blog / Developer guide

How to get a YouTube channel ID from a handle (and the other way round)

Creators share their YouTube channel as @handle, but almost everything in the YouTube Data API is keyed by channel ID. If you store handles alone, a rename breaks your records; if you store IDs alone, you cannot show users the name they recognize. This guide shows you how to resolve a handle to a channel ID with one API call, how to go from IDs back to handles 50 at a time, what to do with legacy /user/ and /c/ URLs, and how to stay inside the default quota.

Handles, channel IDs and legacy names

A YouTube channel can be reached through up to four kinds of identifier. Only one of them is guaranteed to be stable.

IdentifierExampleURL formNotes
Channel IDUC_x5XG1OV2P6uZZ5FSM9Ttwyoutube.com/channel/UC...The ID YouTube uses to identify the channel. Case-sensitive. Use it as your primary key.
Handle@GoogleDevelopersyoutube.com/@GoogleDevelopersUnique, not case-sensitive, and the owner can change it in YouTube Studio.
Legacy usernameGoogleDevelopersyoutube.com/user/GoogleDevelopersFrom the older username system. Resolvable with the API's forUsername filter.
Legacy custom URLGoogleDevelopersyoutube.com/c/GoogleDevelopersOlder personalized URLs, which YouTube says continue to work. No API filter resolves them.

Store the channel ID as the key and keep the handle as a display value you refresh. The rest of this guide is about getting from whatever the user gave you to that ID.

Set up an API key and understand the quota

  1. Step 1Create a Google Cloud project

    In the Google Cloud console, create a project or pick an existing one.

  2. Step 2Enable the YouTube Data API v3

    Open APIs and services, find YouTube Data API v3 in the library and enable it for the project.

  3. Step 3Create an API key

    Under Credentials, create an API key. Public channel data such as channels.list by handle or ID only needs a key, not OAuth. Restrict the key to the YouTube Data API.

  4. Step 4Keep the key out of your code

    Put it in an environment variable, for example YOUTUBE_API_KEY, and read it at runtime. The examples below do exactly that.

  5. Step 5Check your quota

    In the console, the Quotas page for the API shows your daily allocation and usage.

The numbers that matter, from Google's documentation:

So the default quota covers about 10,000 handle lookups a day, and up to 50 times as many ID lookups, because one call can fetch 50 channels by ID for the same single unit.

Handle to channel ID with forHandle

channels.list accepts exactly one filter. The forHandle filter takes a YouTube handle, and the reference says the value can be prepended with an @ symbol, so GoogleDevelopers and @GoogleDevelopers both work. Ask for part=id when you only need the ID; the response is smaller and the cost is the same.

cURL

curl -s "https://www.googleapis.com/youtube/v3/channels?part=id&forHandle=@GoogleDevelopers&key=$YOUTUBE_API_KEY"

The Node example below wraps the request in one helper, listChannels, that the rest of the guide reuses. It returns an empty list when nothing matches and throws on real errors such as a bad key or exhausted quota.

Node.js

const API = "https://www.googleapis.com/youtube/v3/channels";
async function listChannels(params) {
  const key = process.env.YOUTUBE_API_KEY;
  if (!key) throw new Error("Set YOUTUBE_API_KEY");
  const url = new URL(API);
  url.search = new URLSearchParams({ ...params, key }).toString();
  const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
  const data = await res.json();
  if (!res.ok) {
    const reason = data.error?.errors?.[0]?.reason;
    if (reason === "channelNotFound") return [];
    throw new Error(`YouTube API ${res.status} ${reason ?? ""}: ${data.error?.message}`);
  }
  return data.items ?? []; // no match: items is empty or missing
}

/** "@GoogleDevelopers" or "GoogleDevelopers" -> "UC_x5XG1OV2P6uZZ5FSM9Ttw" or null */
export async function channelIdFromHandle(handle) {
  const items = await listChannels({ part: "id", forHandle: handle.trim() });
  return items[0]?.id ?? null;
}

/** Legacy username from a youtube.com/user/<name> URL -> channel ID or null */
export async function channelIdFromUsername(username) {
  const items = await listChannels({ part: "id", forUsername: username.trim() });
  return items[0]?.id ?? null;
}

/** Any number of channel IDs -> Map(id -> { title, customUrl }) */
export async function channelsById(ids) {
  const unique = [...new Set(ids)];
  const found = new Map();
  for (let i = 0; i < unique.length; i += 50) {
    const batch = unique.slice(i, i + 50);
    const items = await listChannels({ part: "snippet", id: batch.join(","), maxResults: "50" });
    for (const ch of items) {
      found.set(ch.id, { title: ch.snippet.title, customUrl: ch.snippet.customUrl ?? null });
    }
  }
  return found; // IDs missing from the map were not found
}

Python

import os

import requests

API = "https://www.googleapis.com/youtube/v3/channels"


def list_channels(**params):
    key = os.environ.get("YOUTUBE_API_KEY")
    if not key:
        raise RuntimeError("Set YOUTUBE_API_KEY")
    response = requests.get(API, params={**params, "key": key}, timeout=10)
    data = response.json()
    if not response.ok:
        errors = data.get("error", {}).get("errors") or [{}]
        reason = errors[0].get("reason")
        if reason == "channelNotFound":
            return []
        raise RuntimeError(f"YouTube API {response.status_code} {reason}: {data.get('error', {}).get('message')}")
    return data.get("items", [])  # no match: items is empty or missing


def channel_id_from_handle(handle):
    """'@GoogleDevelopers' or 'GoogleDevelopers' -> 'UC_x5XG1OV2P6uZZ5FSM9Ttw' or None"""
    items = list_channels(part="id", forHandle=handle.strip())
    return items[0]["id"] if items else None


def channel_id_from_username(username):
    """Legacy username from a youtube.com/user/<name> URL -> channel ID or None"""
    items = list_channels(part="id", forUsername=username.strip())
    return items[0]["id"] if items else None


def channels_by_id(ids):
    """Any number of channel IDs -> {id: {"title", "customUrl"}}; missing IDs were not found"""
    unique = list(dict.fromkeys(ids))
    found = {}
    for i in range(0, len(unique), 50):
        batch = unique[i:i + 50]
        for ch in list_channels(part="snippet", id=",".join(batch), maxResults=50):
            found[ch["id"]] = {
                "title": ch["snippet"]["title"],
                "customUrl": ch["snippet"].get("customUrl"),
            }
    return found

Pass handles as the user wrote them, after decoding any percent-encoding from the URL. Handles can use letters from many scripts, not only ASCII, and both URLSearchParams and requests encode the parameter for you.

Legacy usernames and /c/ custom URLs

Old links in creator bios and spreadsheets often use one of the pre-handle forms.

Channel ID to handle, 50 at a time

Going the other way uses the id filter, which takes a comma-separated list of channel IDs. Request part=snippet and read snippet.customUrl. channelsById above deduplicates the IDs, sends them in chunks of 50, and returns a map keyed by ID. IDs that do not appear in the map were not found, deleted or are not visible to the API.

Not found, errors and caching

A lookup that matches nothing is not an error. For forHandle and forUsername the response simply has no items, so check for an empty or missing items array. The documented errors for channels.list are these:

Error reasonHTTPWhen it happensWhat to do
No items in a 200 response200Handle or username does not existReturn null; cache the miss for a short time
channelNotFound404A channel in the id parameter cannot be foundTreat as not found, as the helpers above do
invalidCriteria400More than one filter in the requestSend exactly one of forHandle, forUsername or id
channelForbidden403The channel does not support the request or it is not properly authorizedLog it and skip the channel
quotaExceeded403Your project used its daily quotaStop and retry after midnight Pacific Time, or request more quota
badRequest (API key not valid)400Missing or wrong keyFix configuration; do not retry

Cache results to save quota and latency, but respect YouTube's rules on storing data. The YouTube API Services Developer Policies (section III.E.4) say you may store non-authorized API data for no longer than 30 calendar days, after which you must delete or refresh it. A cache with a TTL well under 30 days, keyed by the lowercase handle, covers most of the benefit:

Node.js cache

import { channelIdFromHandle } from "./youtube.mjs";

const TTL_MS = 7 * 24 * 60 * 60 * 1000; // refresh well inside YouTube's 30-day limit
const cache = new Map(); // lowercase handle -> { id, at }

export async function cachedChannelId(handle) {
  const key = handle.trim().replace(/^@/, "").toLowerCase();
  const hit = cache.get(key);
  if (hit && Date.now() - hit.at < TTL_MS) return hit.id;
  const id = await channelIdFromHandle(handle);
  cache.set(key, { id, at: Date.now() }); // caches misses (null) too
  return id;
}

In production, put the same logic in Redis or a database table with an expires_at column, and give misses a shorter TTL than hits so a newly created handle is picked up quickly.

The no-API-key fallback: reading the channel page

Public channel pages include the channel ID in their HTML. A request for https://www.youtube.com/@GoogleDevelopers currently returns a page whose <link rel="canonical"> points at https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw, and the same is true of /c/ and /user/ pages, which makes this the one way to resolve a /c/ name without guessing.

Node.js

// Unofficial fallback: reads the public channel page. Fragile, prefer the API.
export async function channelIdFromPage(handle) {
  const url = `https://www.youtube.com/@${encodeURIComponent(handle.replace(/^@/, ""))}`;
  const res = await fetch(url, {
    headers: { "Accept-Language": "en-US" },
    signal: AbortSignal.timeout(10_000),
  });
  if (!res.ok) return null;
  const html = await res.text();
  const match = html.match(/<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/(UC[A-Za-z0-9_-]{22})"/);
  return match?.[1] ?? null;
}

Python

import re
from urllib.parse import quote

import requests

CANONICAL = re.compile(r'<link rel="canonical" href="https://www\.youtube\.com/channel/(UC[A-Za-z0-9_-]{22})"')


def channel_id_from_page(handle):
    """Unofficial fallback: reads the public channel page. Fragile, prefer the API."""
    url = "https://www.youtube.com/@" + quote(handle.lstrip("@"), safe="._-")
    response = requests.get(url, headers={"Accept-Language": "en-US"}, timeout=10)
    if not response.ok:
        return None
    match = CANONICAL.search(response.text)
    return match.group(1) if match else None

Use this sparingly and only where the API cannot help. It is unofficial and fragile:

Comparing the approaches

ApproachInputCostProsCons
channels.list with forHandleHandle, with or without @1 unit per handleOfficial, exact match, works for any handleOne handle per call
channels.list with forUsernameLegacy /user/ name1 unit per nameOfficial, exact matchOnly for legacy usernames
channels.list with idUp to 50 channel IDs1 unit per 50 IDsOfficial, batched, returns title and customUrlcustomUrl can be missing
search.listFree textSeparate limit of 100 calls per day by defaultFinds channels by nameRanked guesses, not exact; small allowance
Channel page canonical linkAny channel URL, including /c/No API quotaWorks without a key; resolves /c/ namesUnofficial, fragile, may be blocked, may conflict with the terms

For most products the right setup is simple: resolve handles with forHandle, store the channel ID, refresh handles in batches of 50 with the id filter, and keep the page fallback behind a flag for the rare /c/ link. If you also need the creator's accounts on other platforms, sociallinks.dev is an API that takes one Instagram, TikTok, YouTube or X profile URL and returns the creator's accounts on other platforms, with a confidence score and the evidence for each match.

FAQ

Do I need the @ when I pass a handle to forHandle?

No. The channels.list reference says the forHandle value can be prepended with an @ symbol, and gives both GoogleDevelopers and @GoogleDevelopers as examples.

How much quota does a handle lookup use?

One unit. Every channels.list call costs 1 unit, and the default allocation is 10,000 units per day, resetting at midnight Pacific Time. Invalid requests cost at least 1 unit too.

Can I look up several handles in one call?

No. forHandle takes a single handle. Only the id filter accepts a comma-separated list, so batch the reverse direction (IDs to handles) and cache the forward direction.

Does a channel ID ever change?

The channel ID is the identifier YouTube uses to uniquely identify the channel. Changing the handle or the title does not change it. That is why it is the right primary key. Handles, titles and custom URLs can all change.

Why does snippet.customUrl come back empty?

Not every channel has a custom URL, as YouTube's own revision history notes. Treat the field as optional and fall back to showing the /channel/UC... URL.

How long can I cache the results?

YouTube's Developer Policies limit stored API data to 30 calendar days before you delete or refresh it. A TTL of a week or less for hits, and shorter for misses, keeps you well inside that.

Find a creator’s accounts