ThreadsHow-to

How to download Threads videos and photos

Threads has no download button for other people's posts. A free downloader saves one post's files; our API saved 10 files from 6 posts in 33 seconds.

5 min read

Short answer

  • Threads has no download button: Save only bookmarks a post inside the app, and Copy as image saves a picture of the post card.
  • Paste a post link into our free Threads Downloader to save its videos as MP4 and its photos at full size, a few posts a day.
  • For many posts, our Threads API returns every video and photo link for 1 credit a post, and a short Python script writes the files to disk.
On this page

Threads doesn't let you download another account's video or photo. To save one post, paste its link into our free Threads Downloader and press the button under each file. To save many posts, our Threads API returns a post's media links in one call, and the script below writes them to disk.

What Threads lets you save

None of Threads' own options gives you the video file. Save, in a post's three-dot menu, bookmarks it in Settings > Saved inside Threads. On the web, Copy as image downloads a picture of the post card. Download Your Information exports your own posts, not anyone else's.

Download one post free

Copy the post's link from its share icon and paste it into the Threads Downloader. Every video and photo in the post gets its own download button, carousel slides included. Videos save as MP4 and photos as JPEG at the widest size Threads lists for them, the same files the API below returns. It reads public posts only and gives you a few lookups a day.

What one call returns

We make ToolzerHub. Our Threads API returns public Threads data as JSON with one API key, and /v1/threads/post takes a post link:

bash
curl "https://api.toolzerhub.com/v1/threads/post?post_url=https://www.threads.com/@natgeo/post/DexJk2LjVHS" \
  -H "x-api-key: $TOOLZERHUB_API_KEY"

A National Geographic video post, trimmed:

json
{
  "code": "DexJk2LjVHS",
  "user": { "username": "natgeo" },
  "media_type": 2,
  "original_width": 1080,
  "original_height": 1920,
  "video_versions": [
    { "type": 101, "url": "https://instagram.fsyd13-1.fna.fbcdn.net/o1/v/t16/f2/m84/AQP..." }
  ],
  "image_versions2": {
    "candidates": [
      { "width": 640, "height": 1136, "url": "https://instagram.fsyd8-1.fna.fbcdn.net/v/t51.71878-15/85877..." }
    ]
  },
  "carousel_media": null
}

video_versions holds the MP4 link, image_versions2.candidates lists a photo in several sizes, and a carousel puts each slide in carousel_media with the same two fields. The MP4 is 720p: this 1080x1920 upload came back as a 720x1280 file. Threads keeps the 1080p copy only in video_dash_manifest, a streaming manifest with eight video qualities from 240p to 1080p and a separate audio track, so saving 1080p means joining two streams. The links are signed and expire, so download soon after the call.

Download many posts with Python

The script takes post links separated by commas, makes one call per post and saves each file as <code>_<n>.mp4 or .jpg. It picks the widest photo, skips files it already has and writes to a .part file that is renamed when the download finishes. It needs Python 3 and requests.

python
import os
import sys
import time

import requests

API = "https://api.toolzerhub.com/v1/threads/post"
KEY = os.environ.get("TOOLZERHUB_API_KEY")


def get_post(url, tries=4):
    # 429 and 503 are worth a retry; the body says how long to wait.
    for attempt in range(tries):
        r = requests.get(API, params={"post_url": url}, headers={"x-api-key": KEY}, timeout=90)
        if r.status_code in (429, 503) and attempt < tries - 1:
            wait = int(((r.json().get("error") or {}).get("details") or {}).get("retryAfter") or r.headers.get("Retry-After") or 5 * (attempt + 1))
            time.sleep(min(wait, 60))
            continue
        r.raise_for_status()
        return r.json()["data"]


def media_files(post):
    # A carousel lists each slide in carousel_media; a single video or photo is the post itself.
    for item in post.get("carousel_media") or [post]:
        videos = item.get("video_versions") or []
        images = (item.get("image_versions2") or {}).get("candidates") or []
        if videos:
            yield videos[0]["url"], "mp4"
        elif images:
            best = max(images, key=lambda c: c.get("width") or 0)
            yield best["url"], "jpg"


def save(url, path):
    part = path + ".part"
    with requests.get(url, stream=True, timeout=120) as r:
        r.raise_for_status()
        with open(part, "wb") as f:
            for chunk in r.iter_content(1 << 16):
                f.write(chunk)
    os.replace(part, path)
    return os.path.getsize(path)


def main(links, out="threads"):
    os.makedirs(out, exist_ok=True)
    files = total = calls = 0
    for link in [l.strip() for l in links.split(",") if l.strip()]:
        calls += 1
        try:
            post = get_post(link)
        except requests.HTTPError as e:
            print(f"skipped {link}: {e.response.status_code}")
            continue
        if not post:
            print(f"skipped {link}: no public post")
            continue
        for n, (url, ext) in enumerate(media_files(post), 1):
            path = os.path.join(out, f"{post['code']}_{n}.{ext}")
            if not os.path.exists(path):
                total += save(url, path)
                files += 1
    print(f"{files} files, {total / 1e6:.1f} MB from {calls} calls, saved to {out}/")


if __name__ == "__main__":
    if not KEY or len(sys.argv) < 2:
        sys.exit("usage: TOOLZERHUB_API_KEY=... python threads_download.py LINK[,LINK...] [folder]")
    main(*sys.argv[1:])

Run it with your links and a folder name:

bash
export TOOLZERHUB_API_KEY=your_key
python threads_download.py "https://www.threads.com/@natgeo/post/DexJk2LjVHS,https://www.threads.com/@natgeo/post/Dej74BIESJL" natgeo

What came out

Given six National Geographic posts, five with media and one text-only, the script printed 10 files, 12.8 MB from 6 calls after 33 seconds.

Post Files Size each Dimensions
3 video posts 1 MP4 each 2.9 to 4.1 MB 720x1280, 13 to 31 seconds
Carousel 6 JPEG 158 to 468 KB 1440x960
Photo post 1 JPEG 414 KB 1080x1350
Text post none

The three videos ran 13, 15 and 31 seconds and came out at 2.9, 4.1 and 3.8 MB, all 720x1280; the post shown above was uploaded at 1080x1920. The carousel's six slides kept their 1440x960 shape. On the video post above, all three entries in video_versions pointed to the same 3.8 MB file, so the script takes the first. A text-only post is a call with nothing to save.

What it costs

Each post costs 1 credit, however many files it holds, and downloading the files is not an API call. The six-post run above cost 6 credits for 12.8 MB of files, and saving 1,000 posts costs 1,000 credits. You get 100 free credits a month; $19 buys 10,000 credits that never expire, and plans for higher volume cover larger archives. Set your key and the first post's files are on disk in under a minute.

To pull posts, replies and counts into a spreadsheet, see how to scrape Threads; for what Meta's own API allows, read the Threads API guide.

We are not affiliated with Meta or Threads.

Get this data from your own code

The Threads API returns it as JSON with one x-api-key header. 100 free credits monthly.