How to Turn Short Links and Share Links Into the Real Post
Nobody pastes a clean link. People tap Share, then Copy link, and paste whatever the app hands them: vt.tiktok.com/ZSh2X7n2y/, youtu.be/jNQXAC9IVRw?si=..., facebook.com/share/r/1AdJDwQJRD/. None of those tells you whose post it is. The TikTok one doesn't even contain the video's ID.
Before you can fetch a post, dedupe it or store it, you have to get from the pasted link to the post. You can send the link to the API as it is, follow its redirects yourself, or pull the ID out of the URL. The right choice depends on the platform. The wrong one costs you credits, or an afternoon working out why half your links "worked" and came back empty.
We tested all three approaches on 1 November 2026. Every format below went to our API exactly as pasted. We followed each one's redirects with a plain HTTP client, meaning Node's fetch and Python's requests with no browser headers. And to see how real share links hold up over time, we followed 283 TikTok and Facebook short links that editors had cited on Wikipedia. Wikipedia publishes a list of every external link in its articles, and editors paste share links straight from their phones, so it's a good pile of real links that have been sitting around for months or years.
Each platform has one endpoint for a single post, and all six take the link as url: tiktok/video-info, youtube/video, instagram/post-info, twitter/tweet, threads/post and facebook/post.
What works, at a glance
| Link people paste | Our API, as pasted | Where a plain client ends up | Strip |
|---|---|---|---|
vm.tiktok.com/CODE/, vt.tiktok.com/CODE/, tiktok.com/t/CODE/ | Works | tiktok.com/@USER/video/ID, often with no username, or the home page if the link is dead | everything after ? |
tiktok.com/@USER/photo/ID | Works | No redirect | everything after ? |
youtu.be/ID, m.youtube.com/watch?v=ID | Works | youtube.com/watch?v=ID with feature or app added | rebuild from the ID |
youtube.com/shorts/ID, youtube.com/live/ID | Works | The same page, after a stop at consent.youtube.com on our machine | rebuild from the ID |
instagram.com/reels/CODE/, instagram.com/USER/reel/CODE/ | Works | No redirect | igsh, igshid, utm_source |
instagram.com/share/CODE | Not tested | /reel/CODE/ or /p/CODE/ in one hop | igsh |
x.com or twitter.com status links with s= and t= | Works | x.com/USER/status/ID | s, t |
threads.net or threads.com post links with xmt= | Works | threads.com/@USER/post/CODE | xmt, hl, the text slug |
fb.watch/CODE/ | Works | facebook.com/PAGE/videos/ID/ | share_url, ref, extid |
facebook.com/share/v/, /share/p/ or /share/r/ with a 10- or 11-character code | Works | The post 82 times in 93, a login page otherwise | mibextid, rdid, share_url |
| The same with a 16-character code | 404 | A login page, 87 times in 87 |
"Works" means the API returned the right post. In practice that gives you a simple rule. Resolve TikTok short links and Instagram share links yourself, because one free request turns them into a post ID. Parse YouTube, Instagram, X and Threads links, because the ID is already in them. Send Facebook share links to the API as they are, because a plain client often can't follow them and the response tells you where they lead.
TikTok: vm, vt and /t/ short links
TikTok's share sheet gives out three kinds of short link: vm.tiktok.com/CODE/, vt.tiktok.com/CODE/ and tiktok.com/t/CODE/. Our tiktok/video-info endpoint accepted all three as pasted. A /t/ link cited on Wikipedia, tiktok.com/t/ZP8vpwFEM/, came back as the Los Angeles Chargers' video 7424320489146944814 with 12,132,048 plays. A vt link cited in the article on Ava Max's album Diamonds & Dancefloors came back as her video 7180046031445429547.
So you don't have to resolve them. We still do, for two reasons.
You need the ID to dedupe. The code in a short link stands for a share, not a video. The redirect behind it carries details of the share, such as a share token or a timestamp, so the same video can reach you under any number of codes. One HEAD request turns a short link into something like tiktok.com/@chargers/video/7424320489146944814?_r=1&_t=ZP-..., and the number in that path is your key. In our test it was quicker too. The vt link took 12 seconds to come back as pasted, and the resolved URL for the same video took under 5. That's one run each.
Dead links look alive. Of the 103 TikTok short links we took from Wikipedia, 70 redirected to https://www.tiktok.com/?_r=1, the home page, with a 200 status. A resolver that only checks for success will call that a win. By type, 37 of 51 vm links, 7 of 24 vt links and 26 of 28 /t/ links ended up there. We sent one of the dead /t/ links to our API and got a 404, "Video not found." From outside you can't tell whether the video was deleted or the link expired, and it doesn't matter. If the final path doesn't contain /video/ or /photo/, stop. Two other links led to profiles rather than posts, because people share profiles too.
Most resolved links have no username
This one surprised us. Of the 31 short links that still led to a post, 23 resolved to a URL with an empty username, like tiktok.com/@/video/7180046031445429547. Ava Max's link was one of them. Our API accepted that form without complaint, and the response named the author, avamax. So don't parse the creator out of a resolved TikTok URL. Read aweme_detail.author.unique_id from the response instead. If you want the creator's permanent ID as well, finding user IDs on Instagram, TikTok and X shows where it sits.
Photo posts
Photo posts, TikTok's slideshows, live at /@USER/photo/ID rather than /video/. Keep the word photo. video-info accepted MrBeast's tiktok.com/@mrbeast/photo/7680982718900981022 and returned it with aweme_type 150 and 19,283,090 plays.
What to strip
Everything after the ?, because the video ID is in the path. Of the 131 full TikTok post links cited on Wikipedia, 76 carried a query string, most often is_from_webapp, is_copy_url and lang. Resolved short links add _r and _t, and older ones a long tail of _d, u_code, share_item_id, share_app_id, timestamp and utm_source=copy. The three oldest, shared in 2020 and 2021, also carried user_id and sec_user_id, which are account IDs. None of it identifies the video. _d came back different all three times we resolved the same link, so two resolutions won't even match each other until you drop it.
YouTube: youtu.be, Shorts, m.youtube.com and /live/
All four forms went through youtube/video as pasted. youtu.be/jNQXAC9IVRw?si=... and m.youtube.com/watch?v=jNQXAC9IVRw both returned "Me at the zoo", at 447,469,553 views. youtube.com/live/bKTbwiFpYuE?feature=share&t=10063, copied from a Wikipedia citation, returned Harvard's commencement stream for the classes of 2020 and 2021.
Don't resolve YouTube links. The 11-character video ID is in all of them, and following the redirects only adds parameters. youtu.be answered our plain client with a 303 to watch?v=ID&feature=youtu.be, keeping the si. m.youtube.com answered with a 302 to watch?app=desktop&v=ID. From our test machine, /shorts/ and /live/ links went through consent.youtube.com first and came back with cbrd=1&ucbcb=1 attached. We were passed straight back, but that can depend on where you are. If YouTube served a consent form instead, a plain client would stop on it. Channel links can run into the same consent screen, which we covered in finding a YouTube channel ID from any URL.
Rebuild every video link as https://www.youtube.com/watch?v=ID. We found a practical reason to do that for Shorts. Sent as youtube.com/shorts/5mU6SRS2Bxo, MrBeast's "World's Largest Tennis Match" came back with its title and its 36-second duration, but with empty view, like and comment counts. The same Short sent as watch?v=5mU6SRS2Bxo came back with 23,659,726 views, 437,454 likes and 22,000 comments. That's one Short on one day. The rewrite costs nothing, so we'd do it anyway.
Both the Short and the old live stream came back with type set to "video". If you need to know what kind of link someone pasted, keep the original next to the clean one.
Rebuilding from the ID drops si, feature, pp, app and playlist parameters such as list and index. It drops t as well. That one is a timestamp, not tracking, so give it its own column if you use it.
Instagram: /reel/, /reels/, /p/ and /USER/reel/
/p/CODE/ and /reel/CODE/ are the same post with the same shortcode, as we showed in converting Instagram shortcodes to media IDs. The other forms people paste behave the same way. NASA's reel came back identical from instagram.com/reels/Dd1gQBsCWE8/, instagram.com/nasa/reel/Dd1gQBsCWE8/ and instagram.com/reel/Dd1gQBsCWE8/?igsh=...: shortcode Dd1gQBsCWE8, media ID 3996242083173720380, 5,876,362 plays.
None of those redirected for a plain client. Instagram served its standard page, and the canonical link on the /reels/ page pointed at /reel/Dd1gQBsCWE8/. The shortcode is the post. The path in front of it only tells you which screen the person copied from.
One trap: instagram.com/reels/audio/ID/ is the page for a sound, not a post. Three of the 60 /reels/ links we pulled from Wikipedia were audio pages, and a parser that takes whatever follows /reels/ will treat audio as a shortcode.
Instagram share links
The app also hands out links like instagram.com/share/BAKKjyVovY/ and instagram.com/share/p/BAKO-f_Mje, with no shortcode in sight. These are worth resolving, and it's easy. All four we tried answered a plain client with a single 302 to /reel/CODE/?igsh=... or /p/CODE/?igsh=.... The code below resolves them and sends the clean URL, which worked when we ran it. We didn't test sending a /share/ link to the API as is.
On any Instagram post link, strip igsh, igshid, utm_source, utm_medium and img_index.
X and Threads
X share links often end in ?s=20 or ?s=46, and older ones add a t= token. Our twitter/tweet endpoint ignored both. x.com/NASA/status/1383221042504638464?s=20 and twitter.com/NASA/status/1383221042504638464?s=46&t=... returned the same post, NASA's April 2021 update that the Mars helicopter had finished a full-speed spin test, with 10,570 likes.
With a plain client, twitter.com 301s to x.com. The more useful discovery is that the handle in a status URL doesn't matter. We put a real status ID behind the wrong handle, and x.com answered with a 307 to the author's actual URL. The status ID is the post. Key on it and treat the handle as display text.
Threads behaves much the same. threads.net 301s to threads.com, and our threads/post endpoint accepted both, with or without ?xmt=.... The Grand Canyon Conservancy's post DNWs7jrh2hn, "Some grand news!!! Brighty has survived!", came back from both domains with 823 likes. Some Threads links also carry a slug of the post text after the code, like /post/DN3G6Zk6Luy/in-1910-albert-todd-.... Cut the URL after the code, and strip xmt, hl and igshid.
Facebook: fb.watch and share links
Our guide to Facebook post URL formats covers the canonical links, such as /reel/, /posts/, permalink.php and mobile links, and why one post can have two IDs. It left out the links the share button makes: fb.watch/CODE/, and facebook.com/share/v/, /share/p/ and /share/r/.
Send those to facebook/post as they are. Our API accepted all four kinds:
| Pasted | What came back | post_id |
|---|---|---|
fb.watch/9WktuN9j-z/ | An MIT CSAIL video, 3,138,632 views | 175310064147000 |
facebook.com/share/v/1B8SVLSD63/ | A video from the Bob Ross page | 775659786578418 |
facebook.com/share/p/15WARbURCZ/ | A post from Thai broadcaster Ch7HD | 10159326701632973 |
facebook.com/share/r/1AdJDwQJRD/ | A Dropkick Murphys reel | 1241084977373018 |
The response's url field tells you where the link leads. For the share/r link it was facebook.com/reel/711141428070282, and for the share/p link a /Ch7HD/posts/pfbid0... address. Store that alongside post_id. Look at the Dropkick Murphys row, though. The number in its url and its post_id are different. That's the two-ID problem from the formats guide, and the same fix applies: keep both, and treat a match on either as the same post.
Following them yourself
For fb.watch links it works. All eight we took from public GitHub issues redirected a plain client to a /PAGE/videos/ID/ page, seven of them by way of /watch/?v=ID.
Share links split by age, and you can tell their age from the code's length. Of the 93 we found on Wikipedia with short codes (10 or 11 characters), 82 redirected to the post: all 33 share/r links, 29 of 30 share/v and 20 of 30 share/p. The other 11 stayed on the share URL and returned a page titled "Facebook" with a login form. None of the 87 with the older 16-character codes redirected. All 87 returned that login page.
Our API couldn't read those either. We sent one, share/v/dKKJaddXhPkJy24T/?mibextid=WC7FNe from the Wikipedia article on an Australian animation studio, and got a 404 saying the post doesn't exist or needs a login. When that happens, ask whoever sent the link to open it in a browser and copy the address it lands on.
Strip mibextid, rdid, share_url, fs, s, vh, extid and ref from whatever you store. rdid came back different all three times we resolved the same link. Keep story_fbid, id, fbid and v, the parameters that identify the post.
The normalizer
The code below takes whatever someone pasted and:
- pulls out the first URL, so pasted text like
Check this out! https://vm.tiktok.com/...works - rebuilds TikTok, YouTube, Instagram, X and Threads post links in one clean form, with no network call
- resolves TikTok short links and Instagram share links with HEAD requests, stops at the first post URL, and flags links that land on the TikTok home page as dead
- sends Facebook share links and
fb.watchlinks as they are, then reads the post's URL and ID from the response - picks the endpoint and returns a
key, liketiktok:7424320489146944814, to dedupe on
Set DRY_RUN=1 to see what it would send without spending credits. On 1 November 2026 we ran both versions against the same 24 pasted links in dry-run mode, and they produced the same URL, endpoint and key for every one. Then we let each make live calls.
Save this as normalize-link.mjs and run it with Node 18 or later:
// normalize-link.mjs: turn a pasted short link or share link into a request our API accepts.
// Node 18+. Usage: SOCIAVAULT_API_KEY=... node normalize-link.mjs "<link>" ["<link>" ...]
// Set DRY_RUN=1 to see what would be sent without calling the API.
const API = "https://api.sociavault.com/v1/scrape";
const KEY = process.env.SOCIAVAULT_API_KEY;
const DRY_RUN = process.env.DRY_RUN === "1";
const ENDPOINT = {
tiktok: "tiktok/video-info",
youtube: "youtube/video",
instagram: "instagram/post-info",
twitter: "twitter/tweet",
threads: "threads/post",
facebook: "facebook/post",
};
const FB_KEEP = new Set(["story_fbid", "id", "fbid", "v"]); // the only Facebook params that identify a post
const IG_TYPES = ["p", "reel", "reels", "tv"];
// Pull the first URL out of whatever was pasted ("Check this out! https://vm.tiktok.com/...").
function firstUrl(text) {
const found = String(text).match(/https?:\/\/[^\s<>"]+/i);
const raw = (found ? found[0] : String(text).trim()).replace(
/[).,!?'"]+$/,
"",
);
return new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
}
// What is this URL? Returns { platform, url, key, short } or null. No network calls.
function classify(u) {
const host = u.hostname.toLowerCase().replace(/^(www|m|mobile)\./, "");
const parts = u.pathname.split("/").filter(Boolean);
const post = (platform, url, key) => ({ platform, url, key, short: false });
const short = (platform, url) => ({ platform, url, key: null, short: true });
if (
host === "vm.tiktok.com" ||
host === "vt.tiktok.com" ||
(host === "tiktok.com" && parts[0] === "t")
) {
return short("tiktok", `https://${u.hostname}${u.pathname}`);
}
if (host === "tiktok.com") {
const m = u.pathname.match(/^\/@([^/]*)\/(video|photo)\/(\d+)/); // the username can be empty: /@/video/ID
return m
? post(
"tiktok",
`https://www.tiktok.com/@${m[1]}/${m[2]}/${m[3]}`,
`tiktok:${m[3]}`,
)
: null;
}
if (host === "youtu.be" || host === "youtube.com") {
const id =
host === "youtu.be"
? parts[0]
: parts[0] === "watch"
? u.searchParams.get("v")
: parts[0] === "shorts" || parts[0] === "live"
? parts[1]
: null;
const ok = id && /^[A-Za-z0-9_-]{11}$/.test(id);
return ok
? post(
"youtube",
`https://www.youtube.com/watch?v=${id}`,
`youtube:${id}`,
)
: null;
}
if (host === "instagram.com") {
if (parts[0] === "share")
return short("instagram", `https://www.instagram.com${u.pathname}`);
const i = IG_TYPES.includes(parts[0])
? 0
: IG_TYPES.includes(parts[1])
? 1
: -1; // /p/CODE or /user/p/CODE
const code = i >= 0 ? parts[i + 1] : null;
if (!code || code === "audio" || !/^[A-Za-z0-9_-]+$/.test(code))
return null; // /reels/audio/ID is a sound page
const type = parts[i] === "reels" ? "reel" : parts[i];
return post(
"instagram",
`https://www.instagram.com/${type}/${code}/`,
`instagram:${code}`,
);
}
if (host === "x.com" || host === "twitter.com") {
const m = u.pathname.match(/^\/(\w{1,15})\/status\/(\d+)/);
return m
? post("twitter", `https://x.com/${m[1]}/status/${m[2]}`, `x:${m[2]}`)
: null;
}
if (host === "threads.net" || host === "threads.com") {
const m = u.pathname.match(/^\/@([^/]+)\/post\/([A-Za-z0-9_-]+)/); // drops the text slug some links carry
return m
? post(
"threads",
`https://www.threads.com/@${m[1]}/post/${m[2]}`,
`threads:${m[2]}`,
)
: null;
}
// Facebook share links and fb.watch links go to the API as they are. The response names the post.
if (host === "fb.watch" && parts[0])
return post("facebook", `https://fb.watch/${parts[0]}/`, null);
if (host === "facebook.com" || host === "fb.com") {
if (parts[0] === "share")
return post("facebook", `https://www.facebook.com${u.pathname}`, null);
if (parts[0] === "groups") return null; // group posts belong to the group endpoints
if (
/\/(posts|videos|reel|photos?|watch)\b|^\/(permalink|story|photo)\.php/.test(
u.pathname,
)
) {
const clean = new URL(`https://www.facebook.com${u.pathname}`);
for (const [k, v] of u.searchParams)
if (FB_KEEP.has(k)) clean.searchParams.set(k, v);
return post("facebook", clean.toString(), null);
}
}
return null;
}
// Follow redirects one hop at a time with HEAD requests, and stop at the first post URL.
async function resolve(start, maxHops = 5) {
let url = start;
for (let hop = 0; hop < maxHops; hop++) {
const res = await fetch(url, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(15000),
});
const next = res.headers.get("location");
if (res.status < 300 || res.status >= 400 || !next) return url;
url = new URL(next, url).toString();
if (classify(new URL(url))?.short === false) return url;
}
return url;
}
export async function normalizeLink(text) {
let c;
try {
c = classify(firstUrl(text));
} catch {
return { input: text, error: "not a URL" };
}
if (!c) return { input: text, error: "not a post link we recognise" };
if (!c.short) return { input: text, ...c, endpoint: ENDPOINT[c.platform] };
// TikTok short links and Instagram share links: a free hop or two reveals the post ID.
let landed;
try {
landed = await resolve(c.url);
} catch (e) {
// our API accepts TikTok short links as they are, so send the short link instead
if (c.platform === "tiktok")
return {
input: text,
...c,
endpoint: ENDPOINT.tiktok,
note: "not resolved",
};
return { input: text, error: `could not resolve: ${e.message}` };
}
const r = classify(new URL(landed));
if (!r || r.short) {
const dead = new URL(landed).pathname === "/"; // dead TikTok short links land on the homepage
return {
input: text,
platform: c.platform,
landed,
error: dead ? "dead link" : "did not lead to a post",
};
}
return {
input: text,
...r,
endpoint: ENDPOINT[r.platform],
resolvedFrom: c.url,
};
}
export async function fetchPost(text) {
const link = await normalizeLink(text);
if (link.error || DRY_RUN) return link;
const res = await fetch(
`${API}/${link.endpoint}?${new URLSearchParams({ url: link.url })}`,
{
headers: { "x-api-key": KEY },
},
);
const body = await res.json().catch(() => ({}));
if (!res.ok || body.error)
return {
...link,
status: res.status,
error: body.error ?? `HTTP ${res.status}`,
};
const data = body.data ?? {};
if (link.platform === "facebook") {
link.key = data.post_id ? `facebook:${data.post_id}` : null; // also match on the IDs in data.url
link.resolvedUrl = data.url ?? null;
}
return { ...link, creditsUsed: body.credits_used ?? null, data };
}
for (const text of process.argv.slice(2)) {
const { data, ...summary } = await fetchPost(text);
console.log(summary);
await new Promise((r) => setTimeout(r, 1000));
}
The Python version follows the same rules:
# normalize_link.py: turn a pasted short link or share link into a request our API accepts.
# Usage: SOCIAVAULT_API_KEY=... python normalize_link.py "<link>" ["<link>" ...]
# Set DRY_RUN=1 to see what would be sent without calling the API.
import os, re, sys, time
from urllib.parse import urlsplit, urljoin, parse_qsl, urlencode
import requests
API = "https://api.sociavault.com/v1/scrape"
KEY = os.environ.get("SOCIAVAULT_API_KEY")
DRY_RUN = os.environ.get("DRY_RUN") == "1"
ENDPOINT = {
"tiktok": "tiktok/video-info",
"youtube": "youtube/video",
"instagram": "instagram/post-info",
"twitter": "twitter/tweet",
"threads": "threads/post",
"facebook": "facebook/post",
}
FB_KEEP = {"story_fbid", "id", "fbid", "v"} # the only Facebook params that identify a post
IG_TYPES = ["p", "reel", "reels", "tv"]
def first_url(text):
found = re.search(r"https?://[^\s<>\"]+", str(text), re.I)
raw = re.sub(r"[).,!?'\"]+$", "", found.group(0) if found else str(text).strip())
parts = urlsplit(raw if re.match(r"^https?://", raw, re.I) else "https://" + raw)
if not parts.hostname or " " in raw:
raise ValueError("not a URL")
return parts
def classify(u):
"""What is this URL? Returns a dict with platform, url, key and short, or None. No network calls."""
host = re.sub(r"^(www|m|mobile)\.", "", (u.hostname or "").lower())
parts = [p for p in u.path.split("/") if p]
query = dict(parse_qsl(u.query))
def post(platform, url, key):
return {"platform": platform, "url": url, "key": key, "short": False}
def short(platform, url):
return {"platform": platform, "url": url, "key": None, "short": True}
if host in ("vm.tiktok.com", "vt.tiktok.com") or (host == "tiktok.com" and parts[:1] == ["t"]):
return short("tiktok", f"https://{u.hostname}{u.path}")
if host == "tiktok.com":
m = re.match(r"^/@([^/]*)/(video|photo)/(\d+)", u.path) # the username can be empty: /@/video/ID
return post("tiktok", f"https://www.tiktok.com/@{m[1]}/{m[2]}/{m[3]}", f"tiktok:{m[3]}") if m else None
if host in ("youtu.be", "youtube.com"):
if host == "youtu.be":
vid = parts[0] if parts else None
elif parts[:1] == ["watch"]:
vid = query.get("v")
elif parts[:1] in (["shorts"], ["live"]) and len(parts) > 1:
vid = parts[1]
else:
vid = None
if vid and re.fullmatch(r"[A-Za-z0-9_-]{11}", vid):
return post("youtube", f"https://www.youtube.com/watch?v={vid}", f"youtube:{vid}")
return None
if host == "instagram.com":
if parts[:1] == ["share"]:
return short("instagram", f"https://www.instagram.com{u.path}")
i = 0 if parts[:1] and parts[0] in IG_TYPES else 1 if len(parts) > 1 and parts[1] in IG_TYPES else -1
code = parts[i + 1] if i >= 0 and len(parts) > i + 1 else None
if not code or code == "audio" or not re.fullmatch(r"[A-Za-z0-9_-]+", code): # /reels/audio/ID is a sound page
return None
kind = "reel" if parts[i] == "reels" else parts[i]
return post("instagram", f"https://www.instagram.com/{kind}/{code}/", f"instagram:{code}")
if host in ("x.com", "twitter.com"):
m = re.match(r"^/(\w{1,15})/status/(\d+)", u.path)
return post("twitter", f"https://x.com/{m[1]}/status/{m[2]}", f"x:{m[2]}") if m else None
if host in ("threads.net", "threads.com"):
m = re.match(r"^/@([^/]+)/post/([A-Za-z0-9_-]+)", u.path) # drops the text slug some links carry
return post("threads", f"https://www.threads.com/@{m[1]}/post/{m[2]}", f"threads:{m[2]}") if m else None
# Facebook share links and fb.watch links go to the API as they are. The response names the post.
if host == "fb.watch" and parts:
return post("facebook", f"https://fb.watch/{parts[0]}/", None)
if host in ("facebook.com", "fb.com"):
if parts[:1] == ["share"]:
return post("facebook", f"https://www.facebook.com{u.path}", None)
if parts[:1] == ["groups"]:
return None # group posts belong to the group endpoints
if re.search(r"/(posts|videos|reel|photos?|watch)\b|^/(permalink|story|photo)\.php", u.path):
keep = urlencode([(k, v) for k, v in parse_qsl(u.query) if k in FB_KEEP])
return post("facebook", f"https://www.facebook.com{u.path}" + (f"?{keep}" if keep else ""), None)
return None
def resolve(start, max_hops=5):
"""Follow redirects one hop at a time with HEAD requests, and stop at the first post URL."""
url = start
for _ in range(max_hops):
res = requests.head(url, allow_redirects=False, timeout=15)
nxt = res.headers.get("location")
if not (300 <= res.status_code < 400) or not nxt:
return url
url = urljoin(url, nxt)
c = classify(urlsplit(url))
if c and not c["short"]:
return url
return url
def normalize_link(text):
try:
c = classify(first_url(text))
except ValueError:
return {"input": text, "error": "not a URL"}
if not c:
return {"input": text, "error": "not a post link we recognise"}
if not c["short"]:
return {"input": text, **c, "endpoint": ENDPOINT[c["platform"]]}
# TikTok short links and Instagram share links: a free hop or two reveals the post ID.
try:
landed = resolve(c["url"])
except requests.RequestException as e:
if c["platform"] == "tiktok": # our API accepts TikTok short links as they are
return {"input": text, **c, "endpoint": ENDPOINT["tiktok"], "note": "not resolved"}
return {"input": text, "error": f"could not resolve: {e}"}
r = classify(urlsplit(landed))
if not r or r["short"]:
dead = urlsplit(landed).path in ("", "/") # dead TikTok short links land on the homepage
return {"input": text, "platform": c["platform"], "landed": landed,
"error": "dead link" if dead else "did not lead to a post"}
return {"input": text, **r, "endpoint": ENDPOINT[r["platform"]], "resolved_from": c["url"]}
def fetch_post(text):
link = normalize_link(text)
if link.get("error") or DRY_RUN:
return link
res = requests.get(f"{API}/{link['endpoint']}", params={"url": link["url"]},
headers={"x-api-key": KEY}, timeout=120)
try:
body = res.json()
except ValueError:
body = {}
if not res.ok or body.get("error"):
return {**link, "status": res.status_code, "error": body.get("error") or f"HTTP {res.status_code}"}
data = body.get("data") or {}
if link["platform"] == "facebook":
link["key"] = f"facebook:{data['post_id']}" if data.get("post_id") else None # also match on IDs in data["url"]
link["resolved_url"] = data.get("url")
return {**link, "credits_used": body.get("credits_used"), "data": data}
if __name__ == "__main__":
for text in sys.argv[1:]:
result = fetch_post(text)
result.pop("data", None)
print(result)
time.sleep(1)
In our live runs, the JavaScript version turned instagram.com/share/p/BAKO-f_Mje into instagram.com/p/DDwv5NgM0XU/, a post from a film's official account, and fetched it. The Python version sent BBC Archive's share/v/1BZSZzFAMW/ link as is and got back post 1934177130407215, with the real /BBCArchive/videos/... address in resolved_url. It also resolved Ava Max's vt link to the empty-username URL before fetching it. The dead TikTok link came back as dead link without touching the API.
When resolving goes wrong
- The TikTok home page. The link is dead. Don't call the API and don't retry.
- A TikTok profile. Two of our short links led to a profile instead of a post. Look it up with
tiktok/profileand the handle. - A Facebook login page. Send the link to the API anyway. If that fails too, it needs a person with a browser.
- A YouTube consent page. You shouldn't be resolving YouTube links at all. Take the ID from the URL.
- A timeout. For TikTok, send the short link as is, since our API accepts it. The code falls back to that.
- A 200 that isn't a success. Check the body for an
errorfield rather than trusting the status code. We went through that one in why an API says an account doesn't exist.
What this can't do
- It's a snapshot. We tested on 1 November 2026 from one machine. Platforms change their redirects without notice, and what a plain client sees can differ by country and network. Re-run the dry run on a sample of your own links every so often.
- Our sample leans old and notable. Wikipedia links mostly point at public figures and organizations, and many were pasted years ago. Links your users pasted last week may die far less often.
- One or two requests per format. The Shorts finding rests on a single Short, and each share-link type went to the API once.
- Some formats we didn't test. Instagram
/share/links sent as is,x.com/i/status/links andt.colinks are all outside what we checked. - Private posts stay private. No link format changes what's publicly visible, and a Facebook share link that only shows a login page is a dead end for us too.
- Strip before you share data. Tracking parameters describe the share itself, such as when it happened and which app made it, and the oldest TikTok links carried account IDs. Clean links before they go into an export someone else will read.
Frequently Asked Questions
How do I expand a vm.tiktok.com link?
Send a HEAD request without following redirects and read the Location header. It points at tiktok.com/@USER/video/ID, sometimes with an empty username. If it points at the TikTok home page, the link is dead. Our API also accepts the short link as it is.
Do I need to resolve short links before calling the API?
Not for TikTok, YouTube, X, Threads or Facebook links sent to our API. We resolve TikTok short links anyway, to get the video ID for deduping, and Instagram share links, because they redirect in one hop and we haven't tested them as is.
Why does my TikTok short link open the TikTok home page?
The link no longer leads to a video. 70 of the 103 TikTok short links cited on Wikipedia did this when we checked on 1 November 2026, and our API returned "Video not found" for the one we sent it.
How do I find the post behind a Facebook share link?
Send it to the post endpoint as it is and read url and post_id from the response. Following the redirect yourself worked for 82 of 93 links with short codes, and for none of the 87 with older 16-character codes.
Is a YouTube Shorts link the same video as a watch link?
Yes, it's the same 11-character ID. We'd still send Shorts as watch?v=ID. In our test the watch form came back with view, like and comment counts that the /shorts/ form didn't.
Which tracking parameters can I safely remove?
On TikTok, Instagram, X and Threads, the whole query string. On YouTube, everything except v, or just rebuild the URL from the ID. On Facebook, keep story_fbid, id, fbid and v and drop the rest.
Got a column of pasted links that only half work? Sign up for 50 free credits, run the script with DRY_RUN=1 to see what it would send, then let it fetch.
Found this helpful?
Share it with others who might benefit
Ready to Try SociaVault?
Start extracting social media data with our powerful API. No credit card required.