Scraper · Instagram

Instagram

Read public Instagram data over the same async API as the rest of ScrapeGuys: the posts of a public profile, one page at a time, and one post's full record with a page of its comments. Everything is read the way a logged-out visitor sees it, so no Instagram account or credentials are involved on your side.

POST/api/v1/social-scraper/instagram/profile-posts/— submit: one page of a profile's posts
GET/api/v1/social-scraper/instagram/profile-posts/{job_id}/— poll
POST/api/v1/social-scraper/instagram/post/— submit: one post, with a page of comments
GET/api/v1/social-scraper/instagram/post/{job_id}/— poll

Base URL: https://api.scrapeguys.com · Auth: X-API-Key: gsk_… with the instagram:profile_posts and/or instagram:post scope · See the Overview.

The Instagram scopes are not on any key by default. instagram:profile_posts and instagram:post are separate scopes; neither implies the other, and no hotel scope implies either. Contact us to have them added to your key. A call outside your key's scopes returns 403 with {"detail": "This API key does not have the 'instagram:profile_posts' scope."}.
Paginated by cursor, one page per job. Every job answers one page — 12 posts, or about 14 comments — and the cursor of the next page (next_cursor / next_comments_cursor), null on the last page. Send that cursor back in a new job to get the next page. There is nothing to size: one job, one page, one unit of quota. See Cursors and truncation.
Public data only. Only what a logged-out visitor can see is reachable, and Instagram meters that per visitor: after some number of pages it may answer with a login wall or a rate limit. The API tells you when that happened (truncated_reason) so you can retry the same cursor in a later job. See Access limits.

Profile posts

One page of a public profile's posts, newest first: 12 posts and the cursor of the next page.

FieldTypeRequiredDefaultNotes
usernamestringRequired—The profile. @name, name or https://www.instagram.com/name/ are all accepted and normalised to lower-case name.
cursorstringOptionalnullThe next_cursor of the previous page's answer. Omit or send null for the first page (the newest posts). Max 2,000 chars.

A pages field is refused with 400: the API pages by cursor, one page per job.

POST /api/v1/social-scraper/instagram/profile-posts/
curl -X POST https://api.scrapeguys.com/api/v1/social-scraper/instagram/profile-posts/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"username": "natgeo", "cursor": null}'
202 Accepted
{ "job_id": "3f7c1a2b-...", "status": "pending", "username": "natgeo", "poll_url": "/api/v1/social-scraper/instagram/profile-posts/3f7c1a2b-.../" }
How long it takes. About 2 seconds a page. The first page of a profile is two requests (the profile block, then the page); every later page, asked with a cursor, reuses the profile's cached id and is one request.

Errors — 400 (a malformed username, a pages count, an over-long cursor, or the Instagram scraper not currently enabled), 401 (missing or invalid key), 403 (key lacks the scope), 429 (rate limit).

Poll for results

GET /api/v1/social-scraper/instagram/profile-posts/{job_id}/ — only the client that submitted a job can read it; any other key gets 404. Polling is free.

200 OK (abridged)
{ "id": "3f7c1a2b-...", "status": "completed", "username": "natgeo", "cursor": "", "complete": true, "posts_fetched": 12, "result": { "profile": { "id": "787132", "username": "natgeo", "full_name": "National Geographic", "is_private": false, "is_verified": true, "followers": 280000000, "following": 130, "post_count": 30000, "biography": "...", "profile_pic_url": "https://..." }, "posts": [ { "shortcode": "DeuOR-CjKE9", "id": "4012207125530714429", "url": "https://www.instagram.com/p/DeuOR-CjKE9/", "type": "carousel", "caption": "...", "taken_at": "2026-10-20T16:06:22Z", "like_count": null, "comment_count": null, "display_url": "https://...", "video_url": null, "is_pinned": null, "location": null, "tagged_users": [], "children": [], "accessibility_caption": "Photo of a boat on a fjord", "product_type": "carousel_container", "owner": {"id": "17841400573960012", "username": "natgeo"} } ], "next_cursor": "QVFC...", "has_more": true, "truncated_reason": null, "error": null, "error_detail": null }, "error_message": "", "duration_seconds": 3.4, "bandwidth_kb": 21.0 }
statusMeaning
pending / processingNot finished — poll again.
completedAn answer. complete says whether the page you asked for was served.
failedNo answer. error_message starts with an error code; result.error carries it alone.
A profile page is Instagram's grid, not each post's full record. Filled: shortcode, id, url, type (image, video, carousel), caption, display_url, accessibility_caption, product_type (feed, clips for a reel, carousel_container), owner and taken_at, which is derived from the post id (accurate to within seconds). Always null here: like_count, comment_count, video_url, is_pinned, location; tagged_users and children are empty. Use the post endpoint below for all of them (one request per post).

