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:
- Fetches a single post or Reel by its shortcode
- Pulls the real image/video CDN URLs out of the JSON
- Saves photos (
.jpg) and videos (.mp4) to a folder - Handles carousels (multi-image posts) automatically
- Bulk-downloads every post from a username
How downloading actually works
It's two distinct HTTP calls, and it's important to keep them separate in your head:
- 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. - 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
- Python 3.8+ and the
requestslibrary (pip install requests) — or Node.js 18+ for the JavaScript version - A free RapidAPI account
- A subscription to the Instagram Cheapest API — the Basic plan is free (30 calls/month), enough to work through this guide
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.