How to Download Instagram Photos and Videos with an API (Python & Node.js)

You want to save a public Instagram photo, video, Reel, or carousel to disk — programmatically, not by right-clicking. The blockers are familiar: Instagram's media URLs are signed, short-lived, and buried in JavaScript-rendered pages, and the official Meta Graph API only exposes media for accounts you own. Scraping the page yourself means rendering a browser and reverse-engineering the page state every time it changes.

This guide uses the Instagram Cheapest API on RapidAPI as the middle layer. You ask it for a post (by shortcode) or a whole profile's media (by user ID), it returns the raw Instagram JSON — including the CDN URLs for every image and video — and then you download those URLs straight to disk with a plain HTTP request. No browser, no proxies.

By the end you'll have copy-paste code that:

How downloading actually works

It's two distinct HTTP calls, and it's important to keep them separate in your head:

  1. Get the metadata (uses your API key). You call the Instagram Cheapest API with your x-rapidapi-key. The response is raw Instagram JSON containing the media's CDN URLs.
  2. Download the bytes (no API key). The CDN URLs point at Instagram's own servers (*.cdninstagram.com / fbcdn.net). You fetch those directly with a normal GET — no RapidAPI headers — and write the response body to a file.

Two consequences worth knowing up front: those CDN URLs are signed and expire (often within hours), so download soon after you fetch them — don't store the URL and try to use it next week. And because the byte download hits Instagram's CDN, not the API, it doesn't count against your RapidAPI request quota.

Prerequisites

Grab your x-rapidapi-key from the RapidAPI dashboard. Every API request sends two headers: x-rapidapi-key (your key) and x-rapidapi-host: instagram-cheapest.p.rapidapi.com.

Step 1: Set up the client

Standard boilerplate. The base URL is https://instagram-cheapest.p.rapidapi.com/api/v1/instagram and every endpoint is a GET.

import os
import requests

API_KEY = os.environ.get("RAPIDAPI_KEY", "YOUR_RAPIDAPI_KEY")
HOST = "instagram-cheapest.p.rapidapi.com"
BASE_URL = f"https://{HOST}/api/v1/instagram"

HEADERS = {
    "x-rapidapi-key": API_KEY,
    "x-rapidapi-host": HOST,
}


def get(path, params=None):
    """GET against the Instagram Cheapest API (returns raw JSON)."""
    resp = requests.get(f"{BASE_URL}/{path}", headers=HEADERS,
                        params=params, timeout=30)
    resp.raise_for_status()
    return resp.json()

Note the 30-second timeout: this API returns real-time, uncached data pulled live from Instagram, so a call takes a few seconds. Give it room rather than failing early.

Step 2: Fetch a single post or Reel by shortcode

Every Instagram post and Reel has a shortcode — the part after /p/, /reel/, or /tv/ in its URL. For https://www.instagram.com/p/Cabc123XYz/ the shortcode is Cabc123XYz. The media_by_code2 endpoint takes it as the code query param:

import json

def get_post(code):
    return get("media_by_code2", params={"code": code})


post = get_post("Cabc123XYz")   # replace with a real shortcode
print(json.dumps(post, indent=2)[:2000])

Always print the raw response first. This API passes through exactly what Instagram returns, so the structure is Instagram's, not ours — and it can differ between a single image, a video/Reel, and a carousel. Inspecting the JSON once tells you where the media URLs live for the post type you care about.

Step 3: Pull the media URLs out of the JSON

Rather than hardcode a brittle path like post["items"][0]["video_versions"][0]["url"], we'll walk the whole JSON tree and collect every media URL we find. Instagram nests image candidates under image_versions2.candidates and video renditions under video_versions (each with a width so you can pick the highest resolution). Because the walker recurses, it transparently handles carousels — where each slide carries its own versions:

def find_media(node, found=None):
    """Recursively collect image/video URLs from raw Instagram JSON.

    Returns a list of dicts, one per media item (photo or video),
    each with the best (highest-resolution) URL available.
    Verify the keys below against your own printed JSON if Instagram
    ever changes the response shape.
    """
    if found is None:
        found = []

    if isinstance(node, dict):
        # A video item: video_versions is a list of renditions w/ widths.
        videos = node.get("video_versions")
        if isinstance(videos, list) and videos:
            best = max(videos, key=lambda v: v.get("width", 0) or 0)
            if best.get("url"):
                found.append({"type": "video", "url": best["url"]})

        # A photo item: image_versions2.candidates is a list of sizes.
        iv = node.get("image_versions2")
        if isinstance(iv, dict):
            candidates = iv.get("candidates") or []
            # Only treat as a standalone photo if this node isn't a video
            # (videos also carry a poster image we don't want to save).
            if candidates and not (isinstance(videos, list) and videos):
                best = max(candidates, key=lambda c: c.get("width", 0) or 0)
                if best.get("url"):
                    found.append({"type": "photo", "url": best["url"]})

        for value in node.values():
            find_media(value, found)

    elif isinstance(node, list):
        for item in node:
            find_media(item, found)

    return found


