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.
| Identifier | Example | URL form | Notes |
|---|---|---|---|
| Channel ID | UC_x5XG1OV2P6uZZ5FSM9Ttw | youtube.com/channel/UC... | The ID YouTube uses to identify the channel. Case-sensitive. Use it as your primary key. |
| Handle | @GoogleDevelopers | youtube.com/@GoogleDevelopers | Unique, not case-sensitive, and the owner can change it in YouTube Studio. |
| Legacy username | GoogleDevelopers | youtube.com/user/GoogleDevelopers | From the older username system. Resolvable with the API's forUsername filter. |
| Legacy custom URL | GoogleDevelopers | youtube.com/c/GoogleDevelopers | Older 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
- Step 1Create a Google Cloud project
In the Google Cloud console, create a project or pick an existing one.
- 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.
- Step 3Create an API key
Under Credentials, create an API key. Public channel data such as
channels.listby handle or ID only needs a key, not OAuth. Restrict the key to the YouTube Data API. - 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. - 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:
- A
channels.listcall costs 1 unit, whichever filter you use. - Projects get a default allocation of 10,000 units per day for all endpoints other than
search.listandvideos.insert, which now have their own limits of 100 calls per day each. - Every request costs at least 1 unit, including invalid requests.
- Daily quotas reset at midnight Pacific Time.
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 -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.
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
}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 foundPass 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.
- `youtube.com/user/<name>`: pass the name to
forUsernamewithchannelIdFromUsernameabove. It costs 1 unit like any otherchannels.listcall. - `youtube.com/c/<name>`: there is no
channels.listfilter for custom URLs. Many channels chose a handle that matches their old custom name, so tryingforHandlewith the same string can work, but a match is not proof that it is the same channel. Confirm it some other way before you rely on it, for example by checking the title or by reading the page's canonical link (described below). - `search.list` returns ranked results for a query, not an exact lookup, and its default limit is 100 calls per day. Avoid it for identifier resolution.
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.
- Batch size. The reference describes
idas a comma-separated list and capsmaxResultsat 50. It does not state a separate limit for the number of IDs, so the code sends 50 per call and setsmaxResults=50so the default of 5 can never truncate a batch. - Order. Do not assume results come back in the order you sent them. Index the response by
id. - `customUrl` is optional. The reference describes it only as the channel's custom URL, and a 2016 revision note says not all channels have one. Treat it as nullable, strip a leading
@if present, and check its format against a few channels you know before you build on it. - Titles are not handles.
snippet.titleis the display name. It is not unique and can contain spaces and emoji.
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 reason | HTTP | When it happens | What to do |
|---|---|---|---|
No items in a 200 response | 200 | Handle or username does not exist | Return null; cache the miss for a short time |
channelNotFound | 404 | A channel in the id parameter cannot be found | Treat as not found, as the helpers above do |
invalidCriteria | 400 | More than one filter in the request | Send exactly one of forHandle, forUsername or id |
channelForbidden | 403 | The channel does not support the request or it is not properly authorized | Log it and skip the channel |
quotaExceeded | 403 | Your project used its daily quota | Stop and retry after midnight Pacific Time, or request more quota |
badRequest (API key not valid) | 400 | Missing or wrong key | Fix 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:
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.
// 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;
}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 NoneUse this sparingly and only where the API cannot help. It is unofficial and fragile:
- The markup is not a documented interface and can change at any time.
- YouTube can serve consent pages, rate limits or a CAPTCHA to automated requests, especially from cloud IP ranges. In our testing, following a
youtu.belink from a cloud server ended on a Google CAPTCHA page. - Automated access to the site may conflict with YouTube's Terms of Service. Read them and prefer the official API.
Comparing the approaches
| Approach | Input | Cost | Pros | Cons |
|---|---|---|---|---|
channels.list with forHandle | Handle, with or without @ | 1 unit per handle | Official, exact match, works for any handle | One handle per call |
channels.list with forUsername | Legacy /user/ name | 1 unit per name | Official, exact match | Only for legacy usernames |
channels.list with id | Up to 50 channel IDs | 1 unit per 50 IDs | Official, batched, returns title and customUrl | customUrl can be missing |
search.list | Free text | Separate limit of 100 calls per day by default | Finds channels by name | Ranked guesses, not exact; small allowance |
| Channel page canonical link | Any channel URL, including /c/ | No API quota | Works without a key; resolves /c/ names | Unofficial, 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.