An Instagram scraping API gives you public Instagram data as JSON without logins, cookies or a browser: profiles by username or ID, posts, reels, tagged posts, single posts, comments, and user, hashtag and place search. This guide shows how to scrape Instagram with one endpoint, how its three pagination styles work, and how to page through a whole account with a runnable Python or Node.js script.
Everything here was checked against live responses on October 3, 2026. The examples use NASA's public account and real response fields, trimmed with … where they run long.
Key takeaways
- Every call is a
POSTtohttps://scrapingbot.io/api/v1/instagramwith{"endpoint": "…", "params": {…}}. Each request costs 5 credits, and only successful requests are charged. - Start with
/user/by_username: the feed endpoints need the numericuser_idit returns. - There are three pagination styles: posts and tagged posts use
end_cursor, reels usemax_id, and comments usepagination_token. - Keep media IDs as strings. They are larger than JavaScript numbers can hold exactly.
- Active stories are available with
/stories/by_username; highlights are not supported. Private accounts return only their basic profile.
Your options for getting Instagram data
There are three realistic routes. Which one fits depends on whose data you need.
| Route | What you get | Limits | Use it when |
|---|---|---|---|
| Meta's official Instagram Platform | Full data for professional accounts that authorize your app; basic data on other business and creator accounts through Business Discovery | App review and permissions; personal accounts are out of scope; hashtag search is capped at 30 unique hashtags per account in a rolling 7 days | You manage the accounts, or you need only basic business data |
| Your own scraper | Whatever the logged-out web app shows | Login walls, aggressive rate limits, fingerprinting and internal endpoints that change without notice | Scraping is your core product and you can staff its maintenance |
| A scraping API | Public profiles, posts, reels, tagged posts, comments and search as JSON | Public data only; a per-request price | You need public data on accounts you don't own |
The rest of this guide covers the third route with the ScrapingBot Instagram API. For the bigger picture on providers, see the comparison of web scraping APIs; for why DIY scrapers break, see the common web scraping challenges.
Instagram API quick start
Sign up to get an API key and 100 free credits; no card is needed. Your first request looks up a profile:
curl -X POST https://scrapingbot.io/api/v1/instagram \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endpoint": "/user/by_username", "params": {"username": "nasa"}}'
- Endpoint
POST /api/v1/instagram- Auth
x-api-keyheader, Bearer token orapi_keyquery parameter- Cost
- 5 credits per successful request
- Timeout
- 45 seconds per request
A successful call returns success, data, duration, statusCode and creditsUsed. Errors return success: false and an error message such as Missing required parameter: username. In Python and Node.js:
import requests
res = requests.post(
"https://scrapingbot.io/api/v1/instagram",
headers={"x-api-key": "YOUR_API_KEY"},
json={"endpoint": "/user/by_username", "params": {"username": "nasa"}},
timeout=90,
)
profile = res.json()["data"]
print(profile["id"], profile["follower_count"])
const res = await fetch("https://scrapingbot.io/api/v1/instagram", {
method: "POST",
headers: { "x-api-key": "YOUR_API_KEY", "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: "/user/by_username", params: { username: "nasa" } }),
});
const { data: profile } = await res.json();
console.log(profile.id, profile.follower_count);
Run these on your server, never in a browser, where anyone could read your key. The cURL converter translates any cURL command into other languages, and the JSON formatter helps with the long responses Instagram produces.
All 20 Instagram endpoints
| endpoint | Returns | Key params | Next page |
|---|---|---|---|
/user/by_username | Profile by username | username | Single result |
/user/by_id | Profile by numeric ID | user_id | Single result |
/user/quick_by_username | Same as /user/by_username | username | Single result |
/medias/by_user_id | An account's posts | user_id, count (1–50) | page_info.end_cursor |
/reels/by_user_id | An account's reels | user_id, page_size (1–50) | paging_info.max_id |
/medias/tagged_by_user_id | Posts the account is tagged in | user_id, count (1–12) | page_info.end_cursor |
/stories/by_username | An account's active stories | username | Single result |
/followers/by_user_id | Accounts that follow the account | user_id, query | next_max_id |
/following/by_user_id | Accounts the account follows | user_id, query | next_max_id |
/media/by_shortcode | One post or reel | shortcode | Single result |
/media/by_url | One post or reel | url | Single result |
/comments/media_comments_by_id | Comments on a post or reel | code_or_id_or_url | pagination_token |
/comments/replies | Replies to one comment | code_or_id_or_url, comment_id | Single result |
/search/users_by_keyword | Accounts | keyword | Single result |
/search/hashtags_by_keyword | Hashtags | keyword | Single result |
/search/places_by_keyword | Places | keyword | Single result |
/search/global | Accounts, hashtags and places | keyword | Single result |
/search/posts | Posts and reels | query | Single result |
/media/shortcode_to_id | Media ID from a shortcode | shortcode | Single result |
/media/id_to_shortcode | Shortcode from a media ID | id | Single result |
An endpoint that isn't in this list fails with HTTP 500 and Failed to process Instagram request, and isn't charged. The Instagram section of the docs has every parameter with its default.
Scrape an Instagram profile by username or ID
/user/by_username takes a username, with or without @; a profile URL works too. /user/by_id takes the numeric account ID. If you send a username to /user/by_id, or a numeric ID to /user/by_username, the request is routed to the right lookup automatically, which saves a round of input cleaning.
{
"success": true,
"data": {
"id": "528817151",
"username": "nasa",
"full_name": "NASA",
"biography": "Making the seemingly impossible, possible. ✨",
"follower_count": 104282511,
"following_count": 89,
"is_verified": true,
"is_private": false,
"is_business": false,
"external_url": "https://www.nasa.gov",
"profile_pic_url": "https://scontent-….cdninstagram.com/…",
"bio_links": [ … ],
…
},
"duration": "0.32",
"statusCode": 200,
"creditsUsed": 5
}
Save id: every feed endpoint takes it as user_id, and unlike the username, it never changes when an account is renamed. That makes it the right key for any table of accounts you track. Check is_private before you request a feed; a private account's posts aren't available, so the request would only fail.
Scrape all posts from an Instagram profile
/medias/by_user_id returns an account's posts, newest first, as edges. Each edge holds a node with the post. Here is the procedure for a complete crawl:
- Look up the profile. Call
/user/by_usernamewith the username. Save the numericid, and stop ifis_privateis true. - Request the first page of posts. Call
/medias/by_user_idwith thatuser_idand acountof 50. Leaveend_cursorout. - Store each post by its media
id. Write every post keyed on itsidand skip IDs you already have. - Check
page_info.has_next_page. If it is false, you have every post. Ignore how many posts the page held. - Send the cursor back. Copy
page_info.end_cursorinto the next request'send_cursor, fetch the next page, and continue from step 3.
A real first page for NASA, asking for 50 posts:
{
"success": true,
"data": {
"count": 33,
"edges": [
{
"cursor": "…",
"node": {
"id": "3999305299946080602",
"code": "DeAYvsnn4la",
"taken_at": 1790974365,
"media_type": 8,
"product_type": "carousel_container",
"is_video": false,
"like_count": 364618,
"comment_count": 1340,
"carousel_media_count": 3,
"caption": { "text": "Swipe for countless stars …" },
"display_url": "https://scontent-….cdninstagram.com/…",
"carousel_media": [ … ],
"timeline_pinned_user_ids": [],
…
}
},
… 32 more
],
"page_info": {
"end_cursor": "3976681299041141597_528817151",
"has_next_page": true
}
},
"duration": "2.50",
"statusCode": 200,
"creditsUsed": 5
}
Things to know before you store these:
- Page size is Instagram's call. We asked for 50 and got 33 on each of two pages. Send the maximum anyway; a request costs the same whatever it returns.
- Pinned posts come first. The first three posts on NASA's page 1 were pinned, with the account's ID in
timeline_pinned_user_ids, and they were older than the posts after them. Don't treat the first post as the newest. media_typetells you the format: 1 is a photo, 2 a video (reels haveproduct_typeclips), and 8 a carousel whose items are incarousel_media.codeis the shortcode in the post URL,instagram.com/p/DeAYvsnn4la/.taken_atis the upload time in Unix seconds.
Scrape Instagram reels
/reels/by_user_id returns only reels, with the newest first. It paginates differently from posts: the items are in items[].media, and the next page comes from paging_info.max_id, which you send back as max_id while paging_info.more_available is true. page_size takes 1 to 50; count works as an alias.
{
"success": true,
"data": {
"items": [
{
"media": {
"id": "3996242083173720380",
"code": "Dd1gQBsCWE8",
"taken_at": 1790609228,
"media_type": 2,
"product_type": "clips",
"like_count": 103528,
"comment_count": 889,
"caption": { "text": "What happens when we detect an asteroid that could pose a th…" },
"video_versions": [ { "width": 608, "height": 1080, "url": "https://scontent-….cdninstagram.com/….mp4?…" }, … ],
…
}
},
… 11 more
],
"paging_info": { "max_id": "3976187338433532695_528817151", "more_available": true },
"status": "ok"
},
"creditsUsed": 5
}
Reels also appear in the posts feed, so if you crawl both, de-duplicate on id. Play counts were empty (null) on the reels we fetched, so don't build a pipeline that depends on view numbers being present; likes and comments were always filled.
Scrape tagged posts
/medias/tagged_by_user_id returns posts in which other accounts tagged this one. It's the closest thing to a mentions feed for brand monitoring. It paginates like posts, with end_cursor, but count tops out at 12: anything larger is rejected with a 400 and Tagged posts support a maximum count of 12.
Two differences from the posts feed showed up in our test. The id carries the owner's ID as a suffix, as in 3976079382136818132_1054403238, so split on _ before you compare it with other media IDs. And the nodes had no taken_at, so if you need the date, fetch the post itself with /media/by_shortcode using its code. The author of each tagged post is in node.user.username.
Get a single post or reel by URL or shortcode
Have a link? Send it to /media/by_url. Have just the code? Send it to /media/by_shortcode. Both return the full media object: caption, owner, counts, every image size, video versions, and, for carousels, every item.
curl -X POST https://scrapingbot.io/api/v1/instagram \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endpoint": "/media/by_url", "params": {"url": "https://www.instagram.com/p/Dd9gYJnDCKl/"}}'
{
"success": true,
"data": {
"id": "3998494441249448613",
"code": "Dd9gYJnDCKl",
"media_type": 1,
"product_type": "feed",
"taken_at": 1790877709,
"like_count": 91869,
"comment_count": 474,
"caption": { "text": "Godspeed, Crew-13. 🚀 …" },
"owner": { "id": "528817151", "username": "nasa", "is_verified": true, … },
"image_versions2": { "candidates": [ { "width": 1275, "height": 1275, "url": "https://…" }, … 13 more ] },
"video_versions": null,
…
},
"duration": "0.27",
"statusCode": 200,
"creditsUsed": 5
}
To get the best file for every item, whatever the format, pick the largest version of each one. This works on posts, reels, carousels and tagged posts:
def largest(versions):
return max(versions, key=lambda v: (v.get("width") or 0) * (v.get("height") or 0))["url"]
def media_urls(media):
"""The best-quality file URL for each item in a post, reel or carousel."""
urls = []
for item in media.get("carousel_media") or [media]:
if item.get("video_versions"):
urls.append(largest(item["video_versions"]))
else:
urls.append(largest(item["image_versions2"]["candidates"]))
return urls
The URLs are signed. In our test they carried an oe expiry parameter about four days out, so download the files you need soon after you fetch them, and store the media ID rather than the URL.
Scrape Instagram comments
/comments/media_comments_by_id accepts a shortcode, a media ID or a post URL in code_or_id_or_url. The response is nested one level deeper than the others: comments are in data.data.items, the post's comment count is in data.data.total, and the token for the next page is data.pagination_token.
{
"success": true,
"data": {
"data": {
"items": [
{
"id": "18119468581950695",
"text": "Go falcon, go dragon, go crew-13 🚀",
"created_at": 1790878296,
"comment_like_count": 97,
"child_comment_count": 1,
"user": { "username": "iamwidya88", … },
…
},
… 16 more
],
"count": 17,
"total": 474
},
"pagination_token": "D01NBBoTERtIfVdDbApBE0UeUh9bQFUfXitCNVtX…"
},
"duration": "1.97",
"statusCode": 200,
"creditsUsed": 5
}
Send pagination_token back exactly as you received it; it is long (over 600 characters in our test), so don't truncate it in a database column. Comment pages are small, 17 comments in our test, so comments are the most expensive Instagram data per item: a post with 474 comments takes roughly 28 requests, or 140 credits. Decide whether you need every comment or just the first few pages.
Search Instagram users, hashtags and places
Four search endpoints take a keyword. Three return one list each (users, hashtags or places), and /search/global returns all three for the same 5 credits, so it is usually the one to call.
// {"endpoint": "/search/global", "params": {"keyword": "sourdough"}}
{
"success": true,
"data": {
"status": "ok",
"query": "sourdough",
"users": [
{ "position": 1, "user": { "id": "1558634137", "username": "sofdoh.official", "full_name": "Soft Sourdough", "is_verified": false, … } },
{ "position": 2, "user": { "username": "the_sourdoughhoe", "is_verified": true, … } },
… 41 more
],
"hashtags": [
{ "position": 0, "hashtag": { "id": "17843824786061363", "name": "sourdough", "media_count": 9222479 } },
{ "position": 19, "hashtag": { "name": "sourdoughbaking", "media_count": 1147921 } },
… 11 more
],
"places": []
},
"duration": "0.74",
"statusCode": 200,
"creditsUsed": 5
}
In that test, the global search returned 43 accounts, 13 hashtags and no places. position is a single ranking shared across the three lists, which is why the top hashtag sits at 0 between accounts 1 and 2. Hashtag results include media_count, a quick way to size a niche. Search returns one page; there is no cursor to follow.
Convert a shortcode to a media ID, and back
Post URLs use a shortcode (Dd9gYJnDCKl); the API's objects and the comments endpoint also use the numeric media ID (3998494441249448613). They are the same number: the shortcode is the ID written in base 64 with the alphabet A–Z a–z 0–9 - _.
/media/shortcode_to_id and /media/id_to_shortcode do the conversion for 5 credits each. Doing it locally is free and instant:
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
def shortcode_to_id(shortcode):
media_id = 0
for ch in shortcode:
media_id = media_id * 64 + ALPHABET.index(ch)
return str(media_id)
def id_to_shortcode(media_id):
n = int(str(media_id).split("_")[0]) # "3976…_1054403238" -> "3976…"
code = ""
while n > 0:
n, r = divmod(n, 64)
code = ALPHABET[r] + code
return code or "A"
print(shortcode_to_id("Dd9gYJnDCKl")) # 3998494441249448613
print(id_to_shortcode("3998494441249448613")) # Dd9gYJnDCKl
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
function shortcodeToId(shortcode) {
let id = 0n;
for (const ch of shortcode) {
const index = ALPHABET.indexOf(ch);
if (index < 0) throw new Error(`invalid shortcode: ${shortcode}`);
id = id * 64n + BigInt(index);
}
return id.toString(); // keep it a string: media IDs are too big for a JS number
}
function idToShortcode(mediaId) {
let id = BigInt(String(mediaId).split("_")[0]); // "3976…_1054403238" -> "3976…"
let code = "";
while (id > 0n) {
code = ALPHABET[Number(id % 64n)] + code;
id /= 64n;
}
return code || "A";
}
console.log(shortcodeToId("Dd9gYJnDCKl")); // 3998494441249448613
console.log(idToShortcode("3998494441249448613")); // Dd9gYJnDCKl
Never parse media IDs as numbers in JavaScript
Instagram media IDs are larger than the biggest integer a JavaScript number holds exactly. Number("3998494441249448613") evaluates to 3998494441249448400, a different post. The API returns IDs as strings; keep them that way in JavaScript, JSON and your database (use a text or 64-bit integer column, not a float).
The three pagination styles, and one script for all of them
Instagram's endpoints don't agree on how to ask for the next page. This table covers the feed and comment endpoints:
| Endpoints | Items are in | Keep going while | Send back as |
|---|---|---|---|
/medias/by_user_id, /medias/tagged_by_user_id | edges[].node | page_info.has_next_page is true | end_cursor = page_info.end_cursor |
/reels/by_user_id | items[].media | paging_info.more_available is true | max_id = paging_info.max_id |
/comments/media_comments_by_id | data.items | pagination_token is present | pagination_token (end_cursor also accepted) |
Follower and following lists (/followers/by_user_id, /following/by_user_id) page with next_max_id, sent back as max_id while has_more is true. For very large accounts, Instagram lists only about 50 recent followers and no further pages; use query to search the whole list instead. The docs have the details.
The script below handles all three with one paginate function. It looks up the profile, skips private accounts, retries timeouts, 429 and 5xx with exponential backoff, de-duplicates on the media ID (stripping the owner suffix tagged posts carry), refuses to loop if a cursor stops moving, and caps the number of pages so a mistake can't drain your balance.
Complete Python script
"""Save an Instagram account's posts, reels or tagged posts, or a post's comments, as JSON Lines.
pip install requests
export SCRAPINGBOT_API_KEY=YOUR_API_KEY
python instagram_feed.py posts nasa > nasa-posts.jsonl
python instagram_feed.py reels nasa > nasa-reels.jsonl
python instagram_feed.py tagged nasa > nasa-tagged.jsonl
python instagram_feed.py comments Dd9gYJnDCKl > comments.jsonl
"""
import json
import os
import sys
import time
import requests
API_URL = "https://scrapingbot.io/api/v1/instagram"
API_KEY = os.environ.get("SCRAPINGBOT_API_KEY", "YOUR_API_KEY")
RETRY_STATUSES = {408, 429, 500, 502, 503, 504}
MAX_PAGES = 200 # 200 pages x 5 credits = at most 1,000 credits per run
def call(endpoint, params, attempts=5):
"""One API call. Failed calls are refunded, so retrying them is safe."""
error = None
for attempt in range(attempts):
try:
res = requests.post(
API_URL,
headers={"x-api-key": API_KEY},
json={"endpoint": endpoint, "params": params},
timeout=90,
)
body = res.json()
except (requests.RequestException, ValueError) as exc:
error = str(exc) # network error, timeout or a non-JSON answer
else:
if res.status_code == 200 and body.get("success"):
return body["data"]
error = f"HTTP {res.status_code}: {body.get('error')}"
if res.status_code not in RETRY_STATUSES:
raise RuntimeError(error) # 400, 401, 402, 404: fix the request instead
time.sleep(2 ** attempt)
raise RuntimeError(f"giving up after {attempts} attempts: {error}")
# endpoint: (token param, items in the page, next token or None when done)
STYLES = {
"/medias/by_user_id": (
"end_cursor",
lambda d: [edge["node"] for edge in d.get("edges") or []],
lambda d: d["page_info"]["end_cursor"] if d.get("page_info", {}).get("has_next_page") else None,
),
"/medias/tagged_by_user_id": (
"end_cursor",
lambda d: [edge["node"] for edge in d.get("edges") or []],
lambda d: d["page_info"]["end_cursor"] if d.get("page_info", {}).get("has_next_page") else None,
),
"/reels/by_user_id": (
"max_id",
lambda d: [item["media"] for item in d.get("items") or []],
lambda d: d["paging_info"].get("max_id") if d.get("paging_info", {}).get("more_available") else None,
),
"/comments/media_comments_by_id": (
"pagination_token",
lambda d: (d.get("data") or {}).get("items") or [],
lambda d: d.get("pagination_token") or None,
),
}
def paginate(endpoint, params):
"""Yield each item once, whichever of the three pagination styles the endpoint uses."""
token_param, items_of, next_of = STYLES[endpoint]
seen, token = set(), None
for page in range(1, MAX_PAGES + 1):
data = call(endpoint, params if token is None else {**params, token_param: token})
items = items_of(data)
for item in items:
key = str(item.get("id") or item.get("pk")).split("_")[0] # tagged ids look like "media_owner"
if key in seen:
continue
seen.add(key)
yield item
print(f"page {page}: {len(items)} items, {len(seen)} total", file=sys.stderr)
next_token = next_of(data)
if not next_token:
return
if next_token == token:
raise RuntimeError(f"{token_param} did not advance after page {page}")
token = next_token
print(f"stopped after MAX_PAGES={MAX_PAGES}", file=sys.stderr)
def user_id_for(username):
profile = call("/user/by_username", {"username": username})
if profile.get("is_private"):
raise SystemExit(f"@{username} is private; only public accounts can be read")
return profile["id"]
if __name__ == "__main__":
what = sys.argv[1] if len(sys.argv) > 1 else "posts"
target = sys.argv[2] if len(sys.argv) > 2 else "nasa"
if what == "comments":
items = paginate("/comments/media_comments_by_id", {"code_or_id_or_url": target})
else:
user_id = user_id_for(target)
if what == "reels":
items = paginate("/reels/by_user_id", {"user_id": user_id, "page_size": 50})
elif what == "tagged":
items = paginate("/medias/tagged_by_user_id", {"user_id": user_id, "count": 12})
else:
items = paginate("/medias/by_user_id", {"user_id": user_id, "count": 50})
for item in items:
print(json.dumps(item, ensure_ascii=False))
Complete Node.js script
The same logic for Node.js 18 or later, with no dependencies. Save it as instagram_feed.mjs.
// Save an Instagram account's posts, reels or tagged posts, or a post's comments, as JSON Lines. Node 18+.
//
// export SCRAPINGBOT_API_KEY=YOUR_API_KEY
// node instagram_feed.mjs posts nasa > nasa-posts.jsonl
// node instagram_feed.mjs reels nasa > nasa-reels.jsonl
// node instagram_feed.mjs tagged nasa > nasa-tagged.jsonl
// node instagram_feed.mjs comments Dd9gYJnDCKl > comments.jsonl
const API_URL = "https://scrapingbot.io/api/v1/instagram";
const API_KEY = process.env.SCRAPINGBOT_API_KEY ?? "YOUR_API_KEY";
const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
const MAX_PAGES = 200; // 200 pages x 5 credits = at most 1,000 credits per run
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// One API call. Failed calls are refunded, so retrying them is safe.
async function call(endpoint, params, attempts = 5) {
let error;
for (let attempt = 0; attempt < attempts; attempt++) {
let res, body;
try {
res = await fetch(API_URL, {
method: "POST",
headers: { "x-api-key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ endpoint, params }),
signal: AbortSignal.timeout(90_000),
});
body = await res.json();
} catch (err) {
error = err.message; // network error, timeout or a non-JSON answer
}
if (body) {
if (res.ok && body.success) return body.data;
error = `HTTP ${res.status}: ${body.error}`;
if (!RETRY_STATUSES.has(res.status)) throw new Error(error); // fix the request instead
}
await sleep(2 ** attempt * 1000);
}
throw new Error(`giving up after ${attempts} attempts: ${error}`);
}
const byCursor = {
param: "end_cursor",
items: (d) => (d.edges ?? []).map((edge) => edge.node),
next: (d) => (d.page_info?.has_next_page ? d.page_info.end_cursor : null),
};
const STYLES = {
"/medias/by_user_id": byCursor,
"/medias/tagged_by_user_id": byCursor,
"/reels/by_user_id": {
param: "max_id",
items: (d) => (d.items ?? []).map((item) => item.media),
next: (d) => (d.paging_info?.more_available ? d.paging_info.max_id : null),
},
"/comments/media_comments_by_id": {
param: "pagination_token",
items: (d) => d.data?.items ?? [],
next: (d) => d.pagination_token || null,
},
};
// Yield each item once, whichever of the three pagination styles the endpoint uses.
async function* paginate(endpoint, params) {
const style = STYLES[endpoint];
const seen = new Set();
let token = null;
for (let page = 1; page <= MAX_PAGES; page++) {
const data = await call(endpoint, token === null ? params : { ...params, [style.param]: token });
const items = style.items(data);
for (const item of items) {
const key = String(item.id ?? item.pk).split("_")[0]; // tagged ids look like "media_owner"
if (seen.has(key)) continue;
seen.add(key);
yield item;
}
console.error(`page ${page}: ${items.length} items, ${seen.size} total`);
const next = style.next(data);
if (!next) return;
if (next === token) throw new Error(`${style.param} did not advance after page ${page}`);
token = next;
}
console.error(`stopped after MAX_PAGES=${MAX_PAGES}`);
}
async function userIdFor(username) {
const profile = await call("/user/by_username", { username });
if (profile.is_private) throw new Error(`@${username} is private; only public accounts can be read`);
return profile.id;
}
const [what = "posts", target = "nasa"] = process.argv.slice(2);
let items;
if (what === "comments") {
items = paginate("/comments/media_comments_by_id", { code_or_id_or_url: target });
} else {
const user_id = await userIdFor(target);
if (what === "reels") items = paginate("/reels/by_user_id", { user_id, page_size: 50 });
else if (what === "tagged") items = paginate("/medias/tagged_by_user_id", { user_id, count: 12 });
else items = paginate("/medias/by_user_id", { user_id, count: 50 });
}
for await (const item of items) {
process.stdout.write(JSON.stringify(item) + "\n");
}
For daily monitoring, you rarely need the whole feed: fetch the first page of posts and stop at the first non-pinned post you already have. That is 5 credits per account per day, plus 5 more if you also track follower counts.
Instagram field reference
These are the fields most projects keep. Media fields apply to posts, reels, tagged posts and single media. Responses contain many more fields; store the raw JSON too if you might need them later.
| Field | Where | Meaning |
|---|---|---|
id | Profile | Numeric account ID, as a string. Stable across renames; use it as the key. |
username, full_name, biography | Profile | Handle, display name and bio |
follower_count, following_count | Profile | Audience counts |
is_private, is_verified, is_business | Profile | Account flags. Private accounts have no readable feed. |
external_url, bio_links | Profile | Links in the bio |
id | Media | Media ID, as a string. Tagged posts add an _ownerid suffix. |
code | Media | Shortcode used in /p/ and /reel/ URLs |
taken_at | Media | Upload time in Unix seconds (missing on tagged posts) |
media_type | Media | 1 photo, 2 video, 8 carousel |
product_type | Media | feed, clips (reel) or carousel_container |
like_count, comment_count | Media | Engagement counts |
caption.text | Media | Caption, with hashtags and mentions |
image_versions2.candidates | Media | Image files in several sizes, each with width, height and url |
video_versions | Media | Video files for videos and reels; null on photos |
carousel_media, carousel_media_count | Media | The items of a carousel, each with its own versions |
timeline_pinned_user_ids | Media | Non-empty when the post is pinned to a profile |
owner / user | Media | The account that posted it |
id, text, created_at | Comment | Comment ID, text and time in Unix seconds |
comment_like_count, child_comment_count | Comment | Likes and number of replies |
user.username | Comment | Who wrote it |
Concurrency, retries and cost
Instagram requests cost 5 credits each, and only successful 200 responses are charged. Errors, timeouts and requests rejected before they run are free. Limits are on concurrent requests, not requests per minute; going over returns 429 immediately, at no cost.
| Plan | Price / month | Credits / month | Instagram requests | Concurrent | Per 1,000 requests |
|---|---|---|---|---|---|
| Free | $0 | 100 on sign-up | 20 | 1 | n/a |
| Starter | $49.99 | 275,000 | 55,000 | 10 | $0.91 |
| Startup | $99.99 | 1,100,000 | 220,000 | 50 | $0.45 |
| Pro | $249.99 | 3,300,000 | 660,000 | 100 | $0.38 |
| Business | $599.99 | 9,000,000 | 1,800,000 | 200 | $0.33 |
| Business+ | $999.99 | 15,500,000 | 3,100,000 | 200 | $0.32 |
What common jobs cost:
| Job | Requests | Credits |
|---|---|---|
| Profiles for 1,000 accounts | 1,000 | 5,000 |
| Every post from an account with 1,000 posts | 1 profile + about 31 pages at ~33 posts | about 160 |
| All comments on a post with 474 comments | about 28 pages at ~17 | about 140 |
| Daily new-post check on 500 accounts, for 30 days | 500 × 30 | 75,000 |
| The same, plus daily follower counts | 500 × 30 × 2 | 150,000 |
| 1,000 shortcode conversions | 0 if done locally | 0 (or 5,000 through the API) |
To spend those credits well:
- Parallelize across accounts. Each page needs the previous page's cursor, so one feed is sequential. Run one account per worker, with as many workers as your plan allows.
- Cache profiles. Bios and follower counts don't change by the minute. Once a day is enough for most tracking.
- Ask for the maximum page size. 50 posts or reels per request cost the same as 12.
- Retry 408, 429 and 5xx with backoff. They are refunded, so a retry never pays twice. Don't retry 400 or 404 without changing the request.
- Set a client timeout above 45 seconds. That is the API's own limit for Instagram; the scripts use 90.
The credits docs and the concurrency docs have the full rules, and the error reference lists every status.
Legal and ethical notes
This is practical guidance, not legal advice.
- Public data only. Private accounts' posts, reels and comments are out of reach by design.
- Know the terms. Read Instagram's Terms of Use and how they apply to your project. Using an API doesn't change what you may do with the data.
- Profiles and comments are personal data. If you store them, privacy laws such as GDPR and CCPA may apply. Collect what you need, keep it only as long as you need it, and secure it.
- Photos and videos belong to their creators. Analyzing engagement is not the same as republishing someone's work. Download media only for uses you have rights to.
- Don't build tools for harm. No harassment, stalking, scraping for spam lists or attempts to unmask private accounts.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
400 "Missing required parameter: user_id" | A username sent to a feed endpoint | Look up the profile first and pass its id |
400 "Tagged posts support a maximum count of 12" | count above 12 on tagged posts | Use 12 and paginate |
500 "Failed to process Instagram request" | An endpoint that doesn't exist, such as highlights | Use one of the 20 endpoints; not charged |
404 | The account or post doesn't exist, was renamed or deleted | Check the input; not charged |
| Feed request fails on a real account | The account is private | Check is_private on the profile first |
| Wrong post when looking up an ID in JavaScript | The ID was converted to a number | Keep IDs as strings |
| Duplicate posts | Reels appear in both feeds, or a post repeats across pages | Primary key on the media ID, without the _owner suffix |
| Media URL stops working | The signed URL expired | Fetch the post again for fresh URLs |
Instagram scraping FAQ
Is it legal to scrape Instagram?
Collecting publicly available Instagram data is common, and it carries less risk than anything that needs a login, but the answer depends on your jurisdiction, what you collect and what you do with it. Stick to public data, read Instagram's Terms of Use, treat profiles and comments as personal data under laws such as GDPR and CCPA, and don't republish photos or videos you have no rights to. If the stakes are high, ask a lawyer about your specific use.
Can I scrape Instagram without logging in?
Yes. Your ScrapingBot API key is the only credential. You don't need an Instagram account, cookies or session tokens, and nothing you do touches an account of yours. Only public data is available.
How do I get all posts from an Instagram account?
Look up the profile with /user/by_username to get its numeric id, then call /medias/by_user_id with that user_id. Send page_info.end_cursor back as end_cursor while page_info.has_next_page is true, and de-duplicate on the media id.
Why does Instagram return fewer posts than the count I asked for?
Instagram decides the final page size. In our tests, asking for 50 posts returned 33 per page. A smaller page is normal; keep paging until has_next_page is false.
How do I convert an Instagram shortcode to a media ID?
A shortcode is the media ID written in base 64 with the alphabet A-Z, a-z, 0-9, - and _. You can call /media/shortcode_to_id or /media/id_to_shortcode for 5 credits, or convert locally for free with the short functions in this guide.
Can I scrape Instagram stories or highlights?
Stories, yes: /stories/by_username returns an account's active stories. Highlights are not supported. The API also covers profiles, followers and following, posts, reels, tagged posts, single posts or reels, comments and replies, search, and shortcode and ID conversion.
Can I scrape private Instagram accounts?
No. A private profile still returns its basic profile with is_private set to true, but its posts, reels and comments are not available. Check is_private before you spend credits on a feed.
How much does Instagram scraping cost?
Every Instagram request costs 5 credits, and only successful requests are charged. New accounts get 100 free credits, enough for 20 requests. On the Starter plan, $49.99 buys 275,000 credits a month, which is 55,000 Instagram requests, or about $0.91 per 1,000 requests.
Next steps
Start with a profile lookup and one page of posts to see the shapes, then run the script on a small account before you scale up. The Instagram API page has more examples, and the search reference and comments reference list every parameter. If you work with AI agents, the MCP server exposes Instagram profiles, media and search as tools.
Collecting TikTok data as well? The TikTok scraping API guide covers videos, full creator feeds, sounds and comments with the same kind of scripts. For sites without a dedicated API, the guide to proxy types explains which proxies get you through.