media = find_media(post)
for m in media:
    print(m["type"], m["url"][:80], "...")

The guard around the image block matters: a video item also ships a poster/thumbnail under image_versions2, and you usually don't want to save that as a separate "photo." Skipping the image when the same node has video_versions keeps one clean file per slide.

Step 4: Download the files to disk

Now the second HTTP call. We hit the CDN URL directly — no RapidAPI headers — and stream the bytes to a file. We pick the extension from the media type:

import os

def download_media(media, out_dir="downloads", prefix="ig"):
    os.makedirs(out_dir, exist_ok=True)
    saved = []
    for i, m in enumerate(media):
        ext = "mp4" if m["type"] == "video" else "jpg"
        path = os.path.join(out_dir, f"{prefix}_{i}.{ext}")
        # No API key here — this is Instagram's public CDN.
        with requests.get(m["url"], timeout=60, stream=True) as r:
            r.raise_for_status()
            with open(path, "wb") as f:
                for chunk in r.iter_content(chunk_size=8192):
                    f.write(chunk)
        saved.append(path)
        print(f"saved {path}")
    return saved


# Full flow for one post:
post = get_post("Cabc123XYz")
download_media(find_media(post), prefix="Cabc123XYz")

That's the entire single-post downloader: fetch JSON → find URLs → stream to disk. A carousel just yields multiple files; a Reel yields one .mp4. Because the CDN links expire, run step 4 right after step 2 — don't cache the URLs.

Step 5: Bulk-download every post from a username

