Facebook video downloader API for reels, Watch posts and share links
The Facebook video downloader API resolves a public video or reel to a direct
fbcdn.net URL with one GET request. Every path shape on the platform's own hosts —
/watch/?v=, /reel/, /share/v/, old
permalink.php URLs — goes into the same parameter. The one link you must expand
yourself is an fb.watch short link. Facebook is also the platform that rearranges its
markup most often, and keeping up with that is the part you are handing over.
- Endpoint
GET /fetch- Credits
- 1.5 per video or reel
- Auth
X-Api-Keyheader- Accepts
- facebook.com & fb.com hosts · /watch/ · /reel/ · /share/v/ · permalink.php
coverage
the many shapes of a Facebook link
Facebook has accumulated more URL formats than every other platform on this site combined — two decades of redesigns and a share sheet that picks a different one depending on where you tapped. Path shape does not matter: anything on the right host goes into the same parameter exactly as the user pasted it.
facebook.com/watch/?v=1234567890— the Watch player, with or without the trailing slash.facebook.com/reel/1234567890— Reels, Facebook's answer to short vertical video.facebook.com/share/v/AbCdEf1234/— the newer share format now used across the apps.facebook.com/pagename/videos/1234567890/— a video posted to a page or profile.facebook.com/permalink.php?story_fbid=…&id=…— the legacy permalink, still handed out by notification emails.
What does matter is the host. Routing happens on it before anything looks at the path, and the
match is exact-or-subdomain against facebook.com and fb.com. So
m.facebook.com, web.facebook.com and the locale prefixes all resolve —
they end in .facebook.com — and so does a bare fb.com link.
fb.watch does not. It is a separate host, it matches neither
entry, and a request carrying one comes straight back as 400 with
{"ok": false, "detail": "Invalid URL"} — the redirect is never followed for you.
Resolve it on your side first: one HEAD request with redirects enabled returns the
canonical facebook.com URL, and that is the string you send. It costs you a
round-trip and no credits, and it is the single most common reason a Facebook integration fails
on links copied from a phone.
One more trap worth naming. Two of those formats carry their own query string. Drop
permalink.php?story_fbid=123&id=456 into a request unencoded and
&id= becomes a parameter of your call rather than part of Facebook's link,
so the API receives a truncated URL. Percent-encode the value; every HTTP client has a helper
for it.
usage
one GET, JSON back
No SDK, no cookie jar, no headless Chrome sitting in your container image. A query parameter and a header.
https://api.fastsaver.io/v1/fetch
1.5 credits
curl -G "https://api.fastsaver.io/v1/fetch" \
--data-urlencode "url=https://www.facebook.com/reel/1234567890" \
-H "X-Api-Key: fs_sk_•••••••••••"
{
"ok": true,
"id": "https://www.facebook.com/reel/1234567890",
"source": "facebook.com",
"type": "video",
"download_url": "https://video-waw2-1.xx.fbcdn.net/o1/v/...",
"thumbnail_url": "https://scontent-waw2-1.xx.fbcdn.net/v/...",
"width": null,
"height": null,
"duration": 34,
"caption": "Sunday league, last-minute equaliser."
}
The -G plus --data-urlencode pairing is the least error-prone way to
send a link that carries its own ? and &.
reference
the fields Facebook actually fills in
It is the same envelope every /fetch platform returns, which is the point when one
worker handles links from several sources: ok, source,
thumbnail_url, duration and caption behave exactly as they do
in the full field reference on the Instagram page.
Four other fields behave differently enough here to be worth spelling out.
| Field | Type | Notes |
|---|---|---|
id | string | Not an ID: the video branch echoes back the URL you sent, character for character. Two link shapes for the same clip give two different id values, so it is no use as a cache key. |
type | string | video for videos and reels alike — there is no separate reel type to branch on. |
download_url | string | Signed fbcdn.net URL. Time-limited — treat it as a one-shot ticket. |
width / height | null | Always null on a Facebook video. The keys are in the payload; the values never are. |
That last row is the one that catches people. Reels are vertical and Watch uploads are usually
landscape, but the response will not tell you which — retrying does not turn those nulls into
pixels. If your UI needs a width, probe the file once you have the bytes (ffprobe, or
the browser's own metadata event) rather than reserving space from the JSON.
code
resolving and saving in one pass
Because the CDN URL expires, the useful unit of work is resolve-then-store, not resolve-then-
queue. Streaming straight to disk keeps memory flat even on a long video. The three-line
expand helper is the fb.watch fix from above, and it is worth having in the path
permanently — phone-copied links arrive in that shape constantly.
import shutil
import requests
API = "https://api.fastsaver.io/v1/fetch"
HEAD = {"X-Api-Key": "fs_sk_•••••••••••"}
def expand(link: str) -> str:
# fb.watch is not a routed host — follow it to facebook.com before sending
if "fb.watch/" in link:
return requests.head(link, allow_redirects=True, timeout=30).url
return link
def save(link: str, path: str) -> dict:
meta = requests.get(API, params={"url": expand(link)}, headers=HEAD, timeout=60).json()
if not meta.get("ok"):
raise RuntimeError(meta.get("detail", "could not resolve"))
# signed and short-lived: pull the bytes now, not in a later job
with requests.get(meta["download_url"], stream=True, timeout=300) as r:
r.raise_for_status()
with open(path, "wb") as f:
shutil.copyfileobj(r.raw, f)
return meta
info = save("https://fb.watch/AbCdEf1234/", "clip.mp4")
print(info["source"], info["duration"], "seconds")
In Node, build the query with URL rather than string concatenation and the encoding
problem disappears.
const endpoint = new URL('https://api.fastsaver.io/v1/fetch');
endpoint.searchParams.set('url', link); // already expanded; encodes ? and & for you
const res = await fetch(endpoint, {
headers: { 'X-Api-Key': process.env.FASTSAVER_KEY },
});
if (res.status === 429) {
// plan rate limit reached — back off, do not retry immediately
throw new Error('rate limited');
}
const data = await res.json();
if (!data.ok) throw new Error(data.detail ?? 'could not resolve');
// data.width and data.height are null on Facebook — do not lay out from them
// data.id is the URL you sent echoed back, not a video ID
console.log(data.duration, data.download_url);
boundaries
public means public
The API sees what a logged-out visitor sees. That boundary is deliberate and not configurable. These never resolve:
- Posts limited to friends, or to a custom audience.
- Anything inside a private or closed group, including videos a member reshared there.
- Videos on pages that are unpublished, geo-blocked, or restricted to an age-verified audience.
- Content that has been deleted — the ID survives in links long after the file does not.
Each returns ok: false with a reason, not a half-written file. Build the error path
around that and most Facebook edge cases are already handled.
honesty
where Facebook links break
- Facebook changes its markup more often than anything else we support. That churn is absorbed on our side and your call signature never moves, though a specific link shape can lag for a few hours after a change lands.
- CDN URLs expire. Signed
fbcdn.netlinks die on their own schedule. Store the file, or store the post URL and re-resolve — never the download URL. - A live broadcast has no finished file. Wait for the replay to be published before pointing anything at it.
- Throughput is capped per plan. Ten requests a minute on the free tier, 900 on the largest. A bulk job needs a queue with backoff, not a tight loop.
- Credits pay for the work, not the outcome. A resolved link costs 1.5 credits whether or not you keep the file, so deduplicate before you call. Key that cache on a URL you normalised yourself — expanded, lowercased host, tracking parameters stripped — because the
idin the response is just your own URL handed back and will not collapse two shapes of the same clip into one entry.
What you do with the media is on you. A public URL is not a licence: the uploader holds copyright and Facebook's terms still apply to redistribution.
faq
questions about the Facebook API
Can the API download private or friends-only Facebook videos?
No. Only content a signed-out visitor can open is reachable — friends-only posts, private-group videos and unpublished pages all return an error. Quickest test: open the link in a private browser window. If it plays without a login prompt, the API can resolve it.
Do I need to expand an fb.watch link before sending it?
Yes, and this one bites people. Requests are routed by host, and only facebook.com (with its subdomains) and fb.com are matched. fb.watch is neither, so it never reaches the Facebook resolver — you get 400 with {"ok": false, "detail": "Invalid URL"}. Follow the short link yourself first: a HEAD request with redirects enabled lands on the canonical facebook.com URL, and that is what you send. /share/v/ links need no such step — they are already on facebook.com.
How much does one Facebook video cost?
1.5 credits per resolved link, videos and reels alike. The free tier ships with 1,000 credits, which is a little over 660 videos before any payment is involved. Checking your remaining balance costs nothing.
Why did a Facebook link that worked last week stop working?
Usually the post changed — deleted, made friends-only, or moved into a group. If it is still public and still failing, Facebook has probably shipped a markup change. Those are patched centrally; nothing in your integration changes.
Why are width and height null on every Facebook response?
Because Facebook does not expose them to us. The two keys are always in the payload and always null on a video, so treat them as unavailable rather than as something a retry will fix. If you need the dimensions, read them from the file after you have downloaded it.
Does the endpoint return the video file or a link to it?
A link. The response is JSON with a download_url pointing at Facebook's own CDN, plus metadata. Nothing is proxied through us, so transfers run at CDN speed — but the URL is signed and expires, so fetch the bytes in the same job.
Point it at a real Facebook link
The playground runs the call in the browser and shows you the raw JSON before you commit to any code.