Post with comments

One post's full record, plus one page of its comments (about 14, in Instagram's own "popular" order) and the cursor of the next comment page.

FieldTypeRequiredDefaultNotes
poststringRequired—A shortcode (CrgT1jPISz5) or an instagram.com /p/, /reel/ or /tv/ URL.
usernamestringOptionalnullThe owner you expect. If the post belongs to someone else the job fails with owner_mismatch and no comments are fetched.
include_commentsboolOptionaltruefalse fetches the post's metadata only (one request).
comments_cursorstringOptionalnullThe next_comments_cursor of the previous page's answer. Omit or send null for the first page. Max 2,000 chars.

A comment_pages field is refused with 400: the API pages by cursor, one page per job.

POST /api/v1/social-scraper/instagram/post/
curl -X POST https://api.scrapeguys.com/api/v1/social-scraper/instagram/post/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"post": "https://www.instagram.com/p/CrgT1jPISz5/", "username": "natgeo", "comments_cursor": null}'
202 Accepted
{ "job_id": "9b1e4c7d-...", "status": "pending", "shortcode": "CrgT1jPISz5", "poll_url": "/api/v1/social-scraper/instagram/post/9b1e4c7d-.../" }

Poll for results

GET /api/v1/social-scraper/instagram/post/{job_id}/

200 OK (abridged)
{ "id": "9b1e4c7d-...", "status": "completed", "shortcode": "CrgT1jPISz5", "post_input": "https://www.instagram.com/p/CrgT1jPISz5/", "username": "natgeo", "include_comments": true, "comments_cursor": "", "complete": true, "comments_fetched": 14, "result": { "post": { "shortcode": "CrgT1jPISz5", "id": "3089556587226934521", "url": "https://www.instagram.com/p/CrgT1jPISz5/", "type": "video", "caption": "...", "taken_at": "2023-11-14T22:13:20Z", "like_count": 4242, "comment_count": 100, "display_url": "https://...", "video_url": "https://...", "is_pinned": false, "location": null, "tagged_users": [], "children": [], "owner": {"id": "787132", "username": "natgeo", "full_name": "National Geographic", "is_verified": true}, "is_paid_partnership": false, "coauthors": [], "accessibility_caption": "Photo of a lion", "play_count": null, "width": 1074, "height": 1908, "has_audio": true, "video_duration": null, "view_count": null, "comments_disabled": null, "like_and_view_counts_disabled": false, "repost_count": null, "carousel_media_count": null, "top_likers": [], "sponsor_tags": [], "audio": {"type": "original_sounds", "title": "Original audio", "artist": null, "username": "natgeo"} }, "comments": [ { "id": "18000000000000003", "text": "Stunning", "created_at": "2023-11-15T01:00:00Z", "like_count": 12, "owner": {"id": "500", "username": "commenter"}, "reply_count": 1, "replies_preview": [ {"id": "19000000000000030", "text": "Agreed", "created_at": "2023-11-15T01:05:00Z", "like_count": 0, "owner": {"id": "9", "username": "replier"}, "reply_count": 0} ] } ], "comments_total": 184, "next_comments_cursor": "{\"is_server_cursor_inverse\":true,\"server_cursor\":\"AQHT...\"}", "comments_has_more": true, "comments_truncated": false, "truncated_reason": null, "error": null, "error_detail": null }, "error_message": "", "duration_seconds": 3.1, "bandwidth_kb": 61.0 }

