# Instagram Competitor Monitor (`dami_studio/instagram-competitor-monitor`) Actor

Track up to 20 Instagram accounts and get one row each with what changed since the last run: followers, following and posts gained or lost, bio and verification changes, and the exact window the change covers.

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

## Pricing

Pay per event

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/actors/running/actors-in-store.md#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 Competitor Monitor

Give it up to 20 Instagram accounts and get one row each showing what changed since the last time it ran: followers gained or lost, posts published, whether the bio or the verification badge moved, and how many hours the window covered. It remembers between runs, so from the second run onwards you get a change report rather than another snapshot. No account, no cookies, no login, no browser.

- Every run is compared against the previous run, so you read movement instead of raw numbers.
- The first check of an account returns nulls, never zeros — a brand-new baseline is not the same thing as a flat week, and the rows say which one you are looking at.
- One row per account per run, whatever the account size: a hundred-million-follower account costs exactly the same to check as a small one.
- The saved baseline only moves forward behind a row that reached your dataset, so an interrupted run cannot quietly swallow a week of change.
- Runs with empty input return a labelled sample row, free, so you can see the shape first.

### Price

**$5.00 per 1,000 competitors** ($0.005 each), plus a **$0.001 start fee per run**. The start fee is billed per gigabyte of run memory with a minimum of one event, and this Actor runs at 512 MB, so a run start is always exactly $0.001.

This is a flat 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.

| Competitors | Total cost |
|---|---|
| 1 | $0.006 |
| 5 | $0.026 |
| 10 | $0.051 |
| 20 | $0.101 |

Every line in that table is **one run**. This Actor checks up to 20 accounts in a single run, so all four volumes fit in one run and carry exactly one start fee each. Watching more than 20 accounts means more than one run — split the list across runs or schedules — and every run carries its own start fee, so 100 accounts is 5 runs and 5 start fees. The per-account rate never changes.

#### What is actually charged

