Blog / Developer guide
How to parse and normalize social media profile URLs (Instagram, TikTok, YouTube, X)
Creators paste their profile links in every shape imaginable: with and without www, from the mobile app with tracking parameters, as short links, as old twitter.com URLs, or as a link to a post instead of the profile. If your product stores those strings as they arrive, the same creator shows up three times and your joins quietly miss. This guide shows you how to turn any Instagram, TikTok, YouTube or X profile URL into a platform, a handle or channel ID, and one canonical URL, with tested code in JavaScript and Python.
Why normalizing profile URLs matters
A profile URL is an identifier, and identifiers only work when they are written one way. https://www.instagram.com/natgeo/, instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA== and https://m.instagram.com/natgeo all point at the same account, but as strings they are three different values.
- Deduplication. When a creator signs up twice, or two team members add the same influencer to a campaign, you want one record, not two.
- Joins. Matching your CRM against an export from another tool only works if both sides use the same key.
- Rejecting bad input early. A link to a reel, a search page or a login screen is not a profile. Catching it at input time is cheaper than cleaning it up later.
- Calling APIs. Official APIs want a handle or an ID, not a URL with tracking parameters attached.
The goal is a small, strict function: it accepts what users actually paste, returns a normalized result for real profile URLs, and returns null for everything else.
The URL shapes you will see
The shapes below were checked against the live sites in October 2026. Platforms change their routing without notice, so treat this as a snapshot and keep your test cases current.
- Profiles live at
https://www.instagram.com/<handle>/. The bareinstagram.comandm.instagram.comhosts redirect towww.instagram.com. - Links shared from the app often carry an
?igsh=parameter. Drop the whole query string and the fragment. - Posts are
/p/<code>/, reels are/reel/<code>/, and stories are/stories/<handle>/<id>/. None of these is a profile URL, even when the handle appears in the path. - App routes such as
/explore/,/accounts/and/direct/share the first path segment with handles, so you need a list of reserved words. The list in the code below is not exhaustive.
TikTok
- Profiles are
https://www.tiktok.com/@<handle>. The baretiktok.comhost redirects towww. - Videos are
/@<handle>/video/<id>. The handle is the author, but the URL is a video link, so the parser below rejects it. - Short links on
vm.tiktok.com,vt.tiktok.comandwww.tiktok.com/t/<code>/carry no handle at all. You have to follow the HTTP redirect to see where they lead, then parse the final URL. - A path without
@(for exampletiktok.com/khaby.lame) is not a profile URL.
YouTube
- Handle URLs:
https://www.youtube.com/@<handle>. YouTube's Help Center says a handle automatically becomes the channel's URL. - Channel ID URLs:
https://www.youtube.com/channel/UC.... This is the stable form. Channel IDs in practice are 24 characters starting withUC. Google does not document the format, so the check below is a sanity bound, not a rule. - Legacy forms:
/user/<username>and/c/<customname>. Both still resolve today, and the Help Center says legacy personalized URLs continue to work. Neither tells you the channel ID without a lookup. - A bare
youtube.com/<name>also still resolves for some channels, but it shares the namespace with site routes, so the parser below does not accept it. - Tabs such as
/@handle/videosand/channel/UC.../featuredbelong to the channel and are safe to accept. m.youtube.comredirects towww.youtube.com. Video links (/watch?v=,/shorts/,youtu.be/<id>) are not channels.
X (formerly Twitter)
- Profiles are
https://x.com/<handle>.twitter.com,www.twitter.com,www.x.com,mobile.twitter.comandmobile.x.comall redirect to a profile onx.comortwitter.com, so treat them as one platform. - Posts are
/<handle>/status/<id>. Like TikTok videos, they name the author but are not profile URLs. - App routes such as
/home,/explore,/search,/settings,/notifications,/messagesand anything under/i/are not handles. - Shared links often add
?s=and?t=parameters. Drop them.
Handle character rules per platform
Validating the handle catches typos and most non-profile paths. Use the platforms' own rules where they publish them, and keep the checks loose where they do not, so you never reject a real account.
| Platform | Allowed characters | Length | Case | Source |
|---|---|---|---|---|
| Letters, numbers, periods, underscores | 30 is widely cited; not found in Instagram's help pages | Treat as case-insensitive; store lowercase | Instagram Help Center mentions periods, numbers and underscores | |
| TikTok | Letters, numbers, underscores, periods; cannot end with a period | 2 to 24 | Case-insensitive in URLs (@TikTok resolves to tiktok) | TikTok Support, changing your username |
| YouTube | Letters or numbers from supported languages, plus underscores, hyphens, periods and Latin middle dots (not first or last) | 3 to 30 for most scripts; shorter limits for Han, Hangul and some other scripts | Not case-sensitive | YouTube Help, handle guidelines |
| X | Letters, numbers and underscores | Up to 15; new usernames must be longer than 4, but older short handles exist | Not case-sensitive (x.com/JACK canonicalizes to x.com/jack) | X Help Center, username rules |
A normalization algorithm
- Step 1Trim and add a scheme
Strip whitespace and stray angle brackets. If the input has no scheme, prepend
https://soinstagram.com/natgeoparses. - Step 2Parse with a real URL parser
Use
new URL()in JavaScript orurllib.parse.urlsplitin Python, never a regex over the whole string. A parser handles ports, user info and encoded characters, and stopshttps://instagram.com@evil.com/natgeofrom passing as Instagram. - Step 3Normalize the host
Lowercase it and strip one leading
www.,m.ormobile.. Then look it up in an exact allow-list of hosts. Never useincludes("instagram.com"), which also matchesinstagram.com.evil.com. - Step 4Drop the query string and fragment
Tracking parameters such as
igsh,s,tandlangnever change which profile a URL points at. - Step 5Split and decode the path
Split on
/, drop empty segments (this removes trailing slashes) and percent-decode each one. Treat malformed encoding as invalid input. - Step 6Route by platform
Apply each platform's shape: one segment on Instagram and X, an
@segment on TikTok, and@handle,channel/UC...,user/nameorc/nameon YouTube. Reject reserved words. - Step 7Validate and lowercase the handle
Check the handle against the platform's character rules, then lowercase it. Do not lowercase YouTube channel IDs: they are case-sensitive.
- Step 8Build the canonical URL
Rebuild the URL from the parts on one fixed host, so every equivalent input produces exactly the same string.
- Step 9Handle short links separately
Detect TikTok short links, follow the redirect with an HTTP request, and run the final URL back through the parser. Keep this out of the pure function so parsing stays fast and testable.
The code in JavaScript and Python
Both functions return the same objects for the same input: { platform, handle, canonicalUrl } for handle URLs, { platform, channelId, canonicalUrl } for YouTube channel IDs, username or customName for the two legacy YouTube forms, and null for anything that is not a profile URL. Neither needs a dependency.
JavaScript (Node 18+)
const HOSTS = {
"instagram.com": "instagram",
"tiktok.com": "tiktok",
"youtube.com": "youtube",
"x.com": "x",
"twitter.com": "x",
};
const IG_RESERVED = new Set(["p", "reel", "reels", "tv", "stories", "explore", "accounts", "direct"]);
const X_RESERVED = new Set([
"home", "explore", "search", "i", "settings", "notifications", "messages",
"compose", "intent", "hashtag", "login", "signup", "logout", "tos", "privacy",
]);
const IG_HANDLE = /^[a-z0-9._]{1,30}$/;
const TIKTOK_HANDLE = /^[a-z0-9._]{2,24}$/;
const X_HANDLE = /^[a-z0-9_]{1,15}$/;
const YT_HANDLE = /^[\p{L}\p{M}\p{N}._\-·]{1,30}$/u;
const YT_CHANNEL_ID = /^UC[A-Za-z0-9_-]{22}$/;
const YT_LEGACY_NAME = /^[A-Za-z0-9._-]{1,100}$/;
export function parseProfileUrl(input) {
if (typeof input !== "string") return null;
let raw = input.trim().replace(/^<|>$/g, "");
if (!raw) return null;
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) raw = "https://" + raw;
let url;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
const host = url.hostname.toLowerCase().replace(/^(www|m|mobile)\./, "");
const platform = HOSTS[host];
if (!platform) return null;
let segments;
try {
segments = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
} catch {
return null; // malformed percent-encoding
}
const [first = "", second] = segments;
if (platform === "instagram") {
const handle = first.toLowerCase();
if (segments.length !== 1 || IG_RESERVED.has(handle) || !IG_HANDLE.test(handle)) return null;
return { platform, handle, canonicalUrl: `https://www.instagram.com/${handle}/` };
}
if (platform === "tiktok") {
if (segments.length !== 1 || !first.startsWith("@")) return null;
const handle = first.slice(1).toLowerCase();
if (!TIKTOK_HANDLE.test(handle) || handle.endsWith(".")) return null;
return { platform, handle, canonicalUrl: `https://www.tiktok.com/@${handle}` };
}
if (platform === "x") {
const handle = first.toLowerCase();
if (segments.length !== 1 || X_RESERVED.has(handle) || !X_HANDLE.test(handle)) return null;
return { platform, handle, canonicalUrl: `https://x.com/${handle}` };
}
// YouTube: /@handle[/tab], /channel/UC...[/tab], /user/name, /c/name
if (first.startsWith("@")) {
const handle = first.slice(1).toLowerCase();
if (!YT_HANDLE.test(handle) || /^[._\-·]|[._\-·]$/.test(handle)) return null;
return { platform, handle, canonicalUrl: `https://www.youtube.com/@${encodeURIComponent(handle)}` };
}
if (first === "channel" && second && YT_CHANNEL_ID.test(second)) {
return { platform, channelId: second, canonicalUrl: `https://www.youtube.com/channel/${second}` };
}
if ((first === "user" || first === "c") && second && segments.length === 2 && YT_LEGACY_NAME.test(second)) {
const key = first === "user" ? "username" : "customName";
return { platform, [key]: second, canonicalUrl: `https://www.youtube.com/${first}/${second}` };
}
return null; // videos, shorts, playlists, search and anything else
}
const SHORT_HOSTS = new Set(["vm.tiktok.com", "vt.tiktok.com"]);
export function isShortLink(input) {
try {
const url = new URL(/^https?:\/\//i.test(input) ? input : "https://" + input);
const host = url.hostname.toLowerCase();
return SHORT_HOSTS.has(host) || (host.replace(/^www\./, "") === "tiktok.com" && url.pathname.startsWith("/t/"));
} catch {
return false;
}
}
export async function resolveShortLink(input) {
const res = await fetch(/^https?:\/\//i.test(input) ? input : "https://" + input, {
redirect: "follow",
signal: AbortSignal.timeout(10_000),
});
await res.body?.cancel();
return res.url; // the final URL after every redirect
}resolveShortLink uses the built-in fetch, which follows redirects and exposes the final address as res.url. Call parseProfileUrl(await resolveShortLink(link)) when isShortLink(link) is true. A short link can also lead to a video rather than a profile, in which case the parser correctly returns null.
Python 3
import re
import unicodedata
import urllib.request
from urllib.parse import quote, unquote, urlsplit
HOSTS = {
"instagram.com": "instagram",
"tiktok.com": "tiktok",
"youtube.com": "youtube",
"x.com": "x",
"twitter.com": "x",
}
IG_RESERVED = {"p", "reel", "reels", "tv", "stories", "explore", "accounts", "direct"}
X_RESERVED = {
"home", "explore", "search", "i", "settings", "notifications", "messages",
"compose", "intent", "hashtag", "login", "signup", "logout", "tos", "privacy",
}
IG_HANDLE = re.compile(r"[a-z0-9._]{1,30}")
TIKTOK_HANDLE = re.compile(r"[a-z0-9._]{2,24}")
X_HANDLE = re.compile(r"[a-z0-9_]{1,15}")
YT_CHANNEL_ID = re.compile(r"UC[A-Za-z0-9_-]{22}")
YT_LEGACY_NAME = re.compile(r"[A-Za-z0-9._-]{1,100}")
YT_PUNCT = "._-·"
def _is_youtube_handle(handle):
if not 1 <= len(handle) <= 30 or handle[0] in YT_PUNCT or handle[-1] in YT_PUNCT:
return False
return all(
ch.isalnum() or ch in YT_PUNCT or unicodedata.category(ch).startswith("M")
for ch in handle
)
def parse_profile_url(value):
if not isinstance(value, str):
return None
raw = value.strip().strip("<>")
if not raw:
return None
if not re.match(r"^[a-z][a-z0-9+.-]*://", raw, re.I):
raw = "https://" + raw
try:
url = urlsplit(raw)
host = (url.hostname or "").lower()
except ValueError:
return None
if url.scheme.lower() not in ("http", "https"):
return None
host = re.sub(r"^(www|m|mobile)\.", "", host)
platform = HOSTS.get(host)
if not platform:
return None
try:
segments = [unquote(s, errors="strict") for s in url.path.split("/") if s]
except UnicodeDecodeError:
return None # malformed percent-encoding
first = segments[0] if segments else ""
second = segments[1] if len(segments) > 1 else None
if platform == "instagram":
handle = first.lower()
if len(segments) != 1 or handle in IG_RESERVED or not IG_HANDLE.fullmatch(handle):
return None
return {"platform": platform, "handle": handle,
"canonicalUrl": f"https://www.instagram.com/{handle}/"}
if platform == "tiktok":
if len(segments) != 1 or not first.startswith("@"):
return None
handle = first[1:].lower()
if not TIKTOK_HANDLE.fullmatch(handle) or handle.endswith("."):
return None
return {"platform": platform, "handle": handle,
"canonicalUrl": f"https://www.tiktok.com/@{handle}"}
if platform == "x":
handle = first.lower()
if len(segments) != 1 or handle in X_RESERVED or not X_HANDLE.fullmatch(handle):
return None
return {"platform": platform, "handle": handle, "canonicalUrl": f"https://x.com/{handle}"}
# YouTube: /@handle[/tab], /channel/UC...[/tab], /user/name, /c/name
if first.startswith("@"):
handle = first[1:].lower()
if not _is_youtube_handle(handle):
return None
return {"platform": platform, "handle": handle,
"canonicalUrl": f"https://www.youtube.com/@{quote(handle, safe='._-')}"}
if first == "channel" and second and YT_CHANNEL_ID.fullmatch(second):
return {"platform": platform, "channelId": second,
"canonicalUrl": f"https://www.youtube.com/channel/{second}"}
if first in ("user", "c") and second and len(segments) == 2 and YT_LEGACY_NAME.fullmatch(second):
key = "username" if first == "user" else "customName"
return {"platform": platform, key: second,
"canonicalUrl": f"https://www.youtube.com/{first}/{second}"}
return None # videos, shorts, playlists, search and anything else
SHORT_HOSTS = {"vm.tiktok.com", "vt.tiktok.com"}
def is_short_link(value):
url = urlsplit(value if re.match(r"^https?://", value, re.I) else "https://" + value)
host = (url.hostname or "").lower()
return host in SHORT_HOSTS or (
re.sub(r"^www\.", "", host) == "tiktok.com" and url.path.startswith("/t/")
)
def resolve_short_link(value, timeout=10):
if not re.match(r"^https?://", value, re.I):
value = "https://" + value
request = urllib.request.Request(value, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.geturl() # the final URL after every redirectPython's \w and str.isalnum() cover Unicode letters and digits but not the combining marks used by scripts such as Devanagari, which is why the YouTube check also accepts characters in the Unicode M categories.
Edge cases and tests
Every row below is part of the test suite that both implementations pass.
| Input | Output | Why |
|---|---|---|
instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA== | instagram, natgeo, https://www.instagram.com/natgeo/ | No scheme, mixed case, share parameter |
https://m.instagram.com/nat.geo_/#bio | instagram, nat.geo_ | Mobile host and fragment |
https://www.instagram.com/p/C9xYzAbCdEf/ | null | A post, not a profile |
https://www.instagram.com/stories/natgeo/1234567890/ | null | A story, even though the handle is in the path |
tiktok.com/@Khaby.Lame/ | tiktok, khaby.lame, https://www.tiktok.com/@khaby.lame | Trailing slash and case |
https://www.tiktok.com/@khaby.lame/video/7106594312292453675 | null | A video URL |
https://vm.tiktok.com/ZMabc123/ | null (follow the redirect first) | Short link with no handle in it |
https://m.youtube.com/@GoogleDevelopers/videos?app=desktop | youtube, googledevelopers | Mobile host, channel tab, query |
https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw/featured | youtube, channelId UC_x5XG1OV2P6uZZ5FSM9Ttw | Channel ID keeps its case |
https://www.youtube.com/c/GoogleDevelopers | youtube, customName GoogleDevelopers | Legacy custom URL; needs a lookup for the ID |
https://www.youtube.com/@%E3%83%86%E3%82%B9%E3%83%88 | youtube, handle テスト | Percent-encoded Unicode handle |
https://youtu.be/dQw4w9WgXcQ | null | A video, not a channel |
https://mobile.twitter.com/jack?s=21&t=abc | x, jack, https://x.com/jack | Old domain and share parameters |
https://x.com/i/flow/login | null | App route |
https://x.com/this_handle_is_too_long | null | More than 15 characters |
https://instagram.com.evil.com/natgeo | null | Lookalike host |
https://instagram.com@evil.com/natgeo | null | User info trick; the real host is evil.com |
@natgeo | null | A bare handle has no platform |
Keep the cases in your repository and run them in CI. When a platform adds a route or a share parameter, you add one row and the fix is obvious.
import assert from "node:assert/strict";
import { parseProfileUrl } from "./parse.mjs";
const cases = [
["instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA==", { platform: "instagram", handle: "natgeo", canonicalUrl: "https://www.instagram.com/natgeo/" }],
["https://www.instagram.com/reel/C9xYzAbCdEf/", null],
["https://www.instagram.com/explore/", null],
["tiktok.com/@Khaby.Lame/", { platform: "tiktok", handle: "khaby.lame", canonicalUrl: "https://www.tiktok.com/@khaby.lame" }],
["https://www.tiktok.com/@khaby.lame/video/7106594312292453675", null],
["https://vm.tiktok.com/ZMabc123/", null],
["https://m.youtube.com/@GoogleDevelopers/videos", { platform: "youtube", handle: "googledevelopers", canonicalUrl: "https://www.youtube.com/@googledevelopers" }],
["https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw/featured", { platform: "youtube", channelId: "UC_x5XG1OV2P6uZZ5FSM9Ttw", canonicalUrl: "https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw" }],
["https://www.youtube.com/user/GoogleDevelopers", { platform: "youtube", username: "GoogleDevelopers", canonicalUrl: "https://www.youtube.com/user/GoogleDevelopers" }],
["https://youtu.be/dQw4w9WgXcQ", null],
["https://twitter.com/Jack/", { platform: "x", handle: "jack", canonicalUrl: "https://x.com/jack" }],
["https://mobile.twitter.com/jack?s=21", { platform: "x", handle: "jack", canonicalUrl: "https://x.com/jack" }],
["https://x.com/jack/status/20", null],
["https://x.com/i/flow/login", null],
["https://instagram.com.evil.com/natgeo", null],
];
for (const [input, expected] of cases) {
assert.deepEqual(parseProfileUrl(input), expected, input);
}
console.log(`${cases.length} cases passed`);from parse_profile import parse_profile_url
CASES = [
("instagram.com/NatGeo?igsh=MWQ1ZGUxMzBkMA==", {"platform": "instagram", "handle": "natgeo", "canonicalUrl": "https://www.instagram.com/natgeo/"}),
("https://www.instagram.com/reel/C9xYzAbCdEf/", None),
("tiktok.com/@Khaby.Lame/", {"platform": "tiktok", "handle": "khaby.lame", "canonicalUrl": "https://www.tiktok.com/@khaby.lame"}),
("https://vm.tiktok.com/ZMabc123/", None),
("https://m.youtube.com/@GoogleDevelopers/videos", {"platform": "youtube", "handle": "googledevelopers", "canonicalUrl": "https://www.youtube.com/@googledevelopers"}),
("https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw/featured", {"platform": "youtube", "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", "canonicalUrl": "https://www.youtube.com/channel/UC_x5XG1OV2P6uZZ5FSM9Ttw"}),
("https://youtu.be/dQw4w9WgXcQ", None),
("https://twitter.com/Jack/", {"platform": "x", "handle": "jack", "canonicalUrl": "https://x.com/jack"}),
("https://x.com/i/flow/login", None),
("https://instagram.com.evil.com/natgeo", None),
]
for value, expected in CASES:
assert parse_profile_url(value) == expected, value
print(f"{len(CASES)} cases passed")Store the raw input and the canonical form
Normalization is lossy by design, and your rules will improve over time. Store both values:
- `raw_url`: exactly what the user or the import gave you. It lets you re-run a better parser later, and it is what you show back to the user when they edit their profile.
- `platform` and `handle` or `channel_id`: the parts, with a unique index on
(platform, handle). This is your dedupe and join key. - `canonical_url`: the rebuilt URL, for display and for calling the platform.
- `parsed_at` and a parser version: so you can find rows normalized by an older version of your rules.
Handles are not permanent. Creators rename accounts on every platform, and a handle you stored last year may now belong to someone else. Where an official API gives you a stable ID, such as a YouTube channel ID, store that as the long-term key and treat the handle as a display value you refresh. The companion guide on getting a YouTube channel ID from a handle shows how.
From one profile to the others
Once a profile URL is normalized, the next question is usually which other accounts belong to the same creator. 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
Should I lowercase handles before storing them?
Yes for the lookup key. YouTube and X say handles are not case-sensitive, and TikTok resolves @TikTok to the same account as @tiktok. Instagram does not document a case rule, but usernames are displayed in lowercase, so lowercasing is the safe default. Keep the original casing in the raw URL if you want to display it. Do not lowercase YouTube channel IDs, which are case-sensitive.
Can I extract the handle from a post or video URL?
For TikTok videos (/@handle/video/id) and X posts (/handle/status/id) the handle in the path is the author, so you can extract it if that is what you want. Instagram post and reel URLs (/p/ and /reel/) do not include the handle. The functions in this guide return null for all of them because they are not profile links.
How do I handle vm.tiktok.com links?
Short links contain only a code. Make an HTTP request, follow the redirects, and parse the final URL. Do it in a separate step with a timeout and a cache, because it is a network call and the code can expire.
Is twitter.com still a valid profile URL?
Yes. twitter.com profile URLs redirect to x.com, so treat both domains as the X platform and store the x.com form as canonical.
Why not use one big regular expression?
A single regex over the full string is hard to read and easy to fool with lookalike hosts, user info or encoded characters. Parse the URL with the standard library first, then use small regexes on the handle only.
How do I turn a YouTube /c/ or /user/ URL into a channel ID?
/user/<name> URLs map to the YouTube Data API's forUsername filter. There is no API filter for /c/ custom names. See the guide on getting a YouTube channel ID from a handle for both cases.