post is the full record: every grid field filled, plus owner.full_name / is_verified, is_paid_partnership, coauthors (usernames), accessibility_caption, play_count on a video — and the rest of what Instagram's record carries:

FieldWhat it holds
width, heightThe original media dimensions in pixels; each carousel child carries its own.
has_audioWhether a video has a sound track (null on a photo).
video_durationSeconds, when Instagram sends it to a logged-out visitor (null on every reel measured so far).
view_countVideo views as a separate figure from play_count; null when Instagram withholds it.
comments_disabledThe owner turned comments off (null when Instagram does not say).
like_and_view_counts_disabledThe owner hides like and view counts — when true, expect like_count null.
repost_countHow often the post was reposted, when sent.
carousel_media_countNumber of children on a carousel.
top_likersUsernames Instagram shows as "liked by …".
sponsor_tagsUsernames of the brands a paid partnership tags.
audioA reel's track: {type, title, artist, username} — original_sounds with the creator's username, or licensed_music with the song and artist; null on anything that is not a reel.
A null in this group means Instagram did not carry the value for a logged-out visitor, not that it is zero. Each carousel child also carries width, height, has_audio and video_duration.

comments_total is the post's own comment count. replies_preview holds the first replies to a comment when Instagram sends them (same shape, without replies_preview); reply_count is the full number. Comments come in Instagram's own ("popular") order, about 14 a page; a comment cursor is a JSON string Instagram hands back, passed through verbatim. The metadata and the comment page are separate: comments_truncated true with a truncated_reason means the post's record is in post but the comment page was not served — the job still completes.

Errors — as for profile posts, plus owner_mismatch and post_not_found on the job itself.

Cursors and truncation

Both endpoints page with opaque cursors, one page per job. Every answer carries the cursor of the next page in next_cursor / next_comments_cursor; on the last page it is null and has_more / comments_has_more is false. To get the next page, submit a new job with the cursor — each page is its own session on its own network exit, which is also how you continue after a wall. Cursors are Instagram's and they expire; one that no longer works comes back as login_required with nothing fetched — start again without a cursor.

Read complete first. It answers one question: did you get the page you asked for? When it is false (or null on a failed job), result.truncated_reason says why.

truncated_reasoncompleteMeaningWhat to do
nulltrueThe page was served. next_cursor is the next page, or null on the last.Continue with the cursor, or stop.
login_requiredfalseInstagram answered a login wall (or a checkpoint) in front of this page.Retry later with the same cursor.
rate_limitedfalseInstagram answered HTTP 429.Retry later with the same cursor.
privatefalseThe profile is private; anonymous access sees no posts.None; needs the owner's approval.
errorfalseSomething other than a wall: every network exit failed, or Instagram answered an error. error_detail says what.Retry with the same cursor.

Within one job a wall is already retried, on a fresh network exit and browser identity, up to twice before the job stops — so login_required means three different exits were walled in a row on that page. A wall in front of a profile page leaves nothing to return, so that job is failed with login_required or rate_limited; it is still one unit.

Error codes

error_message on a failed job starts with one of these; result.error carries it alone and result.error_detail the explanation.

CodeMeaning
profile_not_found / post_not_foundInstagram knows no such username / shortcode.
owner_mismatchThe post belongs to another account than the username you sent.
login_required / rate_limitedWalled before anything was fetched.
proxy_unavailableNo network exit is configured for Instagram on the server; the job did not run.
proxy_faultEvery network exit failed at the transport level.
instagram_errorAn unexpected answer from Instagram (a 5xx, a malformed body).
instagram_disabledThe feature was switched off while the job was queued.
crashAn internal error. The job is still billed and reported.

