Skip to content

ChocoData-com/instagram-post-scraper

Repository files navigation

Instagram Post Scraper

Instagram Post Scraper

Instagram Post Scraper for extracting captions, hashtags, mentions, image and video URLs, comment counts and view counts from Instagram.com. This repo has a free Instagram post scraping script you can run right now, and an Instagram post data API returning 27 fields of real structured JSON per post, reel or carousel.

This repo reads public brand, business and creator posts only, and collects no personal data. Every capture, example and screenshot on this page targets an organisation account (nike, bloombergbusiness). This endpoint returns a comment count, never comment bodies or the people who wrote them, and there is nothing here that reads a private account, a follower list, or a tagged user. More on that in Scope.

Last updated: 2026-07-21. Working against Instagram.com as of July 2026, and re-verified whenever Instagram changes their markup.

This repo covers one endpoint in depth. For Instagram profiles and the other surfaces, see the Instagram Scraper repo.

Every JSON block on this page was captured from the live API, the post objects on 2026-07-21 and the error, latency and free-scraper runs on 2026-07-20. Long strings are truncated where marked, and each block says exactly what was cut; every field shown is verbatim. Full uncut samples are committed in instagram_post_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from instagram_post_scraper_api_codes/.

pip install requests
export CHOCODATA_API_KEY="your_key"     # free: 1,000 requests, one-time, no card
python instagram_post_scraper_api_codes/post.py https://www.instagram.com/p/CfwXgNprcSA/

Those three lines return this, live from Instagram.com (8 of the 27 fields, verbatim; the full object is below):

{
  "shortcode": "CfwXgNprcSA",
  "url": "https://www.instagram.com/p/CfwXgNprcSA/",
  "media_type": "carousel",
  "is_video": false,
  "author": "nike",
  "author_url": "https://www.instagram.com/nike/",
  "comments": 814,
  "caption": "Meet the bold new collection inspired by a 50-year tradition of breaking tradition. This is Circa 72. ..."
}

...for any public post, reel or carousel you point it at, with 27 fields each:

Retrieved Instagram post data

That is the whole point of this repo. The rest of this page is the free path, the two ways Instagram hands you an empty page instead, and the endpoint that returns the post as JSON.


Contents


Scope: public posts only

Instagram is mostly personal data, and that shapes what this repo is willing to be.

What it does: read one public post, reel or carousel by its permalink. Caption, hashtags, mentions, media type, image and video URLs, dimensions, comment count, view count, and the handle that published it.

What it does not do: anything that turns a person into a row. comments is an integer, not a list of comments, so no comment authors and no comment text. No follower lists, no tagged users, no contact details, no private accounts, no individual profiling.

The demand for the other thing is real and easy to see. Google's own autocomplete for this topic offers "instagram post comment scraper", "instagram private post scraper" and "scrape all comments from instagram post". None of that is here. The endpoint reads what Instagram already renders into a public embed, for posts published by organisations that want to be found.

Both posts used throughout this page are brand-owned: a Nike carousel and a Bloomberg Business reel. Their captions are corporate copy, and both returned an empty mentions array, so no individual is named anywhere in the committed samples.


Free Instagram Post Scraper

Instagram server-renders a public post into its embed route:

https://www.instagram.com/p/<shortcode>/embed/captioned/

That page carries the media object as an escaped JSON string, plus a visible caption block. Both can be read without a headless browser, JavaScript rendering, or a login. No key, no cost:

python free_scraper/instagram_post_free_scraper.py CfwXgNprcSA

Source: free_scraper/instagram_post_free_scraper.py. It anchors on the shortcode_media object rather than searching the whole document, which matters more than it sounds: a carousel embeds its children in the same blob, so a document-wide search for a key like is_video finds a child's value and reports the wrong thing for the post itself.

After running the command, your terminal should look something like this:

Free Instagram post scraper output

That is a real run, and it worked: HTTP 200, 152,998 characters, and the post parsed out of it. The next section is the part that decides whether your run looks like that one.

Avoid getting blocked when scraping Instagram posts

The same post, from the same machine, in the same minute, fetched three ways. Reproduce it with --compare:

python free_scraper/instagram_post_free_scraper.py CfwXgNprcSA --compare

The same Instagram post fetched three ways

Two of those three requests returned HTTP 200, a full-size page, and no post in it. Every fetch behind this section is recorded in free_scraper_runs.json.