- **One `competitor-checked` event per competitor 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`.
- The same account written three ways — `nasa`, `@nasa` and its profile link — is one account. It is checked once and charged once.
- An account that cannot be found, has been renamed or has been removed is reported in an uncharged row.
- 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, `competitor-checked`, so the run's cost breakdown lists exactly what you paid for and nothing is metered under a generic dataset-item charge.

### Input

```json
{
  "competitors": [
    "nasa",
    "@natgeo",
    "https://www.instagram.com/spacex/"
  ]
}
```

| Field | What it does |
|---|---|
| `competitors` | The accounts to watch. A bare handle (`nasa`), an `@handle` or a full profile link all work and are treated as the same account, so duplicates cost nothing. Between 1 and 20 per run. Leave it empty and the run returns one free sample row instead. |

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 competitor. A real row from a real run:

```json
{
  "ok": true,
  "charged": true,
  "recordType": "competitor",
  "username": "nasa",
  "fullName": "NASA",
  "isVerified": true,
  "isPrivate": false,
  "isBusiness": true,
  "profileUrl": "https://www.instagram.com/nasa/",
  "followers": 104417769,
  "following": 92,
  "posts": 4887,
  "biography": "Making the seemingly impossible, possible. ✨",
  "externalUrl": "https://www.nasa.gov",
  "followersDelta": 18432,
  "followingDelta": 0,
  "postsDelta": 3,
  "biographyChanged": false,
  "verifiedChanged": false,
  "isFirstRun": false,
  "hoursSinceLastCheck": 168,
  "previousCheckedAt": "2026-08-12T13:42:49.307Z",
  "checkedAt": "2026-08-19T13:42:49.537Z"
}
```

#### Field notes

- `followers` — the follower count at the moment of this check.
- `followersDelta` — the change since the previous run: positive for growth, negative for a loss, `0` when nothing moved, and `null` on the first check of that account, when there is nothing to compare against.
- `postsDelta` — how many posts appeared between the two checks. A negative number means posts were deleted or archived.
- `biographyChanged` — `true` when the bio text differs from the previous check — a launch, a rebrand or a new campaign line usually lands here first. `null` on a first check.
- `verifiedChanged` — `true` when the blue badge was gained or lost since the previous check. `null` on a first check.
- `isFirstRun` — `true` only for the very first check of that account. Every change field on that row is `null`, and the run after it carries the first real comparison.
- `hoursSinceLastCheck` — the length of the window this row describes, to two decimal places, so a schedule that slipped is visible rather than hidden. `null` on a first check.
- `previousCheckedAt` — when the account was last checked. Together with `checkedAt` it tells you exactly which window a delta belongs to.
- `externalUrl` — the link in the bio. Worth diffing between rows — it changes before a launch more often than the bio text does.

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

- Each account is read once per run from a licensed data source — one request per account, no page rendering, no headless browser, no login and no cookies.
- After a row has been written to your dataset, the numbers behind it are saved to a named storage that survives between runs. The next run reads that saved check back and reports the difference against it.
- The saved baseline moves forward only after the row is actually delivered. If a run stops halfway, the accounts it never reached keep their old baseline, and the next run reports the whole window instead of losing it.
- The saved history belongs to your own account and is keyed by handle, so two people running this never see or affect each other's baselines.
- Reading the history for an account happens before anything is fetched for it, so a storage problem can never turn into a row that claims to be a first check and then overwrites good history.

### What people use it for

- A weekly growth report: schedule the same list every Monday and read `followersDelta` down the column instead of copying numbers off profiles by hand.
- Spotting a change of strategy the week it happens — `postsDelta` jumping from 2 to 14 is a campaign starting, and you see it without watching a feed.
- Catching a bio or link change on the day: `biographyChanged` and `externalUrl` move before the announcement does.
- Benchmarking your own account against the set you track, on identical timing and identical definitions, so the comparison is fair.
- Feeding a dashboard or a spreadsheet with a flat, typed table where every row already carries its own window (`previousCheckedAt`, `checkedAt`, `hoursSinceLastCheck`).

### How the memory works

This is the part that makes it a monitor rather than a scheduled scrape, so it is worth being precise about.

- **First run for an account.** There is no previous check, so `isFirstRun` is `true` and every change field — `followersDelta`, `followingDelta`, `postsDelta`, `biographyChanged`, `verifiedChanged`, `hoursSinceLastCheck`, `previousCheckedAt` — is `null`. It is deliberately not `0`: zero would mean "nothing moved", which is a claim about a week nobody watched.
- **Every run after that.** You get the current numbers *and* the movement since the previous run, plus the exact window those deltas belong to.
- **The window is your schedule.** Run it daily and the deltas are daily; run it every Monday and they are weekly. Nothing is normalised for you, because `hoursSinceLastCheck` lets you do that yourself and know you did it right.
- **Adding an account later** costs one first-run row, and the run after that starts reporting its changes. You can grow the list whenever you like.
- **Removing an account** from the input simply stops checking it. Its saved history stays, so putting it back later resumes the comparison — with a longer window, which `hoursSinceLastCheck` will show.

### Reading the output

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

- **Real rows** carry `"charged": true` and `"recordType": "competitor"`. One billed event each, one per account per run.
- **The sample row** carries `"_sample": true` and `"charged": false`. There is exactly one, it only appears when the input had no accounts in it, and it exists so you can see the output shape before spending anything.
- **Diagnostic rows** carry `"_diagnostic": true`, `"charged": false` and an `errorCode` you can switch on: `BAD_INPUT` for an entry that is not a handle or a profile link, `NOT_FOUND` for an account that does not exist any more, `RATE_LIMITED`, `SERVER_ERROR` or `NETWORK` when the data source could not answer for one account within the time allowed, `TIME_BUDGET` when the run ran out of time before reaching the rest of the list, `PARTIAL` when the run stopped early and had to leave part of the list unchecked — including when it reached the maximum cost you set for the run — `STATE_UNAVAILABLE` when the saved history for an account could not be read (it is skipped rather than reported as a first check), `STATE_NOT_SAVED` when a delivered row could not be saved as the new baseline, and `CHARGE_ERROR` when a charge could not be recorded (see below).

If you only want the data, filter on `charged == true`. Normally the count of those rows is exactly the number of events you were billed for, so the dataset doubles as your invoice. There is one exception, and it runs in your favour: if the platform refuses to record a charge, the row has already been written, so it stays in your dataset marked `"charged": true` while nothing was billed for it. That is logged as an error and summarised in a `CHARGE_ERROR` diagnostic row at the end of the run, with the delivered and charged counts on it. A charge is never recorded without a row.

### Limitations

- Profile-level numbers only: followers, following, posts, bio, link, name, verification and account type. There are no per-post metrics, no engagement rates, no stories and no audience breakdown.
- The first check of an account produces a row with no comparison in it. That row is charged, because it is the baseline everything afterwards is measured against, and it is clearly marked with `isFirstRun: true`.
- A delta is the difference between two checks, not a per-day figure. If you skip a week, the delta covers a fortnight; `hoursSinceLastCheck` is there so you can scale it yourself.
- History is keyed by handle. If an account renames itself, the old history stays under the old handle and the new one starts with a fresh first check.
- The counts are whatever the account reports at that moment. Instagram updates them with some lag, so a check taken immediately after a post can trail reality by a few minutes.
- A private account is still readable at profile level in most cases, but nothing behind the privacy wall is — and an account that goes private can stop reporting counts entirely, which shows up as `null` values rather than a drop to zero.
- 20 accounts per run, one check per account per run. For a bigger list, split it across several runs or several schedules, and remember that each run carries its own start fee.
- Run one schedule per account list. The saved history is shared by every run under your account and there is no lock on it, so two runs covering the same account at the same time both read the same saved check, both are charged, and the second row repeats the changes the first one already reported. Give overlapping lists their own schedules on different times, and avoid starting a manual run for accounts a schedule is currently checking.
- Deleting the saved history storage resets the baseline: the next run reports a first check for every account again, and charges for it.

### Questions

**What do I get on the very first run?**

One row per account with the current numbers, `isFirstRun: true`, and every change field set to `null` — there is no previous check to compare against yet. The next run is where the comparison starts. Those first rows are charged, because they are the baseline the rest of the reporting is built on.

**How should I schedule it?**

Pick the window you actually want to read: daily for a fast-moving set, weekly for a growth report. Use the same account list each time. The window between two runs is the window in the deltas, and every row carries it in `hoursSinceLastCheck`, so an occasional missed run is visible instead of silently distorting a chart.

**What happens if a run is aborted or something breaks halfway?**

The accounts already delivered are in your dataset and their baseline has moved on. The ones that were not reached keep the baseline they had, so the next run reports the full window rather than losing the part nobody saw. Nothing is charged for an account that was not checked.

**Can I add or remove accounts later?**

Yes. A new account costs one first-run row and starts reporting changes from the run after that. Removing one just stops checking it; its history is kept, so adding it back resumes the comparison over the longer window.

**Will the run fail if something goes wrong?**

No. A missing account, a data-source problem or a storage problem produces an uncharged diagnostic row explaining what happened, and the run still finishes as succeeded. A failed run would still bill you the start fee, which would mean paying to be told something went wrong.

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

Filter the dataset on `"charged": true`. Sample and diagnostic rows are always `false`, and normally the number of charged rows is exactly the number of billed events. The one way they differ is if the platform refuses a charge: the row is already written, so you keep it — marked `"charged": true` — and were not billed for it. The run logs that as an error and adds a `CHARGE_ERROR` row carrying both counts, so the gap is always visible and always in your favour.

# Actor input Schema

## `competitors` (type: `array`):

The accounts to watch, 1 to 20 per run. A bare handle (nasa), an @handle (@nasa) or a full profile link (https://www.instagram.com/nasa/) all work, and the same account written two ways is checked and charged only once. Leave this empty and the run returns a single free sample row instead. Post and reel links are not accounts and are reported back to you, uncharged.

## Actor input object example

```json
{
  "competitors": [
    "nasa",
    "@natgeo"
  ]
}
```

# Actor output Schema

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

Every row in the default dataset: username, fullName, isVerified, isPrivate, isBusiness, profileUrl, followers, followersDelta, following, followingDelta, posts, postsDelta, biography, biographyChanged, externalUrl, verifiedChanged, isFirstRun, hoursSinceLastCheck, previousCheckedAt, checkedAt. 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 = {
    "competitors": [
        "nasa",
        "@natgeo"
    ]
};

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

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

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,dami_studio/instagram-competitor-monitor"
        }
    }
}

```

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/JHwREiVwUWf8dJy3h/builds/Jte6UNfST7N2PATuu/openapi.json