Rate limits and quota

One job = one page = one request unit, and so is a job that ends failed. Limits are shared with the rest of the API, per client — see the Overview. A submit that is refused with 403 or 400 is never charged; only an accepted job (202) is. Instagram jobs count toward the same GET /usage/ totals as other jobs and are not itemised by product there. Each job reports its bandwidth_kb.
LimitValue
Posts per page12 (Instagram's size)
Comments per pageAbout 14 (Instagram's size; top-level comments only, replies as a preview)
Pages per job1 — page with the cursor
Cursor length2,000 characters
Pagination depth measured100 pages (1,200 posts) on one profile without a wall, 24 Oct 2026
Wall retries within a job2, each on a fresh exit and identity
CachePost metadata 1 h, first comment page 15 min, profile id 24 h — repeating a request inside that window is cheap

Access limits — read before integrating

  1. Logged-out view only. Private profiles return no posts. Instagram meters anonymous reads and walls them after a budget it does not publish; a job that meets a wall tells you so and keeps its cursor, so plan to page with cursors and retry a walled page later.
  2. Counts and dates per post need the post endpoint. The profile grid carries no like or comment counts, no video URL and no tags; taken_at is derived from the media id.
  3. Counts are as a visitor sees them. like_count is null where likes are hidden; comments_total may count replies differently from comment_count.
  4. Media URLs expire. display_url and video_url are signed CDN links valid for a limited time. Download what you need on receipt.
  5. Instagram changes its web client without notice. A job that lands during a change may come back login_required with nothing fetched; retry later.

Example: Python

python
import time, requests BASE = "https://api.scrapeguys.com/api/v1/social-scraper/" H = {"X-API-Key": "gsk_your_api_key"} MAX_RETRIES = 3 def run(path, body): for attempt in range(MAX_RETRIES + 1): resp = requests.post(BASE + path, json=body, headers=H) if resp.status_code != 429: break detail = resp.json().get("detail", "") if "quota" in detail or attempt == MAX_RETRIES: # a spent daily/monthly quota does not free within the minute raise SystemExit(detail) time.sleep(60) # the per-minute limit (10/min by default): a slot frees within the minute resp.raise_for_status() # any other refusal (400, 401, 403) carries no poll_url job = resp.json() while True: r = requests.get("https://api.scrapeguys.com" + job["poll_url"], headers=H).json() if r["status"] in ("completed", "failed"): return r time.sleep(2) RETRY_LATER = ("login_required", "rate_limited", "error", "proxy_fault", "instagram_error") # a fresh exit may serve the same page later def page(path, body): """One served page. A walled page (a failed profile job, or a completed post job whose comment page was not served) is asked for again after a pause, at most MAX_RETRIES times; anything else that stops the page - private, not_found, owner_mismatch, an expired cursor that keeps walling - raises.""" for attempt in range(MAX_RETRIES + 1): r = run(path, body) reason = r["result"].get("error") or r["result"].get("truncated_reason") if reason is None: return r if reason not in RETRY_LATER or attempt == MAX_RETRIES: raise SystemExit(r["error_message"] or reason) time.sleep(600) cursor, posts = None, [] while True: # one page per job, until the last page r = page("instagram/profile-posts/", {"username": "natgeo", "cursor": cursor}) posts += r["result"]["posts"] cursor = r["result"]["next_cursor"] if cursor is None or len(posts) >= 120: # the last page carries no cursor break if not posts: raise SystemExit("the profile has no posts") comments, ccursor = [], None while True: r = page("instagram/post/", {"post": posts[0]["url"], "username": "natgeo", "comments_cursor": ccursor}) comments += r["result"]["comments"] ccursor = r["result"]["next_comments_cursor"] if ccursor is None or len(comments) >= 100: # the last page carries no cursor break print(r["result"]["post"]["caption"], len(comments), "comments")

Looking for the product overview? See Instagram Scraper API.