InstagramAnswer

Instagram API get user posts: Meta's 10K cap and a script

Meta lists your own posts, 10K at most, and others only via Business Discovery. Ours paged three accounts to the end; it can miss reels. Python CSV.

Updated 10 min read

Short answer

  • Meta's API reads your own posts with its media edge, which "Returns a maximum of 10K of the most recently created media", and only for Business and Creator accounts. Another account's posts come only through Business Discovery, again Business or Creator accounts only (Meta's pages, read 2026-10-16).
  • ToolzerHub's /v1/instagram/user/posts reads a public account by username, up to 50 posts per call. On 2026-10-16 it paged SpaceX to the end: 1,087 posts, equal to its profile count, in 22 calls. MrBeast gave 502 of 520. We stopped NASA (4,967 posts) at 2,000 after 40 calls with more available. The posts list can miss reels: 396 of MrBeast's 480 reels were not in it. Unknown usernames return 503, not an empty result.
  • 1 credit per call, so 1,000 posts are 20 credits: $0.02 to $0.04. 100 free credits a month. The script below writes any list of usernames to CSV with a page cap.
On this page

Instagram's official API gets posts only from Business and Creator accounts: your own through the media edge, which returns at most the 10,000 most recent, and other accounts through Business Discovery. It does not read personal accounts. We read Meta's documentation on 2026-10-16. ToolzerHub reads a public account's posts by username. We paged three accounts to the end, one exactly and two 12 to 18 short of their profile count, and the posts list can omit reels (see the reels section).

What Meta returns for your own posts

The call is GET /17841405822304914/media, Meta's sample ID, on graph.facebook.com (Facebook Login) or graph.instagram.com (Instagram Login). Meta's reference (updated September 28, 2026) lists two limits: "Returns a maximum of 10K of the most recently created media." and "Story IG Media not supported", pointing to the separate stories endpoint instead. Meta's Instagram Login get-started page (updated December 2, 2024) describes the same call as returning "the IDs of all the IG Media objects on the IG User"; the reference page is the more recent of the two. We did not test either. The edge takes since and until for time ranges, which is how you ask for recent posts.

It works for professional accounts only. Meta's IG Media reference (updated August 12, 2026): "This API returns only data for media owned by Instagram professional accounts. It can not be used to get data for media owned by personal Instagram accounts." For why the personal-account APIs ended, see our Basic Display replacement post.

Meta's pages give no default or maximum page size for this edge. The Graph API paging page says only that "Some edges may also have a maximum on the limit value for performance reasons."

Field What Meta says
comments_count Public
like_count In Business Discovery's sample
view_count Reels; "Available for Business Discovery API only."
media_product_type FEED, REELS, STORY or AD; Facebook Login only
saved_count Owner or accepted collaborator

The IG User reference lists no reels edge. A reel is a media object with media_product_type REELS.

What Meta returns for other accounts' posts

Business Discovery is the only official route to another account's posts. You call it on your own professional account's ID, with the other account's username: GET /17841405309211844?fields=business_discovery.username(bluebottle){media{like_count,comments_count,view_count}}, Meta's sample. The target must be a Business or Creator account, age-gated accounts return nothing, and Meta documents it for Facebook Login only. Our Basic Display API post covers its fields; this post is about paging a whole posts list.

Its media edge pages with before and after cursors only: Meta says the response "will not include previous or next fields", so you build the next URL yourself. A direct GET on a returned media object fails with insufficient permissions. Meta's Business Discovery pages state no cap on this edge.

Get a public account's posts with ToolzerHub

We make ToolzerHub. Our Instagram API reads a public account's posts from a username, with one API key and no Meta app, Facebook Page or professional account of your own. GET /v1/instagram/user/posts takes username_or_url (or user_id), count up to 50 and a cursor, and costs 1 credit per call (our OpenAPI file, 2026-10-16). It returns more_available and next_max_id, which you pass back as cursor.

Each post has code, taken_at, product_type, like_count, comment_count and caption. view_count was null on the NASA reel we checked, while /v1/instagram/user/reels returned play_count 9,955,101 for the same post, the plays shown on the owner's Reels tab. This script pages every username you give it to one CSV.

python
import csv
import os
import sys
import time
from datetime import datetime, timezone

import requests

URL = "https://api.toolzerhub.com/v1/instagram/user/posts"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
FIELDS = ["username", "code", "posted", "type", "likes", "comments", "caption", "url"]
calls = 0
FAILED = object()  # marks a 400 or a 503 that outlasted the retries, so the run can tell it from "empty"


def cell(value):
    # Spreadsheets run text that starts with = + - or @ as a formula; an apostrophe keeps it text.
    if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
        return "'" + value
    return value


def get(params, tries=3):
    # 429 and 503 are retryable; the wait comes from the error body or the Retry-After header.
    global calls
    for attempt in range(tries):
        r = requests.get(URL, headers=HEADERS, params=params, timeout=90)
        calls += 1
        if r.status_code == 400:  # bad username: skip this account, keep the rest of the run
            print("bad request:", r.json()["error"]["message"].splitlines()[0])
            return FAILED
        if r.status_code not in (429, 503) or attempt == tries - 1:
            if r.status_code == 503:  # an unknown username also ends here
                return FAILED
            if r.status_code >= 400:
                sys.exit(f"HTTP {r.status_code}: {r.text[:200]}")
            return r.json().get("data")  # None when there is nothing to return
        try:
            wait = r.json()["error"]["details"]["retryAfter"]
        except (ValueError, KeyError, TypeError):
            wait = None
        try:
            wait = int(wait or r.headers.get("Retry-After") or 5 * (attempt + 1))
        except ValueError:
            wait = 5 * (attempt + 1)
        time.sleep(min(wait, 60))


def export_account(writer, name, max_pages):
    params, seen, pages, more, failed = {"username_or_url": name, "count": 50}, set(), 0, False, False
    while pages < max_pages:
        data = get(params)
        if data is FAILED:
            failed = True
            break
        if not data or not data.get("items"):
            break  # null data, a private account or no posts
        pages += 1
        for m in data["items"]:
            if m["code"] in seen:  # pages can repeat a post
                continue
            seen.add(m["code"])
            writer.writerow({
                "username": cell(name), "code": m["code"],
                "posted": datetime.fromtimestamp(m["taken_at"], timezone.utc).isoformat(timespec="minutes"),
                "type": m.get("product_type"), "likes": m.get("like_count"),
                "comments": m.get("comment_count"),
                "caption": cell(((m.get("caption") or {}).get("text") or "").replace("\n", " ")),
                "url": f"https://www.instagram.com/p/{m['code']}/",
            })
        more = bool(data.get("more_available") and data.get("next_max_id"))
        if not more:
            break
        params["cursor"] = data["next_max_id"]
        time.sleep(0.3)
    if failed:
        status = " (gave up: bad request or 503 after retries; unknown usernames end up here)"
    elif not pages:
        status = " (nothing returned: private or empty)"
    else:
        status = " (stopped at the page cap, more available)" if more else " (reached the end)"
    print(f"{name}: {len(seen)} posts, {pages} pages{status}")
    return len(seen)


def main(accounts, max_pages="30", out="posts.csv"):
    total = 0
    with open(out, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        for name in [a.strip().lstrip("@") for a in accounts.split(",") if a.strip()]:
            total += export_account(writer, name, int(max_pages))
    print(f"wrote {total} posts to {out} in {calls} calls")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit("usage: python instagram_posts.py nasa,spacex [max_pages] [file.csv]")
    if not HEADERS["x-api-key"]:
        sys.exit("set TOOLZERHUB_API_KEY first")
    main(*sys.argv[1:])

The script strips spaces and a leading @, skips empty items, de-duplicates by code, and reports whether each account ended or hit your page cap. An unknown username returns 503, not an empty result, so the script retries it twice (about 20 seconds) before skipping it; a 400 is skipped at once. A 429 that outlasts the retries stops the run. We tested the retry branch against a stand-in server, not live. cell() puts an apostrophe in front of text that starts with =, +, - or @, so a spreadsheet shows it as text.

Run the script

bash
export TOOLZERHUB_API_KEY=your_key
python instagram_posts.py "spacex, @mrbeast ,nobody_here_qq9zz,,bad handle" 30 posts.csv

Output on 2026-10-16, with a stray space, an @, an empty item and two bad names added to test the handling:

text
spacex: 1087 posts, 22 pages (reached the end)
mrbeast: 502 posts, 11 pages (reached the end)
nobody_here_qq9zz: 0 posts, 0 pages (gave up: bad request or 503 after retries; unknown usernames end up here)
bad handle: 0 posts, 0 pages (gave up: bad request or 503 after retries; unknown usernames end up here)
wrote 1589 posts to posts.csv in 39 calls

It took 232 seconds. clips are reels. The first three MrBeast rows are older than the fourth, probably pinned posts, so sort by posted for newest first.

How deep the list goes

We compared each list with the account's post count from /v3/instagram/user/details (media_count, 3 credits). /v1/instagram/user/info has no post count. All numbers are from 2026-10-16.

Account Profile count Rows read Calls End
SpaceX 1,087 1,087 22 reached
MrBeast 520 502 11 reached
Taylor Swift 713 701 15 reached
NASA 4,967 2,000 40 not reached

"Reached" means more_available turned false. SpaceX matched its profile count exactly, back to 2015-06-21. MrBeast ended 18 short and Taylor Swift 12 short, so expect a small gap; we don't know why. NASA's 40 calls gave 2,000 rows, 1,999 unique, back to 2020-08-28, with more_available still true, so we can't say where its list stops or whether it stops. One post repeated on page 30, which is why the script de-duplicates.

Reels

/v1/instagram/user/reels takes the same username and returns 12 reels per call. count=50 is accepted, but the page stayed 12 in our test. MrBeast's reels list gave 480 reels in 40 calls and was still going; paged to the end on 2026-10-17 it held 705. His posts list had 502 posts, 211 of them reels, and 396 of the 480 reels were not in it. We did not find out why. Eleven of the 480 belong to other accounts, which looks like collaborative reels. Our Instagram reels API post covers the reels list.

Where Meta's API is the better pick

  • Your own account or your customers' accounts, with their consent. Meta does not return saved_count, shares_count or total_views_count through Business Discovery. Our posts route returns none of them either, and its view_count is null; use the reels route's play_count.
  • Time ranges. Meta's media edge takes since and until; ours takes a cursor only.
  • Documented behaviour. Meta states its cap, 10K; we know only what we measured, up to 2,000 on NASA.
  • Reliability on bad input. On 2026-10-16 an unknown username on our posts route returned HTTP 503 ("Upstream returned 502", retryable true) on all 15 tries across three names; /v1/instagram/user/info returns {"data": null} for it. In the 7 days before, the posts route had 59 successes and 2 server errors, and reels 11 and 2 (61 and 13 calls, some of them our own tests).

We returned public data only and did not test private accounts.

What it costs

Each call is 1 credit. SpaceX's 1,087 posts took 22 credits, MrBeast's 502 took 11: 33 credits for the two, 3 to 6 cents at our rates of $0.99 to $1.90 per 1,000 credits ($1.45 on the $29 plan). Failed calls cost 0. At 50 posts per call, 1,000 posts are 20 calls: 20 credits, $0.02 to $0.04. At 12 reels per call, 1,000 reels are 84 calls: 84 credits, $0.08 to $0.16. The free tier gives 100 credits a month, enough for about 5,000 posts.

For comments on one post, see how to export Instagram comments.

We are not affiliated with Meta Platforms, Inc. or Instagram.

Sources

Get this data from your own code

The Instagram API returns it as JSON with one x-api-key header. 100 free credits monthly.