# Instagram Profile and Posts Scraper, Full History, No Login (`scrapegeist/instagram-profile-posts-scraper`) Actor

Scrape public Instagram profiles with their full post history, not only the latest 12 posts. Profile stats, bio links, every post with caption and timestamp, optional likes, comments and views. No login, no cookies, no browser. Never billed for private or missing accounts or duplicates.

- **URL**: https://apify.com/scrapegeist/instagram-profile-posts-scraper.md
- **Developed by:** [Scrapegeist](https://apify.com/scrapegeist) (community)
- **Categories:** Social media, Lead generation
- **Stats:** 9 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 profile scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Instagram Profile and Posts Scraper, Full History, No Login

> Scrape public Instagram profiles with their full post history, not only the latest 12 posts. One profile row per account with stats and bio links, one row per post with caption, type, thumbnail and timestamp, and likes, comments and views when you ask for them. Hashtag pages, location pages and single post URLs work too. No login, no cookies, no browser.

![Posts table of a run over organisation accounts at detail level: thumbnail, account, timestamp, type, caption, likes, comments](https://api.apify.com/v2/key-value-stores/AGUDYcJLNDmUeIjNd/records/instagram-posts-view.png)

***

### What you get in 30 seconds

Press **Start** with the prefilled input. It asks for two organisation accounts and nothing else:

```json
{
  "usernames": ["natgeo", "https://www.instagram.com/sfmoma/"]
}
```

The run gives you, per account:

- **one profile row**: username, full name, biography, bio links, follower and following count, verified and private flags, profile picture, pronouns and highlight titles. Set `detailSample` above `0` and it also carries an engagement rate computed from a sample of the newest posts;
- **up to 100 post rows** (`maxPostsPerProfile`, raise it to 5,000): caption, media type, product type, thumbnail, timestamp, post URL and the account that owns it. The first 12 posts are included in the profile price.

Every row carries `scraped_at` or a `taken_at` you can sort by, and `discoveredVia`, which says what brought the row into the dataset (`input`, `profile:natgeo`, `hashtag:museum`, `location:212988663`).

The dataset has four views in the Apify Console: **Profiles**, **Posts**, **Locations** and **Skipped**. Export any of them as JSON, CSV, Excel or XML.

### Input kinds, with examples

You can mix all five in one run. Each has its own cap.

| Field | What you paste | What comes back |
|---|---|---|
| `usernames` | `natgeo`, `@sfmoma`, `https://www.instagram.com/britishmuseum/` | One profile row plus its post history |
| `postUrls` | `https://www.instagram.com/p/DdG4RIxIPyf/`, or the bare shortcode `DdG4RIxIPyf` | One detail row per post |
| `hashtags` | `museum`, `#architecture` | About 60 post rows per tag (see Limits) |
| `locations` | `212988663` (New York), `213385402` (London), or `/explore/locations/213385402/` | One location row plus about 70 post rows |
| `searchQueries` | any keyword | A skipped row. Keyword search needs a login (see Limits) |

```json
{
  "usernames": ["natgeo", "sfmoma", "britishmuseum"],
  "postUrls": ["https://www.instagram.com/p/DdG4RIxIPyf/"],
  "hashtags": ["museum"],
  "locations": ["213385402"],
  "maxPostsPerProfile": 200,
  "postDetail": "list",
  "detailSample": 3
}
```

Handles that do not exist and invalid entries do not stop the run. They become free `skipped` rows with a `reason` you can filter on.

A **private account** is the one case that is not free: Instagram publishes its profile card to anyone, so you get a normal profile row with the name, follower and following count, biography and bio links, charged as a profile row, plus a free `skipped` row with the reason `private_no_posts`. The posts are not public, so none are delivered and the engagement fields stay `null`.

### Sample output

The run's Output tab has one table per row type (Posts, Profiles, Locations, Skipped). Apify tables cannot filter rows, so every table lists every row, and a row of another type shows `undefined` in the columns it does not have. Download the JSON or CSV and filter on `type` for a clean per-type file.

Four real rows, from a run on organisation accounts. Long CDN URLs are shortened here with `...`; the dataset carries them in full.

#### Profile row

```json
{
  "type": "profile",
  "username": "natgeo",
  "pk": "787132",
  "url": "https://www.instagram.com/natgeo/",
  "full_name": "National Geographic",
  "biography": "Step into wonder and find your inner explorer with National Geographic 🌎",
  "bio_links": [
    { "url": "http://visitstore.bio/natgeo", "title": null },
    { "url": "https://ngmdomsubs.nationalgeographic.com/servlet/OrdersGateway?cds_mag_code=NGM", "title": "Subscribe Here!" }
  ],
  "external_url": "http://visitstore.bio/natgeo",
  "follower_count": 268532277,
  "following_count": 194,
  "post_count": null,
  "is_verified": true,
  "is_private": false,
  "profile_pic_url": "https://scontent.cdninstagram.com/v/t51.82787-19/683576066_...jpg",
  "pronouns": [],
  "highlights": ["Jaguar vs. Croc"],
  "engagement_rate": 0.0003,
  "avg_likes": 70596,
  "avg_comments": 408,
  "avg_video_views": 768614,
  "sample_size": 3,
  "scraped_at": "2026-09-18T09:12:44.000Z",
  "discoveredVia": "input"
}
```

#### Post row, `list` level (the default)

```json
{
  "type": "post",
  "pk": "3983118388705688735",
  "shortcode": "DdG4RIxIPyf",
  "url": "https://www.instagram.com/p/DdG4RIxIPyf/",
  "owner_username": "natgeo",
  "owner_pk": "787132",
  "media_type": "video",
  "product_type": "clips",
  "caption": "Presented by @Rolex. Welcome to Africa 🌍 Africa Earth's Wild Home takes you deep into the heart of our wildest continent ...",
  "thumbnail_url": "https://scontent-tpe5-1.cdninstagram.com/v/t51.82787-15/802503979_...jpg",
  "taken_at": "2026-09-10T12:52:15.035Z",
  "detail_level": "list",
  "discoveredVia": "profile:natgeo"
}
```

#### Post row, `engagement` level

Likes, comment count and the view count of a video, at about 55 KB per post instead of the 88 KB of a full detail row.

```json
{
  "type": "post",
  "pk": "3947535193527528320",
  "shortcode": "DbIdlf0j6uA",
  "url": "https://www.instagram.com/p/DbIdlf0j6uA/",
  "owner_username": "natgeo",
  "owner_pk": "787132",
  "media_type": "video",
  "product_type": "clips",
  "caption": "Describe Pompeii: Out of Time in 3 words? It's Now Streaming.",
  "thumbnail_url": "https://scontent-tpe5-1.cdninstagram.com/v/t51.82787-15/752808040_...jpg",
  "taken_at": "2026-07-23T10:34:47.618Z",
  "detail_level": "engagement",
  "discoveredVia": "profile:natgeo",
  "like_count": 70603,
  "comment_count": 408,
  "view_count": 768770,
  "taken_at_exact": null,
  "video_url": "https://instagram.fkhh1-2.fna.fbcdn.net/o1/v/t2/f2/m86/...mp4",
  "usertags": [],
  "location": null,
  "co_authors": [],
  "comments": [],
  "like_and_view_counts_disabled": false
}
```

#### Post row, `detail` level

Everything the post page itself carries: exact timestamp, usertags, co-authors, location and the visible comments. The post page publishes no view count, so `view_count` is `null` here. The one exception is a post URL run with `minViews`, where the embed page is read first and its view count is carried over into the detail row.

```json
{
  "type": "post",
  "pk": "3983118388705688735",
  "shortcode": "DdG4RIxIPyf",
  "url": "https://www.instagram.com/p/DdG4RIxIPyf/",
  "owner_username": "natgeo",
  "owner_pk": "787132",
  "media_type": "video",
  "product_type": "clips",
  "caption": "Presented by @Rolex. Welcome to Africa 🌍 ...",
  "thumbnail_url": "https://scontent-tpe5-1.cdninstagram.com/v/t51.82787-15/802503979_...jpg",
  "taken_at": "2026-09-10T12:52:15.035Z",
  "detail_level": "detail",
  "discoveredVia": "input",
  "like_count": 70358,
  "comment_count": 408,
  "view_count": null,
  "taken_at_exact": "2026-09-10T13:00:14.000Z",
  "video_url": "https://scontent-tpe5-1.cdninstagram.com/o1/v/t2/f2/m86/...mp4",
  "usertags": ["rolex"],
  "location": null,
  "co_authors": ["rolex"],
  "comments": [
    { "pk": "18126357995498209", "username": "...", "text": "...", "created_at": "2026-09-17T13:43:16.000Z", "like_count": 0 }
  ],
  "like_and_view_counts_disabled": false
}
```

A location input adds a fifth shape, the **location row**:

```json
{
  "type": "location",
  "id": "212988663",
  "name": "New York, New York",
  "lat": 40.714,
  "lng": -74.005,
  "nearby": [{ "id": "378453", "name": "The Metropolitan Museum of Art", "lat": 40.779, "lng": -73.963 }],
  "discoveredVia": "input"
}
```

And a **skipped row**, for anything that could not be delivered:

```json
{ "type": "skipped", "input": "sfmoma_does_not_exist", "reason": "not_found", "message": "profile: null_user" }
```

`reason` is one of `not_found`, `private_no_posts`, `login_wall`, `rate_limited`, `identity_mismatch`, `invalid_input`, `not_available_logged_out`, `error`.

### What it costs

This actor bills per row (pay per event). The current prices are in the pricing box at the top of this page. There is **no actor start fee**: a run that delivers nothing costs nothing. Compute and the residential proxy are included in the row prices and are never billed on top.

| Row type | Charge event | What it covers |
|---|---|---|
| Profile row | `profile-scraped` | The profile row **and the first 12 posts of that account**, delivered as free list rows. The `detailSample` posts among those 12 are the exception: they are fetched for the engagement rate and charged at the level they are fetched at. `detailSample` is `0` by default, so nothing extra is charged unless you ask for it. |
| Timeline post row, `list` level | `post-scraped` | A post from a profile timeline beyond the first 12. Caption, type, product type, thumbnail, timestamp, post URL and the account that owns it. |
| Hashtag or location row, `list` level | `grid-post-scraped` | The same fields, off a hashtag or a location grid, plus whatever counts that grid printed for free: a view count on hashtag rows, likes, comment count and the exact timestamp on location rows. Its own event, because one grid page delivers many rows and costs less per row than a timeline page. |
| Post row, `engagement` level | `post-engagement-scraped` | A post with likes, comment count and the view count. From a timeline, a hashtag or location grid, or `postUrls`. |
| Post row, `detail` level | `post-detail-scraped` | A post with likes, comment count, exact timestamp, video URL, usertags, location and the visible comments. From a timeline, a hashtag or location grid, or `postUrls`. |
| Location row | `location-scraped` | The place with its coordinates, plus the other places its posts are tagged at. Priced like a profile row. |

**Charged by the level you asked for and by where the row came from, never by what arrived.** A timeline `list` row is `post-scraped`, a hashtag or location `list` row is `grid-post-scraped`, an `engagement` row is `post-engagement-scraped` and a `detail` row is `post-detail-scraped`. With the default `postDetail: list`, hashtag and location rows stay list rows at the grid price, even though their grid hands over likes, the comment count, the exact timestamp or a view count for free. A number the page volunteered does not raise your bill. Ask for `postDetail: detail` and those rows become detail rows, with the detail price.

#### What you are never charged for

- **Skipped rows.** Every `skipped` row is free, whatever the reason.
- **Duplicates.** A post is delivered once per run and billed once, even when two hashtags and a profile all contain it.
- **Accounts that do not exist**, and every other invalid input.
- **Login walls and rate limits.** If Instagram gates a request, you get a skipped row, not a bill. A place whose grids were all gated still gives you its location row, free, next to that skipped row.
- **Rows dropped by `minViews`.** The fetch that read the view count is our cost, not yours.
- **Empty runs.** No rows, no charge, and no start fee.
- **The first 12 posts of every profile.** They come with the profile row, as list rows. `detailSample` is `0` by default, so none of them costs you anything extra. Raise it and the N newest of those posts are fetched for the engagement rate and charged at the level they are fetched at.
- **Comments.** They are part of a detail row and are never billed separately.
- **Any row after your charge limit is reached.** When Apify reports that the limit is hit, the run stops fetching and hands you the page-1 posts the profile row already paid for, as free list rows.

`maxTotalPosts` is a hard ceiling on billed post rows: the four post events together. It is checked before each fetch, not after the row lands, so the number you type is the most post rows you can be charged for in that run. If your Apify charge limit is lower, the actor lowers `maxTotalPosts` to match it and says so in the log.

### Honest limits

Everything below was measured on 2026-09-18 from a logged-out reader. This actor does not log in, so these are the real edges of the public surface, not settings you can raise.

**Hashtag pages give about 60 posts per tag.** That is where Instagram's public tag page ends: it keeps paging until it reports that there is no next page, which happened after 60 posts for `tedx` and 64 for `museum`. A higher `maxResultsPerHashtag` gives you no more. The logged-out tag grid also contains **reels only, no photo posts**: all 172 posts measured across two tags were videos. For photo posts from an account, scrape the account itself through `usernames`.

**Location pages give about 70 posts per place.** The location page does not page. Its grid answers "there is more" and then serves an empty page for the cursor, so the actor reads the two grids the page itself has (the ranked one and the newest one) and stops there. Measured: 69 posts for New York (`212988663`) and 72 for The Metropolitan Museum of Art (`378453`), with almost no overlap between the two grids. A location id nobody uses comes back as a skipped row with `not_found`.

**View counts exist for videos and reels only.** Instagram publishes no view count for an image or a carousel post to a logged-out reader, on any route. Those posts have `view_count: null` and never pass `minViews`. The `view_count` field holds the number Instagram itself publishes: the **play count** for a reel and for every hashtag row, the **video view count** for a feed video read from its embed page. Both live in one field, so a comparison across the two kinds compares two metrics.

**A detail row carries up to about 14 comments.** The post page prints them inline and says there is a next page, but every pagination variable is dropped and the same 14 come back (measured on a post with 408 comments). `comment_count` is the real total, so you always see how many a post has.

**Keyword search needs a login.** Use `hashtags` and `locations` for discovery. A keyword in `searchQueries` comes back as a skipped row with the reason `not_available_logged_out` and is never billed.

**Stories and highlight contents need a login.** Highlight **titles** are on the profile row. The story routes answer a login page, and the highlights query answers an empty list to a logged-out reader, so there is nothing to deliver.

**Not served to a logged-out reader at all**, so not returned: business contact fields (email, phone, WhatsApp, category), follower and following lists, and the total post count (`post_count` is `null`).

**Hidden counts come back as `null`, never `0`.** When an account hides its like and view counts, the row carries `like_and_view_counts_disabled: true` and null counts, so you can tell "hidden" from "zero".

### How to

#### Get the full post history, not 12 posts

Set `maxPostsPerProfile` to how far back you want to go, up to 5,000. The actor pages the profile timeline 12 posts at a time and stops at your number, at `maxTotalPosts`, or when the account runs out of posts.

```json
{ "usernames": ["sfmoma"], "maxPostsPerProfile": 2000 }
```

#### Get an engagement rate per profile

`detailSample` is `0` by default, so the sample is off and the engagement fields stay `null`. Set it and the N newest posts of each account are fetched at a level that carries counts, which writes `engagement_rate`, `avg_likes`, `avg_comments`, `avg_video_views` and `sample_size` onto the profile row. The rate is `(avg_likes + avg_comments) / follower_count`, rounded to four decimals, and is `null` when the sample has no counts or the follower count is hidden. Each sampled post is charged as a post engagement or a post detail row, so a sample of 3 is what most runs need.

#### Only posts since a date

```json
{ "usernames": ["britishmuseum"], "since": "2026-01-01" }
```

Profile pagination stops once the posts are older than the date. Hashtag and location grids are ranked, not sorted by date, so there `since` drops the older posts out of the grid and the actor keeps reading the rest.

#### Only videos above a view threshold

```json
{ "usernames": ["natgeo"], "minViews": 500000 }
```

A match from a profile is delivered as an `engagement` row (the embed page is the only source of its view count). A match from a hashtag grid stays at the level you asked for, because the grid prints the count. A match from a location grid is an `engagement` row when the grid printed no view count for it. A match from `postUrls` is a `detail` row, because a post URL is always fetched at detail level. Posts below the threshold are not delivered and not billed. Image and carousel posts have no view count anywhere, so they are dropped without a request. On a hashtag page the view count is already in the grid, so the filter there costs nothing extra.

#### Scrape hashtags

```json
{ "hashtags": ["museum", "architecture"], "maxResultsPerHashtag": 60 }
```

Rows arrive with `discoveredVia: "hashtag:museum"` and a `view_count` the grid printed, at the plain list price. Add `"postDetail": "detail"` to fetch each post on top for likes, the exact timestamp, usertags, the location and the comments, at the detail price.

#### Scrape locations

```json
{ "locations": ["212988663", "213385402"] }
```

You get one location row per place, with its coordinates and a `nearby` list, and about 70 post rows that already carry likes, the comment count and the exact timestamp. They stay `list` rows at the list price: the grid printed those counts for free, so they do not raise the bill. The exception is `minViews`, which buys an embed per video and delivers those as engagement rows.

**`nearby` is not a nearby-places list.** Instagram publishes no such list to a logged-out reader. `nearby` holds the other places that the posts on this page are tagged at, with their coordinates, which is the only place data the public page exposes. A city page therefore lists many of them, a single venue usually none, because its posts all name the venue itself.

To find a location id, open the place on instagram.com and read it out of the URL: `instagram.com/explore/locations/213385402/london-united-kingdom/`.

#### Use your own proxy

The default is Apify Proxy with the `RESIDENTIAL` group, which needs no setup. Instagram gates datacenter IPs on the post routes, so residential is required. Your own proxy URLs are accepted in the proxy editor.

```json
{ "proxyConfiguration": { "useApifyProxy": false, "proxyUrls": ["http://user:pass@my-proxy:8000"] } }
```

### API and integrations

#### Python

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")

run = client.actor("scrapegeist/instagram-profile-posts-scraper").call(run_input={
    "usernames": ["natgeo", "sfmoma"],
    "maxPostsPerProfile": 100,
    "detailSample": 3,
})

for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    if item["type"] == "profile":
        print(item["username"], item["follower_count"], item["engagement_rate"])
```

#### JavaScript

```js
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });

