FastSaverAPI moved. The API and dashboard now live at api.fastsaver.iowhat changed.

downloading Instagram reels in Python, streamed and batched

Downloading Instagram reels in Python is two HTTP requests: resolve the link to a media URL, then pull the bytes. This guide builds the downloader with requests — naive first, then streamed to disk, then with retries and timeouts, then as a batch runner over a list of links. It also covers why that media URL expires, and why that single fact decides how you design a queue.

Last updated

Language
Python 3.9+, requests only
Endpoint
GET /fetch · 1.5 credits per reel
Per reel
1 metered call + 1 direct CDN download
Ends with
a batch runner over a list of links

the shape

what one call gives you

The two requests do different jobs, and keeping them apart makes the rest obvious. The first turns a page URL into a media URL. The second is an ordinary download from Instagram's CDN — it never touches us and costs nothing.

GET https://api.fastsaver.io/v1/fetch resolve step · 1.5 credits
request
curl --get "https://api.fastsaver.io/v1/fetch" \
  --data-urlencode "url=https://www.instagram.com/reel/Cx7YyZgIlPq/" \
  -H "X-Api-Key: $FASTSAVER_KEY"
200 OK · response
{
  "ok": true,
  "id": "Cx7YyZgIlPq",
  "source": "instagram.com",
  "type": "video",
  "download_url": "https://scontent-fra5-1.cdninstagram.com/o1/v/...",
  "thumbnail_url": "https://scontent-fra5-1.cdninstagram.com/v/...",
  "width": 1080,
  "height": 1920,
  "duration": 27,
  "caption": "three minutes of sourdough, compressed."
}

Note the --data-urlencode: reel links carry query strings like ?igsh=, and pasting one raw turns its parameters into yours. requests handles that when you pass params=.

step 1

the version that works and nothing more

Start with the smallest thing that puts an MP4 on disk.

naive.py
import os
import requests

FETCH = "https://api.fastsaver.io/v1/fetch"
KEY = os.environ["FASTSAVER_KEY"]
LINK = "https://www.instagram.com/reel/Cx7YyZgIlPq/"

meta = requests.get(FETCH, params={"url": LINK}, headers={"X-Api-Key": KEY}).json()
open("reel.mp4", "wb").write(requests.get(meta["download_url"]).content)

Run it and you have the reel. Ship it and you have three problems. .content holds the whole file in memory. Neither request has a timeout, so a stalled socket hangs the process forever. And meta["download_url"] raises KeyError on failure, which is a strange way to learn a post was deleted. Keep the key in the environment from the first draft.

step 2

stream the file to disk

Replace the second request with a streamed write. stream=True defers the body, iter_content hands you fixed-size chunks, and the with block releases the connection even if the write throws.

save() — streamed write
from pathlib import Path

def save(url: str, dest: Path) -> Path:
    """Stream a media URL to dest. Writes dest.part first, then renames."""
    tmp = dest.with_suffix(dest.suffix + ".part")
    with requests.get(url, stream=True, timeout=(5, 30)) as r:
        r.raise_for_status()
        with tmp.open("wb") as f:
            for chunk in r.iter_content(chunk_size=65536):
                f.write(chunk)
    tmp.replace(dest)
    return dest

Two details earn their place. The timeout is a tuple — five seconds to connect, thirty between chunks; one number would cap the whole transfer and break on slow downloads. The .part rename means an interrupted run leaves an obviously incomplete file, not a truncated MP4 that plays for four seconds and stops.

step 3

timeouts, retries and the ok flag

Now the resolve step. A Session sets the key once and reuses the connection; urllib3 handles transport retries so you never write a sleep loop.

resolve() — session, retries, typed failure
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

FETCH = "https://api.fastsaver.io/v1/fetch"
BALANCE = "https://api.fastsaver.io/v1/balance"

session = requests.Session()
session.headers["X-Api-Key"] = os.environ["FASTSAVER_KEY"]
session.mount("https://", HTTPAdapter(max_retries=Retry(
    total=4,
    backoff_factor=1.5,               # 0s, 1.5s, 3s, 6s
    status_forcelist=(429, 500, 502, 503, 504),
    allowed_methods=("GET",),
    respect_retry_after_header=True,
)))


class ResolveError(RuntimeError):
    """The link cannot be resolved. Retrying will not change that."""


def resolve(link: str) -> dict:
    r = session.get(FETCH, params={"url": link}, timeout=(5, 60))
    data = r.json()                       # read the body first — failures are JSON too
    if not data.get("ok"):
        raise ResolveError(data.get("detail", f"HTTP {r.status_code}"))
    return data

Note what is missing: there is no raise_for_status(). Read the body before you look at the status, because running out of credits answers a plain 400 — this API has no 402 — carrying detail: "Insufficient credits. Please top up to your account.". A raise_for_status() would flatten that into an opaque HTTPError and you would never see the reason. Treat ok as the success flag instead: a private profile or a deleted post is a real answer, so record it and move on. Automatic retries belong to 429 and 5xx, and the adapter above already owns those; the guides index has the whole error table. Before a long run, check what you have — /balance is free.

know the budget first
bal = session.get(BALANCE, timeout=10).json()
print(bal["plan"], "-", bal["credits"], "credits,", bal["rpm_limit"], "req/min")
# Pro - 98500 credits, 60 req/min  ->  about 65,000 reels at 1.5 each

the constraint

