# Instagram Profile Scraper - $0.70 per 1,000 Profiles (`dami_studio/instagram-profile-scraper`) Actor

The cheapest Instagram profile scraper on the market at $0.70 per 1,000 profiles. Usernames in, one row per public profile out: bio, follower, following and post counts, verified and private flags, business category, e-mail, phone, bio links and HD picture. No login, no browser.

- **URL**: https://apify.com/dami\_studio/instagram-profile-scraper.md
- **Developed by:** [Dami's Studio](https://apify.com/dami_studio) (community)
- **Categories:** Social media, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.70 / 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.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Instagram Profile Scraper

Paste Instagram usernames or profile links and get one clean row per public profile: the bio, the follower, following and post counts, the verified and private flags, the business category, the business e-mail and phone when the account publishes them, every link in the bio, and the profile picture. Profiles are read from Instagram's public, logged-out surfaces, with no browser and no account of yours.

- Usernames, @handles or full profile URLs — mixed together in the same list if you like.
- Up to 1,000 profiles in a single run, read several at a time, deduplicated before anything is billed.
- Exact counts, not the rounded ones the page shows: 20,087,807 followers rather than "20M".
- A profile that no longer exists returns a free diagnostic row, not a charge and not a crash.
- Empty input returns one clearly labelled free sample row, so you can see the shape before you spend anything.

### Price

**$0.70 per 1,000 profiles**, plus a **$0.001 start fee per run** (billed per gigabyte of run memory, so exactly that on the default 1 GB).

This is **the cheapest Instagram profile scraper on the market**, and it is the same rate on every plan, free or paid. There are no volume tiers, no minimum spend, no subscription and no add-on fees. What you read here is what you pay on day one and on day four hundred.

| Profiles | Total cost |
|---|---|
| 100 | $0.071 |
| 1,000 | $0.701 |
| 10,000 | $7.001 |
| 100,000 | $70.001 |

#### What is actually charged

- **One `profile-scraped` event per profile row written to the dataset.** Nothing else is metered per row.
- **Free:** the sample row an empty run returns, and every diagnostic row — a blocked target, a dead URL, a search that matched nothing. Those rows all carry `"charged": false`.
- Duplicate usernames in the same list are collapsed before the run starts, so you pay once for each profile.
- Profiles that do not exist, are removed, or that Instagram refused on this run — all uncharged.
- A run that finds nothing costs the start fee and nothing more.
- Rows never leave the dataset without a charge, and are never charged without a row. The billed event is a named one, so there is no price quietly attached to `apify-default-dataset-item` — the trick that makes some scrapers bill you for their own error messages.

### Input

```json
{
  "usernames": [
    "nasa",
    "@gopro",
    "https://www.instagram.com/natgeo/"
  ],
  "includeLatestPosts": false
}
```

| Field | What it does |
|---|---|
| `usernames` | One entry per profile. `nasa`, `@nasa`, `instagram.com/nasa` and `https://www.instagram.com/nasa/` all mean the same thing. Up to 1,000 per run; duplicates are removed. |
| `includeLatestPosts` | Adds a `latestPosts` array to every row with the profile's most recent posts — shortcode, link, caption, likes, comments, thumbnail and timestamp. It costs no extra requests and no extra charge; it simply makes each row much larger. Off by default. |
| `maxItems` | Stop after this many profiles from the list. Leave it out to read the whole list. Handy while testing, since you pay per profile. |
| `concurrency` | How many profiles are read at once. Default 6, maximum 12. Raising it makes a long list finish sooner; lowering it gives the run more room to fill every field. |
| `sessionCookies` | Leave empty. Only used on a handle every public surface refused; see [When Instagram refuses a handle](#when-instagram-refuses-a-handle). |
| `proxyUrls` | Leave empty. Fill it in only if you want the traffic to leave through proxy servers you already pay for, as `http://user:pass@host:port`. |

Run it with **empty input** and you get one clearly labelled sample row, free, so you can see the output shape before you spend anything.

### Output

One row per profile. A real row from a real run:

```json
{
  "ok": true,
  "charged": true,
  "recordType": "profile",
  "inputUrl": "https://www.instagram.com/gopro/",
  "id": "28902942",
  "username": "gopro",
  "url": "https://www.instagram.com/gopro/",
  "fullName": "GoPro",
  "biography": "We help the world to capture and share itself in immersive and exciting ways.",
  "externalUrl": "https://gopro.com/",
  "externalUrls": [
    {
      "title": "",
      "url": "https://gopro.com/",
      "linkType": "external"
    }
  ],
  "followersCount": 20087807,
  "followsCount": 240,
  "postsCount": 9551,
  "verified": true,
  "private": false,
  "isBusinessAccount": true,
  "isProfessionalAccount": true,
  "categoryName": "Product/service",
  "businessCategoryName": null,
  "businessEmail": null,
  "businessPhoneNumber": null,
  "businessAddress": null,
  "profilePicUrl": "https://scontent-iad3-1.cdninstagram.com/v/t51.2885-19/449069728_1006900254437011_2641997517452774766_n.jpg",
  "profilePicUrlHD": "https://scontent-iad3-1.cdninstagram.com/v/t51.2885-19/449069728_1006900254437011_2641997517452774766_n.jpg",
  "highlightReelCount": 7,
  "igtvVideoCount": 0,
  "hasChannel": false,
  "joinedRecently": false,
  "pronouns": [],
  "relatedProfiles": [],
  "emailsFound": [],
  "phonesFound": [],
  "bioHashtags": [],
  "bioMentions": [],
  "latestPosts": [],
  "fbid": "17841400823952971",
  "dataSource": "profile-api-lean+profile-page",
  "scrapedAt": "2026-08-15T17:41:02.914Z"
}
```

#### Field notes

- `username` — the handle as Instagram spells it, lower-cased. Stable, and safe to use as a primary key across runs.
- `id` — the numeric account id. It survives a username change, so it is the better key if you track accounts over time.
- `followersCount` — the exact number, not the abbreviated one shown on the page.
- `followsCount` — how many accounts this profile follows. Instagram publishes this on one surface only, and that surface refuses a share of requests, so on a small number of profiles it can come back null rather than wrong.
- `postsCount` — total posts on the profile at read time.
- `externalUrl` — the first link in the bio. `externalUrls` carries all of them when the account uses the multi-link layout.
- `businessEmail` — only present when the account publishes a public business e-mail. `emailsFound` additionally lists any address written into the bio text.
- `categoryName` — the category the account chose, e.g. "Product/service". Personal accounts have none.
- `profilePicUrlHD` — the largest picture Instagram offers for the account. The link is a time-limited CDN URL — download the image if you need to keep it.
- `relatedProfiles` — the "suggested for you" accounts Instagram associates with this one, when it offers any.
- `dataSource` — which public surfaces the row was assembled from. Useful when you want to know why a field is null.
- `scrapedAt` — when the row was read, in UTC. Counts move constantly, so a row is a snapshot, not a fact.

Every real row carries `"charged": true`. Sample rows carry `"_sample": true` and diagnostic rows carry `"_diagnostic": true` with an `errorCode` you can filter on, and neither is ever billed.

### How it works

- It reads the public, logged-out surfaces Instagram serves to anyone with a browser: the profile data endpoint the web app calls, the profile page itself and the public embed card. That is the default path and it is what answers almost every handle.
- Those surfaces disagree with each other. Some accounts are refused by one and answered by another, one of them reports a following count that does not match the profile page, and another leaves the post count at zero. The run reads whichever ones answer for a given account and takes each field only from a source that is known to be correct for it — a field nobody could supply comes back null rather than wrong.
- Requests leave through a large pool of rotating addresses, so a per-address rate limit is answered by moving to another address rather than by waiting.
- Profiles are read several at a time, each on its own address, which is what keeps a 1,000-profile run to minutes rather than hours.
- No page is rendered and no headless browser is started, which is most of the reason this costs what it costs.
- Only when all of those refuse the same handle on the same run does it try once more with a session. See below.

### When Instagram refuses a handle

Sometimes every public surface refuses the same handle within the same run — usually because the addresses that run happened to draw were all throttled at that moment. Before giving up and writing an uncharged `BLOCKED` row, the run makes one more attempt using a built-in session.

Two things worth knowing about that fallback:

- **It is a last resort, not the normal path.** Rows assembled without it are the overwhelming majority; you can tell them apart by `dataSource`, which reads `profile-api-session` on the rare row that needed it.
- **The built-in session is shared between everyone running this Actor**, and Instagram meters a session per account rather than per caller. Under heavy concurrent load it is therefore a small, shared budget, not an unlimited one. If you are running large lists and want that fallback to be reliably available to you, put your own cookie in `sessionCookies`: your account, your rate limit, nobody else drawing on it.

To get your own cookie in Chrome: `F12` → Application → Cookies → `https://www.instagram.com` → copy the `sessionid` value. Pasting the whole cookie string works too. Use an account you own and are willing to automate; a session that Instagram invalidates is dropped for the rest of the run and the run finishes with whatever it collected rather than failing.

The closing log line of every run carries `sessionState`: `keyless` when no session was configured at all, `ok` while sessions are healthy, `degraded` after one was dropped, and `exhausted` when none are left.

### What people use it for

- Influencer shortlisting: drop in a few hundred handles and sort the result by follower count, engagement potential or category before anyone opens Instagram.
- Lead enrichment: business accounts publish a category, an e-mail and a phone number, and the bio often carries a second address. One row per handle, straight into a CRM.
- Brand monitoring: re-run the same list weekly and diff `followersCount` and `postsCount` to see who is growing and who has gone quiet.
- Market mapping: read every account in a niche, keep the bios and the bio links, and cluster them however you like.
- Data quality checks on a list you already own: `verified`, `private` and the not-found diagnostics tell you which handles are still real.
- Feeding a dashboard or a spreadsheet with typed, flat rows rather than screenshots of a rendered page.

### Reading the output

Every run writes three kinds of row, and they are easy to tell apart:

- **Real rows** carry `"charged": true` and `"recordType": "profile"`. One billed event each.
- **The sample row** carries `"_sample": true` and `"charged": false`. There is exactly one, it only appears when the input had no usernames, and it exists so you can see the shape of the output before you spend anything.
- **Diagnostic rows** carry `"_diagnostic": true`, `"charged": false`, the `username` they belong to and an `errorCode` you can switch on: `NOT_FOUND` when no public account exists at that handle, `BLOCKED` when Instagram refused every public route for it on this run, `TIME_BUDGET` when the run ran out of time before reaching it.

If you only want the data, filter on `charged == true`. The count of those rows always equals the number of events you were billed for, so the dataset is its own invoice.

### Private accounts

A private account still returns a full row — Instagram publishes the bio, the counts, the verification flag and the picture for private profiles, and `private` comes back `true`. What is not public is the content: `latestPosts` is empty for a private account no matter what you ask for. Nothing in this Actor can see behind a private profile, and nothing about it tries to.

### Limitations

- Public data only. Private posts, stories, direct messages, follower lists and anything behind a login are out of scope and no input can reach them. Supplying `sessionCookies` does not unlock any of them — it only affects the retry described above.
- The counts are a snapshot at read time. Followers move by the second on a large account, so two runs minutes apart will disagree slightly, and neither is wrong.
- Instagram publishes the following count on a single public surface, and that surface refuses a share of requests. The run retries it on fresh addresses, but on a small number of profiles `followsCount` still comes back null. It is never filled in with an approximation.
- Instagram itself currently returns a schema error for a slice of business accounts on its richest public endpoint. Those rows are assembled from the other surfaces instead, which is why `businessCategoryName` can be null on an account that clearly has a category — `categoryName` usually still carries it.
- Post captions, likes and comments are only included when you ask for `includeLatestPosts`, and only the most recent handful of posts. For a full post history, use a post-level scraper instead.
- Follower and following LISTS are not returned — only the counts.
- Profile picture and post image links are time-limited CDN URLs. They work immediately after the run and expire later, so download anything you need to keep.
- A handle that has been renamed resolves to whoever owns the handle now. Track `id` if that matters to you.
- Hard ceiling of 1,000 profiles per run. Split larger lists across runs.
- Instagram changes its public surfaces without notice. When that happens a run returns uncharged diagnostic rows rather than wrong data, and the Actor is repaired.

### Questions

**Do I need an Instagram account, cookies or a session?**

No. Profiles are read logged-out, and that is what answers almost every handle. A session is used only as a last resort on a handle every public surface refused, and one is built in, so you never have to supply anything. Adding your own cookie in `sessionCookies` is optional and only changes which session that fallback uses.

**What happens if a username does not exist?**

You get one uncharged diagnostic row with `errorCode: "NOT_FOUND"` and the run carries on to the rest of your list. You are never billed for a handle that returned nothing.

**Why is followsCount sometimes null?**

Because Instagram publishes the following count on one public surface only, and that surface throttles hard. The run retries it on several fresh addresses; when it still refuses, the field is left null instead of being filled with the wrong number that another surface reports. Re-running usually fills it.

**Can it get e-mails and phone numbers?**

It returns the public business e-mail and phone when the account chose to publish them, plus anything written into the bio text (`emailsFound`, `phonesFound`). Personal accounts usually publish neither, and no private contact detail is ever revealed.

**Will the run fail if Instagram blocks something?**

No. A refused profile becomes an uncharged diagnostic row and the run still finishes as succeeded. A failed run would still bill the start fee, which would mean paying to be told something went wrong.

**Do I need a proxy?**

No. The run brings its own egress and it is already inside the price you see. The `proxyUrls` field exists only for callers who specifically want traffic to leave through servers they already own.

**Can I run this on a schedule?**

Yes. Nothing is held between runs, so the same input is safe to repeat. Diff on `id` and `followersCount` to see what changed.

**How do I get exactly the rows I paid for?**

Filter the dataset on `"charged": true`. Sample and diagnostic rows are always `false`, and the number of charged rows always equals the number of billed events.

# Actor input Schema

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

One entry per profile. Any of these forms works: nasa, @nasa, instagram.com/nasa, https://www.instagram.com/nasa/. Up to 1,000 per run. Duplicates are removed before anything is charged.

## `includeLatestPosts` (type: `boolean`):

Adds a latestPosts array to each row with the profile's most recent posts (shortcode, link, caption, likes, comments, thumbnail, timestamp). It costs nothing extra - the same requests already carry them - but it makes the rows much larger. Off by default.

## `maxItems` (type: `integer`):

Stop after this many profiles from the list above. Leave it empty to read the whole list. Useful while testing, because you pay per profile.

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

How many profiles are read in parallel. The default of 6 is the setting that keeps the data most complete; raise it for speed on large lists, lower it if you are supplying your own proxy servers and they are few.

## `sessionCookies` (type: `array`):

Leave this empty. Profiles are read logged-out and that is what answers almost every handle. On the rare handle Instagram refuses on every public surface, the run falls back to a built-in session that all callers share - supply your own account cookie here instead and that fallback uses your account, on a rate limit nobody else touches. In Chrome: F12 -> Application -> Cookies -> instagram.com, copy the sessionid value (or paste the whole cookie string).

## `proxyUrls` (type: `array`):

Leave this empty. By default the run rotates a large pool of addresses that cost you nothing per gigabyte. Fill it in only if you specifically want the traffic to leave through proxy servers you already pay for, in the form http://user:pass@host:port.

## Actor input object example

```json
{
  "usernames": [
    "nasa",
    "gopro",
    "natgeo"
  ]
}
```

# Actor output Schema

## `results` (type: `string`):

Every row in the default dataset: username, fullName, biography, followersCount, followsCount, postsCount, verified, private, isBusinessAccount, categoryName, businessEmail, businessPhoneNumber, externalUrl, profilePicUrlHD, url, id, inputUrl, externalUrls, businessCategoryName, businessAddress, highlightReelCount, igtvVideoCount, hasChannel, joinedRecently, isProfessionalAccount, profilePicUrl, pronouns, relatedProfiles, emailsFound, phonesFound, bioHashtags, bioMentions, latestPosts, fbid, dataSource, scrapedAt. An empty, blocked or unmatched run returns a single uncharged row explaining what happened instead.

# 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": [
        "nasa",
        "gopro",
        "natgeo"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("dami_studio/instagram-profile-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": [
        "nasa",
        "gopro",
        "natgeo",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("dami_studio/instagram-profile-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": [
    "nasa",
    "gopro",
    "natgeo"
  ]
}' |
apify call dami_studio/instagram-profile-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,dami_studio/instagram-profile-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/T6CNYZIlEH54amgkC/builds/qvp5X2X6hp4tRTLbF/openapi.json