What bites you Why What it costs you
A browser User-Agent returns less, not more Carrying the default python-requests User-Agent, the embed route returned 152,995 characters including the media object. The identical request carrying a Chrome User-Agent returned 597,186 characters and no media object. Same result on both posts, on 2026-07-20. The first thing every scraping tutorial tells you to do is the thing that empties your payload. You will spend the afternoon adding more headers, which is the wrong direction.
The bare permalink is not the post /p/<shortcode>/ returned 597,154 and 597,155 characters on the two posts, with no media object. /p/<shortcode>/embed/captioned/ returned it. You scrape the URL a human pasted you and get a shell. The fix is a different route, not a better parser.
HTTP 200 is not success All six comparison fetches returned 200 with <title>Instagram</title>, whether or not the post was in them. No redirect, no 403, no captcha page. The expensive failure is not a crash. It is a table of empty rows that looked like "this post has no caption".
It is intermittent 12 fetches of the embed route on 2026-07-20 returned the post 10 times and HTTP 500 with a zero-length body twice. The two 500s landed on the same post, minutes either side of successful fetches of that same post. A pipeline that assumes a stable source silently drops posts. Retry, and record which pass a row came from.

None of that is a parsing problem, which is why a better parser does not fix it: on the empty responses the media object is already missing before your code runs.


Using the Chocodata Instagram Post Scraper API

The managed option, and the one this repo is built around. The Chocodata Instagram Post Scraper API returns the post as 27 parsed fields from a single GET, at a ~99% success rate, with no proxy management. It takes a /p/, /reel/ or /tv/ permalink or a bare shortcode interchangeably, so you can hand it whatever form your data arrived in. Free for the first 1,000 requests.


Instagram Post Scraper API reference

Below is the Instagram Post Scraper API reference to get you started: authentication, the parameters, error handling, concurrency, and the endpoint itself.

Quickstart

curl "https://api.chocodata.com/api/v1/instagram/post?api_key=YOUR_KEY&url=https://www.instagram.com/p/CfwXgNprcSA/"
import requests

r = requests.get(
    "https://api.chocodata.com/api/v1/instagram/post",
    params={"api_key": "YOUR_KEY", "shortcode": "CfwXgNprcSA"},
    timeout=90,
)
print(r.json()["author"], r.json()["media_type"], r.json()["comments"])
# nike carousel 814

After running the command, your terminal should look something like this:

Running the Instagram Post Scraper API

Get a key at chocodata.com (1,000 requests, one-time, no card).

Authentication

Pass api_key as a query parameter on every request. That is the whole auth model. No OAuth, no Instagram login, no session cookie: you never hand us or Instagram an account.

Parameters

Param Type Required Default Description
api_key string yes - Your Chocodata API key.
url string one of - A post permalink. /p/, /reel/, /reels/ and /tv/ forms all work and all resolve to the same object.
shortcode string one of - The permalink slug on its own, e.g. CfwXgNprcSA.
id string one of - Alias for shortcode, kept for back-compat. Takes the same slug, not a numeric media id.
country string (ISO-2) no us Accepted for consistency with the rest of the API.
add_html boolean no false Also return the upstream HTML in an extra html key alongside the parsed JSON, for debugging a parse. It adds well over 100,000 characters to the response, so leave it off in production.

url, shortcode and id are three ways of naming the same post, and you need exactly one of them. All three returned the identical object for CfwXgNprcSA on 2026-07-20, as did /reel/DUNg75LElex/ and /p/DUNg75LElex/ for the reel; the four requests and the control are recorded in input_variants.json. Send none of them and you get a 400 naming the omission.

Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).

Errors

Nothing here is billed: you are only charged on a 2xx.

Status error code Meaning Billed What to do
400 invalid_params No url, shortcode or id was passed. no Fix the query string.
401 INVALID_API_KEY Key missing, unrecognised, or revoked. no Check api_key. Get one at chocodata.com.
402 INSUFFICIENT_CREDITS Balance exhausted. no Top up or upgrade at chocodata.com.
404 item_not_found The URL does not resolve to an Instagram post. A non-Instagram URL lands here. no Check the permalink.
429 RATE_LIMITED Over your plan's concurrency. no Back off and retry; see Rate limits.
502 extraction_failed Instagram did not render the post on this attempt. A well-formed shortcode with no post behind it lands here too, as does a post that has been deleted. no Retry once after a few seconds. If it persists, treat the shortcode as gone.