why the CDN URL expires, and what that costs you

download_url is signed by Instagram, not by us. The expiry sits in the query string, and once it passes the CDN returns 403 to everyone. That one fact decides the shape of your pipeline.

  • Resolve and download are one unit of work. Queue the URL for a worker that picks it up twenty minutes later and a share of those jobs are dead on arrival.
  • Store the id, never the URL. The shortcode makes a good key and filename; a cached download_url is a time bomb in your database.
  • Re-resolving costs 1.5 credits. That is the price of splitting the stages, and it beats a job that fails at 3 a.m.
  • Make 403 recoverable. The download stage should call resolve() again rather than failing an item for good.

step 4

a batch runner over a list of links

Put the pieces together: one link per line, skip reels already on disk without spending a credit, pace against your rate limit, log failures to a re-runnable file.

batch.py
import re
import time
from pathlib import Path

OUT = Path("reels")
OUT.mkdir(exist_ok=True)
SHORTCODE = re.compile(r"/(?:reel|reels|p|tv)/([A-Za-z0-9_-]+)")


def shortcode(link: str) -> str:
    m = SHORTCODE.search(link)
    return m.group(1) if m else ""


def run(list_file: str, gap: float = 1.2) -> None:
    links = [l.strip() for l in Path(list_file).read_text().splitlines() if l.strip()]
    failed = []

    for i, link in enumerate(links, 1):
        code = shortcode(link)
        if code and (OUT / f"{code}.mp4").exists():
            print(f"[{i}/{len(links)}] have {code}.mp4")
            continue
        try:
            meta = resolve(link)                          # 1.5 credits
            if meta["type"] != "video":
                raise ResolveError(f"type is {meta['type']}, not video")
            dest = save(meta["download_url"], OUT / f"{meta['id']}.mp4")
            print(f"[{i}/{len(links)}] {dest.name}  {meta.get('duration')}s")
        except Exception as exc:
            print(f"[{i}/{len(links)}] FAIL {link}: {exc}")
            failed.append(f"{link}\t{exc}")
            if "Insufficient credits" in str(exc):
                failed.extend(links[i:])          # every remaining link fails the same way
                print("out of credits - stopping")
                break
        time.sleep(gap)

    if failed:
        Path("failed.txt").write_text("\n".join(failed))


run("links.txt")

gap is your rate budget and only covers the resolve call. Free's 10 a minute means gap=6; Pro's 60 brings it to 1; on Ultra and Mega the sleep stops mattering and your disk becomes the bottleneck — move to a thread pool and a token bucket. Skipping by shortcode before resolving is the difference between re-running a thousand-link list for free and paying 1,500 credits for files you already had. A /p/ link can resolve to an album, and an album has no top-level download_url at all — that is what the type guard is for, and the Instagram downloader API reference has that shape. Breaking out on the credits message matters too: once the balance is gone every remaining link burns a request for the same 400.

honest comparison

instaloader, yt-dlp, and when not to pay

instaloader is purpose-built for Instagram and needs no key for public posts. yt-dlp covers it alongside a thousand other sites. Both are free, and at a few downloads a day from a home connection either one beats an API call.

They degrade in the same place. Instagram is aggressive toward unauthenticated traffic from data centre ranges, so the day your script moves to a VPS you collect 429s and login walls. The fixes are all work: a session cookie from a real account, kept warm, residential proxies, rotation, and an update whenever an extractor breaks on Instagram's schedule. A login also puts that account at risk.

That maintenance is the whole product here. Both routes still see public content only.

honesty

where an Instagram reel downloader breaks

  • Public content only. Private profiles fail by design, with a reason rather than a file.
  • Signed URLs expirethumbnail_url too. Fetch the bytes in the same job and serve poster frames from your own storage.
  • Rate limits are per plan — 10 a minute on Free up to 900 on Mega. A 429 is pacing, not a bad link.
  • You get the file Instagram serves. Nothing is transcoded on the way through.
  • Instagram changes. Fixes land on our side and resolve() stays as written, but nobody in this business can promise "never breaks".
  • Copyright is yours. Downloading a reel is not a licence to republish it.

Log the reason for everything you skip — a batch downloader without a failure log is one you cannot debug three weeks later.

faq

questions about downloading reels in Python

How do I download an Instagram reel in Python without logging in?

Send the link to GET /fetch with your key in the X-Api-Key header, read download_url from the JSON, then requests.get that URL with stream=True and write the chunks to a file. No cookie, no headless browser.

Why does the Instagram video URL stop working after a while?

It is a signed URL: Instagram's CDN puts an expiry in the query string and then answers 403 to everyone. Nothing on our side extends it. Download the bytes in the same function that resolved the link.

Is instaloader or yt-dlp good enough instead of a paid API?

Often, yes — both are free and handle public reels from a home connection. They fall over once Instagram rate-limits your IP, which is normal for data centre traffic. After that you own a cookie jar, proxies and extractor fixes.

How many reels can I download per minute?

Your plan's per-minute ceiling covers the resolve call only; the CDN download is not metered. Free allows 10 a minute, Pro 60, Ultra 250, Mega 900. GET /balance reports your rpm_limit and never spends credits.

Can a Python script download reels from a private account?

No. Only publicly reachable content resolves, so a private profile comes back with ok: false and a reason instead of a file.

Run the script against your own list

The free tier is 1,000 credits — roughly 660 resolves — enough to take this batch runner all the way to production.