To grab a whole profile, you need the numeric user_id (the media endpoints don't accept usernames). Resolve it once with userinfo, then page through user_media, which returns ~12 posts per page and echoes a top-level next_max_id plus more_available for pagination:

import time

def get_user_id(username):
    profile = get(f"user/{username}")
    # Confirm the key against your printed profile JSON.
    return profile.get("id") or profile.get("pk")


def download_profile(username, max_pages=3):
    user_id = get_user_id(username)
    next_max_id, page = None, 0

    while page < max_pages:
        params = {"user_id": user_id}
        if next_max_id:
            params["next_max_id"] = next_max_id

        resp = get("user_media", params=params)
        media = find_media(resp)
        print(f"page {page + 1}: found {len(media)} media files")
        download_media(media, out_dir=f"downloads/{username}",
                       prefix=f"{username}_p{page}")

        # Pagination is driven by the response, not assumptions.
        if not resp.get("more_available"):
            break
        next_max_id = resp.get("next_max_id")
        if not next_max_id:
            break
        page += 1
        time.sleep(1)   # be polite; the free tier is ~1 req/sec


download_profile("nike", max_pages=2)

Each page is one billed API request (the byte downloads are free CDN traffic). The time.sleep(1) keeps you under the free tier's ~1 request/second limit; raise the pace on a paid tier. Want Reels specifically? Swap user_media for user_reels (it paginates with an after cursor read from page_info.end_cursor instead of next_max_id).

The Node.js version

Same two-step flow with the built-in fetch (Node 18+) and fs. The recursive URL finder is identical in spirit:

import { writeFile, mkdir } from "node:fs/promises";

const API_KEY = process.env.RAPIDAPI_KEY || "YOUR_RAPIDAPI_KEY";
const HOST = "instagram-cheapest.p.rapidapi.com";
const BASE_URL = `https://${HOST}/api/v1/instagram`;

async function get(path, params = {}) {
  const qs = new URLSearchParams(params).toString();
  const url = `${BASE_URL}/${path}${qs ? "?" + qs : ""}`;
  const res = await fetch(url, {
    headers: { "x-rapidapi-key": API_KEY, "x-rapidapi-host": HOST },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${path}`);
  return res.json();
}

// Recursively collect best-resolution image/video URLs.
function findMedia(node, found = []) {
  if (Array.isArray(node)) {
    node.forEach((item) => findMedia(item, found));
  } else if (node && typeof node === "object") {
    const videos = node.video_versions;
    if (Array.isArray(videos) && videos.length) {
      const best = videos.reduce((a, b) => ((b.width || 0) > (a.width || 0) ? b : a));
      if (best.url) found.push({ type: "video", url: best.url });
    }
    const cands = node.image_versions2?.candidates;
    if (Array.isArray(cands) && cands.length && !(Array.isArray(videos) && videos.length)) {
      const best = cands.reduce((a, b) => ((b.width || 0) > (a.width || 0) ? b : a));
      if (best.url) found.push({ type: "photo", url: best.url });
    }
    Object.values(node).forEach((v) => findMedia(v, found));
  }
  return found;
}

async function downloadMedia(media, dir = "downloads", prefix = "ig") {
  await mkdir(dir, { recursive: true });
  for (let i = 0; i < media.length; i++) {
    const ext = media[i].type === "video" ? "mp4" : "jpg";
    const res = await fetch(media[i].url); // no API headers — public CDN
    const buf = Buffer.from(await res.arrayBuffer());
    const path = `${dir}/${prefix}_${i}.${ext}`;
    await writeFile(path, buf);
    console.log(`saved ${path}`);
  }
}

// Download one post by shortcode:
const post = await get("media_by_code2", { code: "Cabc123XYz" });
await downloadMedia(findMedia(post), "downloads", "Cabc123XYz");

Quick one-liner with curl + jq

If you just want to eyeball the URLs from the terminal, fetch the JSON with curl and dig out video links with jq:

curl -s "https://instagram-cheapest.p.rapidapi.com/api/v1/instagram/media_by_code2?code=Cabc123XYz" \
  -H "x-rapidapi-key: $RAPIDAPI_KEY" \
  -H "x-rapidapi-host: instagram-cheapest.p.rapidapi.com" \
  | jq -r '.. | .video_versions? // empty | .[0].url'

# Then download whatever URL it prints:
curl -o reel.mp4 "<paste-the-cdn-url-here>"

The .. recursive descent in jq mirrors the recursive walker above — it finds video_versions anywhere in the tree without you knowing the exact path.

The complete script (copy, save, run)

Don't want to assemble the pieces? Save the whole thing below as download_media.py, set RAPIDAPI_KEY, and run it. It bundles every step above and takes a command-line argument: a shortcode for one post, @username for a whole profile, or even a pasted URL.

#!/usr/bin/env python3
"""Download public Instagram photos & videos via the Instagram Cheapest API.

Setup:
    pip install requests
    export RAPIDAPI_KEY='your-key'      # from your RapidAPI dashboard

Usage:
    python3 download_media.py Cabc123XYz                 # one post / Reel by shortcode
    python3 download_media.py @nike                      # a whole profile
    python3 download_media.py @nike --pages 5            # more pages of the profile
    python3 download_media.py https://www.instagram.com/p/Cabc123XYz/   # a pasted URL works too

Files are saved into ./downloads/. Instagram CDN links are signed and expire
quickly, so this fetches the JSON and downloads immediately.
"""
import os
import sys
import json
import time
import argparse
import requests

HOST = "instagram-cheapest.p.rapidapi.com"
BASE_URL = f"https://{HOST}/api/v1/instagram"
API_KEY = os.environ.get("RAPIDAPI_KEY", "")
HEADERS = {"x-rapidapi-key": API_KEY, "x-rapidapi-host": HOST}


def get(path, params=None):
    resp = requests.get(f"{BASE_URL}/{path}", headers=HEADERS, params=params, timeout=30)
    resp.raise_for_status()
    return resp.json()


def find_media(node, found=None):
    """Recursively collect best-resolution image/video URLs (single, Reel, carousel)."""
    if found is None:
        found = []
    if isinstance(node, dict):
        videos = node.get("video_versions")
        if isinstance(videos, list) and videos:
            best = max(videos, key=lambda v: v.get("width", 0) or 0)
            if best.get("url"):
                found.append({"type": "video", "url": best["url"]})
        iv = node.get("image_versions2")
        if isinstance(iv, dict):
            candidates = iv.get("candidates") or []
            if candidates and not (isinstance(videos, list) and videos):
                best = max(candidates, key=lambda c: c.get("width", 0) or 0)
                if best.get("url"):
                    found.append({"type": "photo", "url": best["url"]})
        for value in node.values():
            find_media(value, found)
    elif isinstance(node, list):
        for item in node:
            find_media(item, found)
    return found


def download_media(media, out_dir="downloads", prefix="ig"):
    os.makedirs(out_dir, exist_ok=True)
    saved = []
    for i, m in enumerate(media):
        ext = "mp4" if m["type"] == "video" else "jpg"
        path = os.path.join(out_dir, f"{prefix}_{i}.{ext}")
        with requests.get(m["url"], timeout=60, stream=True) as r:   # public CDN, no API key
            r.raise_for_status()
            with open(path, "wb") as f:
                for chunk in r.iter_content(chunk_size=8192):
                    f.write(chunk)
        print(f"  saved {path}")
        saved.append(path)
    return saved


def download_post(code):
    print(f"Fetching post {code} ...")
    post = get("media_by_code2", params={"code": code})
    media = find_media(post)
    print(f"Found {len(media)} media file(s)")
    return download_media(media, prefix=code)


def download_profile(username, max_pages=3):
    profile = get(f"user/{username}")
    user_id = profile.get("id") or profile.get("pk")
    if not user_id:
        print("Could not find user_id. Raw profile (first 1 KB):")
        print(json.dumps(profile, indent=2)[:1000])
        return []
    print(f"{username} -> user_id {user_id}")
    saved, next_max_id, page = [], None, 0
    while page < max_pages:
        params = {"user_id": user_id}
        if next_max_id:
            params["next_max_id"] = next_max_id
        resp = get("user_media", params=params)
        media = find_media(resp)
        print(f"Page {page + 1}: {len(media)} media file(s)")
        saved += download_media(media, out_dir=f"downloads/{username}",
                                prefix=f"{username}_p{page}")
        if not resp.get("more_available"):
            break
        next_max_id = resp.get("next_max_id")
        if not next_max_id:
            break
        page += 1
        time.sleep(1)   # stay under the free tier's ~1 req/sec
    return saved


def parse_target(target):
    """Return ('post', shortcode) or ('profile', username) from any input."""
    t = target.strip().rstrip("/")
    if "instagram.com" in t:
        parts = [p for p in t.split("/") if p]
        for kw in ("p", "reel", "reels", "tv"):
            if kw in parts:
                return "post", parts[parts.index(kw) + 1]
        return "profile", parts[-1].lstrip("@")
    if t.startswith("@"):
        return "profile", t[1:]
    return "post", t


def main():
    parser = argparse.ArgumentParser(description="Download public Instagram media.")
    parser.add_argument("target", help="a shortcode, @username, or full instagram.com URL")
    parser.add_argument("--pages", type=int, default=3,
                        help="max pages when downloading a profile (default 3)")
    args = parser.parse_args()

    if not API_KEY:
        sys.exit("Set RAPIDAPI_KEY first:  export RAPIDAPI_KEY='your-key'")

    kind, value = parse_target(args.target)
    if kind == "profile":
        download_profile(value, max_pages=args.pages)
    else:
        download_post(value)


if __name__ == "__main__":
    main()

What it costs

Only the metadata calls are billed; the actual media bytes come free from Instagram's CDN. Pricing is per request and drops as you scale: from $0.10 per 1,000 requests on the Mega tier, $0.11 on Ultra, and $0.13 on Pro, with a free Basic plan (30 calls/month) for prototyping. Since one user_media page returns ~12 posts, downloading 1,000 posts from a profile is on the order of ~85 API calls — well under a penny of request cost at the top tier. Each tier also includes 10 GB/month of API bandwidth (response JSON, not the media downloads), billed at $0.001/MB beyond that.

Conclusion

Downloading Instagram media programmatically comes down to a clean two-step split: use the Instagram Cheapest API to turn a shortcode or username into raw JSON, walk that JSON to collect the CDN URLs, then stream those URLs to disk with an ordinary GET. The recursive finder keeps you honest — it adapts to single photos, Reels, and carousels without hardcoding a fragile path — and the golden rule still holds: print the JSON and verify against what you actually see.

Ready to build your downloader? Start on the free tier:

Get started with the Instagram Cheapest API on RapidAPI →

Compliance note: this API returns public Instagram data only. Downloading and reusing someone else's media may be restricted by copyright and Instagram's terms — only download content you have the right to use, and comply with applicable law (GDPR/CCPA). This product is not affiliated with or endorsed by Meta/Instagram.

Start Building Today

Get 30 free requests per month on the Basic plan. No commitment required.

Get Started on RapidAPI