The split between 404 and 502 is worth knowing before you write your retry logic. A URL that is not an Instagram post at all (we used https://example.com as a control) is rejected up front with 404 item_not_found, so the endpoint will not fetch an arbitrary domain and hand it back under this schema. A shortcode that is shaped like a real one but has nothing behind it (ZZZZZZZZZZZ) cannot be told apart from a post Instagram declined to render, so both come back 502. Retry the 502; do not retry the 404.

The scripts in this repo map every status in that table onto an actionable message, so a typo'd key does not hand you a stack trace:

Instagram Post Scraper API error handling

A bad key, verbatim:

curl "https://api.chocodata.com/api/v1/instagram/post?api_key=totally_invalid_key_123&shortcode=CfwXgNprcSA"
{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}

A request with a valid key but no url, shortcode or id, verbatim. Note the shape difference: auth and billing errors nest under error.code in uppercase, while scrape-layer errors are flat with a lowercase error string.

{
  "error": "invalid_params",
  "issues": [
    {
      "code": "custom",
      "message": "url or shortcode is required",
      "path": []
    }
  ]
}

Both bodies are committed in errors.json, along with the status and code recorded for the 404 and 502 cases.

Rate limits and concurrency

Two separate ceilings apply, and they are easy to confuse:

  • Rate: 120 requests per 60 seconds per key, enforced hard. Stay under roughly 1.5 requests per second and you will not meet it.
  • Concurrency: how many requests you may have in flight at once, which is what your plan sets.
Plan Concurrent requests
Free 10
Vibe 30
Pro 50
Custom 100 to 500+

Exceed either and you get 429, not a queue. The endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. The examples use timeout=90 because a request that has to be re-attempted upstream takes longer than the median (see Measured latency).

Fan out with a thread pool sized to your plan, and keep the pool small enough that it also respects the per-minute rate:

from concurrent.futures import ThreadPoolExecutor
import requests

def one(shortcode):
    r = requests.get("https://api.chocodata.com/api/v1/instagram/post",
                     params={"api_key": KEY, "shortcode": shortcode}, timeout=90)
    return shortcode, (r.json().get("comments") if r.ok else None)

posts = ["CfwXgNprcSA", "DUNg75LElex"]
with ThreadPoolExecutor(max_workers=10) as pool:   # <= your plan's concurrency
    for shortcode, comments in pool.map(one, posts):
        print(shortcode, comments)

Post: captions, media URLs, comment counts and view counts

One public Instagram post, reel or carousel: identity, author, caption with its hashtags and mentions, every image and video URL, dimensions, and the engagement counters Instagram renders into the embed.

Param Type Required Default Description
url string one of - The post permalink, in any of its /p/, /reel/ or /tv/ forms.
shortcode string one of - The permalink slug on its own.
id string one of - Alias for shortcode.
curl "https://api.chocodata.com/api/v1/instagram/post?api_key=YOUR_KEY&url=https://www.instagram.com/p/CfwXgNprcSA/"

Real response. The object is complete, all 27 fields verbatim. Only three values are shortened where marked: caption and title (525 characters each, identical to one another) and the images array, cut from 47 entries to 2 (full sample):

{
  "id": "CfwXgNprcSA",
  "shortcode": "CfwXgNprcSA",
  "media_id": "2877903530640655488",
  "url": "https://www.instagram.com/p/CfwXgNprcSA/",
  "embed_url": "https://www.instagram.com/p/CfwXgNprcSA/embed/captioned/",
  "media_type": "carousel",
  "is_video": false,
  "product_type": null,
  "title": "Meet the bold new collection inspired by a 50-year tradition of breaking tradition. This is Circa 72. ...",
  "author": "nike",
  "author_name": null,
  "author_id": "13460080",
  "author_url": "https://www.instagram.com/nike/",
  "images": [
    "https://scontent-lax3-2.cdninstagram.com/v/t51.71878-15/622642725_4413514895549401_5115656771937871029_n.jpg?stp=...",
    "https://scontent-lax3-2.cdninstagram.com/v/t51.71878-15/622642725_4413514895549401_5115656771937871029_n.jpg?stp=..."
  ],
  "thumbnail": "https://scontent-lax3-2.cdninstagram.com/v/t51.71878-15/622642725_4413514895549401_5115656771937871029_n.jpg?stp=...",
  "video_url": null,
  "dimensions": {
    "width": 720,
    "height": 900
  },
  "caption": "Meet the bold new collection inspired by a 50-year tradition of breaking tradition. This is Circa 72. ...",
  "hashtags": [],
  "mentions": [],
  "likes": 230833,
  "comments": 814,
  "plays": null,
  "taken_at": null,
  "location": null,
  "source": "instagram",
  "data_source": "embedded"
}

images is the field most people misread. Look at the two entries above: same filename, different query string. They are one photo at two CDN sizes, not two photos. The array holds 47 entries for that carousel and that is not 47 photos, it is 4 distinct media files at roughly a dozen size variants each; the next new file does not appear until index 11. The reel is the same shape, 13 entries for 1 file. De-duplicate on the filename before you count anything:

files = {u.split("?")[0].rsplit("/", 1)[-1] for u in post["images"]}
len(files)     # 4 for the carousel, 1 for the reel

A reel returns the same 27 fields with a different subset populated. product_type reads clips, video_url is a direct MP4, and plays carries the view count that is null on a still post (full sample):

{
  "shortcode": "DUNg75LElex",
  "url": "https://www.instagram.com/p/DUNg75LElex/",
  "media_type": "reel",
  "is_video": true,
  "product_type": "clips",
  "author": "bloombergbusiness",
  "video_url": "https://scontent-sjc6-1.cdninstagram.com/o1/v/t2/f2/m367/AQM5kIpC6-5kfIu00leQ...",
  "dimensions": {
    "width": 1079,
    "height": 1919
  },
  "comments": 28,
  "plays": 24238
}

That block is 10 of the 27 fields, each verbatim; the other 17 are in the committed sample.

Running it:

Instagram Post Scraper API reel output

Field notes:

  1. comments is a count, not the comments. It is the integer Instagram renders next to the post. There is no comment text and no commenter identity in this payload, by design (see Scope).
  2. likes is the exact like count, not a rounded one: the carousel returned 230833 and the reel 1708, captured 2026-07-21. It can still arrive null, because Instagram intermittently serves a caption-only render with no engagement block, so read it as "a number when the post rendered fully" rather than a guaranteed field.
  3. plays is populated on video, null on stills. The reel returned 24238; the carousel returned null. Use is_video or media_type to decide whether the null is meaningful.
  4. taken_at and location were null on both posts sampled. The embed does not carry a timestamp, so you cannot date a post from this endpoint; record your own observation time instead, which is what campaign_report.py does.
  5. title is a copy of caption. Identical on both posts, to the character. Use caption, and treat title as a convenience alias rather than a separate field worth storing.
  6. author_name was null on both; author is the reliable identity. The handle and the numeric author_id came back on both posts, the display name on neither. If you are storing rows over time, author_id is the more specific of the two identifiers.
  7. hashtags and mentions are parsed out of the caption, and both were empty on the two posts sampled, because neither caption contains a # or an @. They populate from caption text, so a post whose tags live in the first comment rather than the caption will return empty arrays here.
  8. url always normalises to the /p/ form, whatever you sent. Request /reel/DUNg75LElex/ and url comes back as /p/DUNg75LElex/. Both address the same post; pick one form for your keys and stay with it.
  9. media_type is derived, is_video is not always what you would guess. A carousel that contains video children still reports media_type: "carousel" and is_video: false, because both describe the post, not its children.

Runnable: instagram_post_scraper_api_codes/post.py


Track engagement on a set of brand posts

Watching a set of posts and how their engagement moves is the main reason to scrape Instagram posts, so that use case is in the repo end to end rather than as a snippet. campaign_report.py takes permalinks or shortcodes, fans out with a thread pool, appends every observation to a CSV, and prints the batch ranked by comment count:

python instagram_post_scraper_api_codes/campaign_report.py CfwXgNprcSA DUNg75LElex

Instagram post engagement report output

That is a real run. One API request per post per pass, so the arithmetic is simple: a 25-post list checked daily is 25 requests a day, and the 1,000 free requests cover about 40 days of that, one-time.

A non-200 skips that post and the batch continues, because 502 is retryable and one deleted post should not kill a run. The CSV is appended rather than overwritten, so running it on a schedule gives you an engagement time series per post without any further work. Since taken_at is null on this endpoint, the observed_at column the script writes is what dates your rows.


Measured latency

Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-20, 7 runs against each of the two posts. This includes the upstream fetch, the anti-bot handling, and the parse:

Endpoint Median Range n
/instagram/post 2.5s 1.3 to 4.5s 14

All 14 returned 200. Read the range, not just the median: the slowest call took about three and a half times the fastest, and a request that has to be re-attempted upstream lands at that end of the spread. Absorbing those attempts, silently, is a good part of what you are paying for. Small sample from one laptop on one afternoon, so size your timeouts off the tail rather than the median, and reproduce it with the scripts in this repo. Every run is recorded in latency.json.

Both sample posts returned 200 on every call we made on 2026-07-20. taken_at is null on this endpoint, so neither sample carries a publication date and we do not claim one. A post can be removed by its owner at any time: if either shortcode starts returning 502 on every retry, treat it as deleted rather than blocked, and swap in another public brand post.


License

MIT. See LICENSE.