const run = await client.actor('scrapegeist/instagram-profile-posts-scraper').call({
  usernames: ['natgeo', 'sfmoma'],
  maxPostsPerProfile: 100,
  detailSample: 3,
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
const posts = items.filter((i) => i.type === 'post');
console.log(`${posts.length} posts`);
```

#### cURL

```bash
curl -X POST "https://api.apify.com/v2/acts/scrapegeist~instagram-profile-posts-scraper/run-sync-get-dataset-items?token=<YOUR_APIFY_TOKEN>" \
  -H 'Content-Type: application/json' \
  -d '{"usernames":["natgeo"],"maxPostsPerProfile":60,"postDetail":"list"}'
```

#### n8n, Make, Zapier and MCP

The actor is a normal Apify actor, so every Apify integration drives it without extra work. In **n8n** use the Apify node, pick this actor and paste the JSON input. In **Make** use the Apify "Run an Actor" module and map the dataset items into the next step. In **Zapier** use the Apify app with the "Run Actor" action. Through the **Apify MCP server** the actor is a callable tool, so an assistant can start a run and read the dataset back without any code. You can also let Apify push the dataset straight into Google Sheets, Airtable, Slack or a webhook after every run.

#### For AI agents

Call this actor when you need public Instagram profile or post data and you have no Instagram account to use.

- Actor id: `scrapegeist/instagram-profile-posts-scraper`.
- Minimal input: `{"usernames": ["natgeo"]}`. Nothing else is required.
- Output is a flat array of objects. Branch on the `type` field: `profile`, `post`, `location`, `skipped`. Do not assume all rows have the same shape.
- Post rows have a `detail_level` field: `list`, `engagement` or `detail`. Read `like_count`, `comment_count` and `view_count` only when `detail_level` is not `list`, or on hashtag and location rows.
- A `null` count means "not published", never "zero". Check `like_and_view_counts_disabled` before you report a zero.
- Never retry a `skipped` row with reason `not_found`, `private_no_posts` or `not_available_logged_out`. Those do not change on a second run. A `login_wall` or `rate_limited` row is worth one retry later.
- Bound spend with `maxTotalPosts` and `maxPostsPerProfile` before you start a run over a long list.

### Switching from the official Instagram actor

The field names differ. This map covers the ones people use most:

| Official actor | This actor | Note |
|---|---|---|
| `username` | `username` | Same |
| `fullName` | `full_name` | |
| `biography` | `biography` | Same |
| `followersCount` | `follower_count` | |
| `followsCount` | `following_count` | |
| `postsCount` | not available | Instagram does not serve the total to a logged-out reader. It is `post_count: null`. |
| `verified` | `is_verified` | |
| `private` | `is_private` | |
| `profilePicUrl` | `profile_pic_url` | |
| `externalUrl` | `external_url` | `bio_links` carries all of them, not only the first |
| `latestPosts[]` | separate post rows | One row per post in the same dataset, with `type: "post"` and `discoveredVia: "profile:<username>"`. Filter on that instead of reading a nested array, and you are no longer capped at 12. |
| `latestPosts[].caption` | `caption` | |
| `latestPosts[].likesCount` | `like_count` | Needs `postDetail` `engagement` or `detail`, or `detailSample` above `0` |
| `latestPosts[].commentsCount` | `comment_count` | Same |
| `latestPosts[].videoViewCount` | `view_count` | Videos and reels only |
| `latestPosts[].timestamp` | `taken_at`, `taken_at_exact` | `taken_at` is derived from the post id and is on every row. `taken_at_exact` is the value Instagram prints, on `detail` rows, on location rows, and on `engagement` rows where the grid printed it. |
| `latestPosts[].url` | `url` | |
| `latestPosts[].type` | `media_type` | `image`, `video`, `carousel` or `unknown` |

Three behaviour differences worth knowing before you migrate:

1. **The profile row and the posts are separate rows, not one nested document.** That keeps a run of 5,000 posts flat and exportable to CSV.
2. **Failures are rows, not gaps.** A handle that does not exist produces a `skipped` row you can join back to your input list.
3. **Nothing is guessed.** A value Instagram does not publish is `null`, never `0` and never an estimate.

### Input reference

| Field | Type | Default | What it does |
|---|---|---|---|
| `usernames` | string\[] | `["natgeo", "https://www.instagram.com/sfmoma/"]` (prefill) | Handles, `@handles` or profile URLs. One profile row per account plus its posts. |
| `hashtags` | string\[] | `[]` | Hashtags with or without `#`. About 60 posts per tag, reels only. |
| `locations` | string\[] | `[]` | Location ids or `/explore/locations/<id>/` URLs. One location row plus about 70 posts. |
| `searchQueries` | string\[] | `[]` | Keywords. Needs a login, so each one returns a free `skipped` row. |
| `postUrls` | string\[] | `[]` | Post or reel URLs or shortcodes. Each becomes a `detail` row, whatever `postDetail` says. With `minViews`, a view count is added from the embed page (videos and reels only). |
| `maxPostsPerProfile` | integer | `100` | 0 to 5,000. How far back to page each profile. `0` returns the profile row only. |
| `maxTotalPosts` | integer | `10000` | Hard cap on billed post rows across all inputs (the four post events together), checked before each fetch. |
| `maxResultsPerHashtag` | integer | `100` | Cap per hashtag and per location page. The public pages end before 100 anyway. |
| `postDetail` | enum | `list` | `list`, `engagement` or `detail`. See the level table below. |
| `detailSample` | integer | `0` | `0`, the default, disables the sample. Above `0`, and with `postDetail: list`, the N newest posts per profile are fetched for the engagement rate and charged at the level they are fetched at. |
| `since` | string (date) | none | ISO date, for example `2026-01-01`. Paging stops at posts older than this. |
| `minViews` | integer | none | Keep only posts with at least this many views. Videos and reels only. Dropped rows are not billed. |
| `includeComments` | boolean | `true` | Put the visible comments (about 14) on each `detail` row. Never billed separately. |
| `proxyConfiguration` | object | Apify Proxy, `RESIDENTIAL` | Residential is required for post pages. Your own proxy URLs are accepted. |
| `concurrency` | integer | `4` | 1 to 8 parallel sessions. Each keeps one proxy IP and one cookie jar. |

#### The three post detail levels

| Level | Extra request per post | Fields it adds | Billed as |
|---|---|---|---|
| `list` | none | Caption, media type, product type, thumbnail, `taken_at`, URL, owner | `post-scraped` from a timeline, `grid-post-scraped` off a hashtag or location grid |
| `engagement` | about 55 KB | `like_count`, `comment_count`, `view_count`, `video_url` | `post-engagement-scraped` |
| `detail` | about 88 KB | `like_count`, `comment_count`, `taken_at_exact`, `video_url`, `usertags`, `co_authors`, `location`, `comments`, `carousel_media`. **No view count.** | `post-detail-scraped` |

Use `engagement` when you need views, `detail` when you need comments, usertags or the location, and `list` when you need reach and speed.

### FAQ

**Do I need an Instagram login, cookies or a session id?**
No. The actor reads only what a logged-out visitor sees. There is nothing to configure and nothing of yours to put at risk.

**Why do I only get about 60 posts from a hashtag?**
Because that is where Instagram's public tag page ends. The actor pages until the page itself says there is no next page. Raising `maxResultsPerHashtag` changes nothing. The same holds for locations at about 70 posts.

**Can I get more than 14 comments on a post?**
No, not without a login. The post page prints about 14 and then drops every pagination attempt. `comment_count` still gives you the real total.

**Why is `view_count` null on an image post?**
Instagram publishes a view count for videos and reels only. There is no logged-out route that carries one for an image or a carousel post, so the field stays `null` rather than guessing.

**Why is `post_count` null?**
The total post count of an account is not served to a logged-out reader. The actor returns `null` instead of an estimate.

**Can I search Instagram by keyword?**
No. Keyword search needs a login. Use `hashtags` and `locations` for discovery.

**Can I get stories or highlight media?**
No. Both need a login. The profile row carries the highlight **titles**, which is everything the public surface exposes.

**Can I get an email or a phone number from a profile?**
No. Business contact fields are behind the login. If you need contact data for organisations, the [Facebook Pages Contact Scraper](https://apify.com/scrapegeist/facebook-pages-contact-scraper) reads the public contact block of a Facebook Page instead.

**What happens with private accounts?**
You get the profile row, charged as a profile row, because Instagram shows the profile card of a private account to anyone: name, follower and following count, biography, bio links, verified and private flags. On top of it comes a free `skipped` row with the reason `private_no_posts`. What you do not get is the posts, because they are not public, so the engagement rate and the averages stay `null`. Put another way, you pay for a private account only when there was something to deliver, and never for its posts.

**Why a residential proxy?**
Instagram gates datacenter IPs on the post routes. The default `RESIDENTIAL` group of Apify Proxy is enough and needs no setup.

**A run failed. What now?**
Re-run it. Failed and skipped rows are never billed, so a failed run costs you nothing. When Instagram gates the whole proxy pool, the actor stops early and says so in the status message instead of burning your budget on walls.

**Can I run this over thousands of accounts?**
Yes. Use `maxTotalPosts` to bound spend, and Apify's scheduler to split the work. The actor streams rows to the dataset as it goes, so a stopped run keeps everything it already delivered.

### Legal and responsible use

This actor reads only what a logged-out visitor sees on public Instagram pages. No login, no cookies, no account, no CAPTCHA solving, no circumvention of a technical access control. The posts of a private account are not public, so they are never read: only the profile card Instagram itself shows to every visitor.

The output is research, enrichment and verification data. It is not an opt-in marketing list, and it does not become one because it is in a dataset. If you contact anyone in it, you are the controller for that processing, and you are responsible for the rules that apply to you: GDPR, UWG § 7 in Germany, CAN-SPAM in the US, PECR in the UK, and the equivalents elsewhere. Check before you send.

Every row carries `discoveredVia` and a timestamp, so you can show where a record came from and when.

#### Data notice for account owners

What is collected: the public profile of an Instagram account (username, name, biography, bio links, follower and following count, verified and private flags, profile picture, pronouns, highlight titles) and its public posts (caption, media type, thumbnail, timestamp, and where public, likes, comment count, view count, usertags, location and the comments the post page prints). Nothing behind the login is read. Follower and following lists, stories, direct messages and the posts of private accounts are never read. Of a private account only the public profile card is collected, which is what Instagram shows to any logged-out visitor.

Who processes it and why: the customer who starts a run chooses the input and receives the output, and is the controller for any use of it. The operator of this actor processes the data only to deliver it to that customer and keeps no database beyond the run storage the customer configures. Legal basis for the collection is legitimate interest (GDPR Art. 6(1)(f)): the data is published by the account owner for a public audience, it is limited to what a logged-out visitor sees, and no contact details are collected.

Your rights: to have your account excluded from future runs, to learn whether it was included, or to have data erased on the operator's side, open an issue on this actor's Store page (the **Issues** tab) with the subject "Exclude my account" and the profile URL. Exclusions are applied within 30 days and checked on every run. You also have the right to complain to a supervisory authority.

### Other Scrapegeist actors

- [Facebook Events Scraper, Past the 8-Event Limit](https://apify.com/scrapegeist/facebook-organizer-events-scraper): every public event of an organizer or a city, not the 8 Facebook shows you when logged out.
- [Facebook Pages Contact Scraper](https://apify.com/scrapegeist/facebook-pages-contact-scraper): one contact row per Facebook Page with email, phone, WhatsApp, website, address, followers and rating.

### Support

Use the **Issues** tab on this actor's Apify Store page for bugs and feature requests. Include the profile URL, the hashtag or the location id when a field is missing: that is what makes a fix possible. Failed and skipped rows are never billed, so a broken run costs you nothing while we investigate.

# Actor input Schema

## `usernames` (type: `array`):

Handles (natgeo), @handles or profile URLs (https://www.instagram.com/sfmoma/). One profile row per account plus its posts. A handle that does not exist is a free skipped row. A private account gives its public profile card as a normal charged profile row, plus a free skipped row with the reason private\_no\_posts, because its posts are not public.

## `hashtags` (type: `array`):

Hashtags with or without #. Returns about 60 posts per hashtag without a login: that is where Instagram's public tag page ends. The hashtag grid Instagram shows without a login contains reels only, no photo posts, and prints a view count per post, so hashtag rows carry views without an extra fetch and at no extra cost. List rows are billed as grid posts (grid-post-scraped), engagement rows as post engagements (post-engagement-scraped) and detail rows as post details (post-detail-scraped). Rows are deduplicated over the whole run and can be cut by date through the since field.

## `locations` (type: `array`):

Instagram location ids (212988663) or /explore/locations/<id>/ URLs. Returns one location row with the coordinates of the place, then about 70 posts per location: that is everything Instagram shows for a place without a login, and the page does not go deeper. The nearby field of that row is not a nearby-places list, which Instagram does not publish logged out: it holds the other places the posts on this page are tagged at, with their coordinates. Location rows carry likes, the comment count and the exact timestamp, which the grid prints itself, at no extra cost. List rows are billed as grid posts (grid-post-scraped), engagement rows as post engagements (post-engagement-scraped) and detail rows as post details (post-detail-scraped).

## `searchQueries` (type: `array`):

Keyword search needs a login; this actor does not log in. Use hashtags and locations for discovery. Each keyword comes back as a skipped row with the reason not\_available\_logged\_out and is never billed.

## `postUrls` (type: `array`):

Post or reel URLs or shortcodes. Each becomes a detail row with likes, comment count, timestamp, usertags, location and the visible comments, whatever the postDetail level is. A view count is added from the public embed page when minViews is set (videos and reels only); the post itself publishes none.

## `maxPostsPerProfile` (type: `integer`):

How far back to page through each profile. The first 12 posts are included in the profile price as list rows, except the detailSample posts among them, which are charged at the level they are fetched at (post-engagement-scraped or post-detail-scraped); detailSample is 0 by default, so nothing extra is charged unless you ask for it. Every further timeline post is a post row (post-scraped), or a post engagement or post detail row when you raise postDetail. 0 returns the profile row only.

## `maxTotalPosts` (type: `integer`):

Hard cap on billed post rows across all inputs. Checked before each fetch, not after the row lands, so the number you type is the most post rows a run can charge you for. A lower Apify charge limit lowers it further.

## `maxResultsPerHashtag` (type: `integer`):

Upper limit of post rows for each hashtag or location page. Without a login a hashtag page gives about 60 posts and a location page about 70, so a higher value changes nothing for them.

## `postDetail` (type: `string`):

Sets the level for every post row, and the level and the source set the price: a timeline list row is post-scraped, a hashtag or location list row is grid-post-scraped, an engagement row is post-engagement-scraped and a detail row is post-detail-scraped. Hashtag and location rows carry the counts their grid prints at whichever level you choose, for free. list is the cheap row from the timeline. engagement fetches the public embed of each video for likes, comment count and view count, at about 55 KB per post. detail fetches the post itself for likes, comment count, exact timestamp, video URL, usertags, location and the visible comments, at about 88 KB per post; it carries no view count, because Instagram does not publish one there. View counts exist for videos and reels only.

## `detailSample` (type: `integer`):

0, the default, disables the sample: no extra page is fetched for the first 12 posts and the engagement fields stay null. Above 0, and with postDetail = list, the N newest posts of each profile are fetched and the engagement rate is computed from them. The sample is fetched at detail level (post-detail-scraped), or at engagement level (post-engagement-scraped) when minViews is set or postDetail is engagement, which is the cheaper page and the only one with a view count. These sample posts are the one part of the free first 12 that costs extra.

## `since` (type: `string`):

ISO date, for example 2026-01-01. Profile pagination stops once the posts are older than the date. Hashtag and location grids are ranked, not sorted by date, so there since drops the older posts out of the grid and the actor keeps reading the rest.

## `minViews` (type: `integer`):

Keeps only posts with at least this many views. The number is the one Instagram publishes: a play count for reels and for hashtag rows, a video view count for a feed video, both written into the view\_count field. View counts exist for videos and reels only, so image and carousel posts never pass this filter. A match from a profile is delivered as an engagement row (the embed page is the only source of its view count). A match from a hashtag grid stays at the level you asked for, because the grid prints the count. A match from a location grid is an engagement row when the grid printed no view count for it. A match from postUrls is a detail row. On hashtag pages the count is in the grid and costs nothing; everywhere else each video costs one embed fetch. Filtered posts are not delivered and not billed.

## `includeComments` (type: `boolean`):

Adds the comments to each detail row: up to about 14 most relevant comments, Instagram exposes no more without a login. Comments are part of the detail row and are never billed separately.

## `proxyConfiguration` (type: `object`):

Residential proxy is required for post pages. Your own proxy URLs are accepted.

## `concurrency` (type: `integer`):

Parallel sessions. Each session keeps one proxy IP and one cookie jar. More sessions finish faster and use more memory.

## Actor input object example

```json
{
  "usernames": [
    "natgeo",
    "https://www.instagram.com/sfmoma/"
  ],
  "hashtags": [],
  "locations": [],
  "searchQueries": [],
  "postUrls": [],
  "maxPostsPerProfile": 100,
  "maxTotalPosts": 10000,
  "maxResultsPerHashtag": 100,
  "postDetail": "list",
  "detailSample": 0,
  "includeComments": true,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  },
  "concurrency": 4
}
```

# Actor output Schema

## `profiles` (type: `string`):

One row per profile.

## `posts` (type: `string`):

One row per post.

## `locations` (type: `string`):

One row per location page, with its coordinates and the other places its posts are tagged at.

## `skipped` (type: `string`):

Inputs that produced no row and were not billed.

## `allItems` (type: `string`):

Every dataset row.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "usernames": [
        "natgeo",
        "https://www.instagram.com/sfmoma/"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapegeist/instagram-profile-posts-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "usernames": [
        "natgeo",
        "https://www.instagram.com/sfmoma/",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("scrapegeist/instagram-profile-posts-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "usernames": [
    "natgeo",
    "https://www.instagram.com/sfmoma/"
  ]
}' |
apify call scrapegeist/instagram-profile-posts-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapegeist/instagram-profile-posts-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/EtgnaS36LJV1OBdVb/builds/gYfKFBBG4Aw6aIqse/openapi.json
