# Instagram Hashtag Scraper — Posts by Hashtag or Keyword (`steadyfetch/instagram-hashtag-scraper`) Actor

Instagram posts and reels under any hashtag, or found by keyword search — captions, hashtags, mentions, engagement, media links, owner and location. No login. From $0.60 per 1,000 posts. Your result limit is exact: set 30 and you get 30. No start fee, and an undelivered post is never charged.

- **URL**: https://apify.com/steadyfetch/instagram-hashtag-scraper.md
- **Developed by:** [Steadyfetch Team](https://apify.com/steadyfetch) (community)
- **Categories:** Social media, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.60 / 1,000 hashtag posts

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?

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 Hashtag Scraper — Posts by Hashtag or Keyword

Type the hashtags you want and get the posts and reels published under them: caption, hashtags, mentions, engagement, media links, owner, location and the permalink. Or search by keyword and get the reels Instagram ranks top for that phrase, or name a PLACE and get everything posted there. No login, no cookies, no proxy to configure. **Your result limit is exact — set 30 and you get 30**, and a post we could not deliver is never charged. Nothing is charged for starting a run.

**Using an AI agent?** Pin this actor in Apify's MCP server with one link: `https://mcp.apify.com?tools=steadyfetch/instagram-hashtag-scraper`

- **Actor id:** `steadyfetch/instagram-hashtag-scraper`
- **Input:** `{ "hashtags": ["travel"], "resultsLimit": 30 }` — the one field you have to set. `{ "keywords": ["van life"] }` searches instead of tagging, and `{ "locations": ["Eiffel Tower"] }` collects a place.
- **Cap the bill:** set `maxTotalChargeUsd` on the run (a run option, not Actor input), e.g. `0.50` — the run stops when it reaches it.

*Unofficial. Not affiliated with, endorsed by, or sponsored by Instagram or Meta Platforms, Inc.*

### What you get

One row per post, with everything the post exposes at one price — there is no second charge for "details":

| Column | What it holds |
|---|---|
| `shortCode` · `url` | the post's own code and its permalink |
| `caption` · `hashtags` · `mentions` | the caption, and the tags and @handles pulled out of it |
| `takenAt` | when it was posted, ISO-8601 UTC |
| `type` · `productType` · `isVideo` | image, video or carousel, and whether it is a reel (`clips`) |
| `likeCount` · `commentCount` · `playCount` · `videoDuration` | engagement, and the length of a video |
| `ownerUsername` · `ownerFullName` · `ownerId` · `ownerIsVerified` | who posted it |
| `displayUrl` · `videoUrl` · `videoUrlExpiresAt` | the media links, and when the signed link stops working |
| `taggedUsers` · `coauthors` · `locationName` · `carouselCount` | tagged accounts, co-authors, place, carousel size |
| `hashtag` · `keyword` · `location` | which of your terms found it — `location` is the location ID it was collected under |
| `source` · `postSource` | `source` is always `instagram`; `postSource` is `hashtag`, `keyword-search` or `location` |
| `charged` · `status` · `statusReason` | whether this row was billed, and why, so your invoice reconciles from the dataset itself |

Media links are signed and time-limited by Instagram: `videoUrlExpiresAt` tells you exactly how long each one stays fetchable, so download what you need in the same session. The requesting network address is stripped out of every link before it reaches your dataset.

### What a row looks like

One delivered row from a real run — the hashtag `latteart`, 2026-09-12. Every post or reel is one `hashtag-post` charge, and `hashtag` / `keyword` say which of your inputs found it. Media links are signed and time-limited by Instagram, so they are shown here as placeholders.

```json
{
  "postId": "3984712977128543428",
  "shortCode": "DdMi1c9otTE",
  "url": "https://www.instagram.com/p/DdMi1c9otTE/",
  "type": "image",
  "productType": "feed",
  "caption": "From blossom to berry, and from green cherry to roasted bean ☕️🌱 — every coffee bean has a journey.",
  "hashtags": [],
  "mentions": [],
  "takenAt": "2026-09-12T17:41:13.000Z",
  "takenAtTimestamp": 1789234873,
  "likeCount": 103,
  "commentCount": 2,
  "playCount": 0,
  "videoDuration": null,
  "displayUrl": "https://…",
  "videoUrl": null,
  "videoUrlExpiresAt": null,
  "carouselCount": null,
  "dimensionsWidth": 1304,
  "dimensionsHeight": 1463,
  "ownerId": "20486637937",
  "ownerUsername": "wealllovecoffee",
  "ownerFullName": "We all love coffee",
  "ownerIsVerified": false,
  "taggedUsers": [],
  "coauthors": [],
  "locationName": null,
  "locationId": null,
  "isPinned": null,
  "isSponsored": false,
  "postSource": "hashtag",
  "profileHandle": null,
  "profileFullName": null,
  "profileFollowers": null,
  "profileIsVerified": null,
  "profilePostCount": null,
  "source": "instagram",
  "hashtag": "latteart",
  "keyword": null,
  "isVideo": false,
  "input": "latteart",
  "sourceIndex": 0,
  "scrapedAt": "2026-09-12T20:42:25.359Z",
  "repeat": false,
  "charged": true,
  "status": "delivered",
  "retryable": false,
  "statusReason": null,
  "#k": "3984712977128543428",
  "#ce": {"hashtag-post":1},
  "#ask": {"delivered":1}
}
```

### Collect by place

Put a place in **Locations** and you get the posts tagged at it, exactly as the hashtag door works — same price, same `hashtag-post` charge, same exact result limit. Three shapes are read:

| You type | What happens |
|---|---|
| `103912118089363` | the Instagram location ID is used as it stands, with no lookup |
| an `/explore/locations/103912118089363/eiffel-tower/` link, copied from Instagram's own place page | the ID is lifted out of the link |
| `Eiffel Tower` | one lookup resolves the name to the place Instagram ranks first |

**A name is ambiguous and the run says so rather than pretending it is not.** Instagram ranks two different "Eiffel Tower"s — the Paris monument and one in Las Vegas — so a run that resolves a name adds one uncharged row naming the place it read, its location ID, and the other places ranked under the same name. If you meant one of those, put its ID in **Locations** and re-run: an ID is never resolved and never ambiguous.

A place that Instagram has nothing under is an uncharged `tag_not_found` row — proved by asking Instagram's own place record, never guessed from an empty grid. A place whose grid answers nothing while the place itself exists is an uncharged `vendor_unavailable` row and worth a re-run: a place publishes no post count, so there is no way to prove a grid is empty rather than unread, and this actor will not tell you a place is empty when it cannot know that.

Mixing works: hashtags, keywords and locations in one run share `resultsLimit` evenly, and every row says which of them found it.

### Price

One charged event, `hashtag-post`, and it lands only on a post that was actually delivered.

| Apify plan | Per post | Per 1,000 posts |
|---|---|---|
| Free plan | $0.0024 | $2.40 |
| Bronze | $0.0014 | $1.40 |
| Silver | $0.0009 | $0.90 |
| Gold and above | $0.0006 | **$0.60 per 1,000** |

Platform usage is included in that price — there is no separate compute bill on top, and no start fee. Set `maxTotalChargeUsd` on the run and it is a hard ceiling: the run stops cleanly under it and the last row says what is left.

### Honest rows: what "not charged" actually means

Every run closes its own books. A post that was not delivered leaves an uncharged row saying which of nine things happened, and none of them is billed:

- **`tag_not_found`** — Instagram has no such hashtag, or no place under the name or ID you gave. Proved by asking Instagram's own tag or place lookup, not guessed from an empty page.
- **`no_posts`** — Instagram offered no next page for the term. A definitive answer for this run, not a failure, and the only one of these that is a verdict about the hashtag itself.
- **`skipped.page_cap`** — this actor reads at most 40 pages for one term, and the walk reached that bound with the feed still serving. A bound of ours: ask for fewer posts per term, or give the term a run of its own, to reach further.
- **`skipped.all_repeat`** — three pages in a row held only posts you already have, so the run stopped reading rather than buy more pages of the same. Also a bound of ours: switch **Include posts you already have** on to get them delivered again.
- **`stopped_at_limit`** — one of your own limits, or the run clock, stopped it first. The row names which.
- **`vendor_unavailable`** — the read did not go through, or a place's grid came back empty while the place itself exists (a place publishes no post count, so an empty grid can never be proved to be the place's own answer). Temporary, says nothing about your hashtag, phrase or place, and a re-run is the fix. When posts had already been delivered for that term before the feed stopped answering, the row opens with how many — those rows are in your dataset and say what they were charged, and only the rest carries no result fee.
- **`source_refused`** — Instagram refused to look the term up at all, and it refuses it the same way every time, so a re-run cannot change it. Check the spelling in a browser; if the term works there, say so on the Issues tab.
- **`unsupported_shape`** — Instagram answered in a shape this actor does not read yet. That is on us, not on your term, and a re-run gets the same answer — tell us on the Issues tab which term it was and support for it will be added.
- **`vendor_budget`** — this actor reached its own monthly collection allowance and stopped rather than collecting more.

The last row of every run is a receipt: delivered, asked for, what was skipped, what stopped it, and the charged-event totals. A time limit ends the collecting, never the delivering — posts already in hand are always written out.

### How often this data changes

A hashtag feed moves continuously: popular tags take thousands of new posts an hour, quiet ones a handful a day. A place moves more slowly and far more unevenly — a landmark or a stadium takes hundreds of posts on a busy day and almost none on a quiet one, a small venue a handful a week — so a daily schedule on a location is usually worth it only for somewhere busy, and a weekly one is the right cadence for most places. Re-running the same hashtag therefore buys mostly new posts, which is the point — but it would also re-buy the ones you already have, so it does not. Every run remembers what it delivered to your account, in the key-value store `ig-hashtag-account` in your own Apify account, and a later run skips those: not delivered again, not charged, and the slot goes to the next new post instead, so you still get the number you asked for. The memory is a key-value store in your own Apify account, so a run started with a scoped API token in restricted-access mode needs key-value store Read, Write and Create permission (Write is what lets the run remember what it delivered; without it every run pays again) or Actor runs set to Full access; without it the run says so on its status line and on an uncharged row, and cannot skip the posts you already have. That makes a daily schedule cost only what is new, as long as the token the schedule runs on can open that store. Switch **Include posts you already have** on to get them back anyway, marked `repeat: true` and still uncharged, and use **Skip posts in this dataset** to carry the same memory across accounts.

### Reliability

The posts come from a licensed data feed rather than from scraping Instagram's own web pages, which is why there is nothing to log into and nothing to configure. When that feed cannot answer, the run says so on an uncharged row and finishes successfully — a failed read is never billed and never dressed up as an empty hashtag.

### Related actors

- One account's own posts, rather than a hashtag: **Instagram Profile Posts Scraper** — https://apify.com/steadyfetch/instagram-profile-posts
- The spoken words inside a reel: **Instagram Reel Transcript Scraper** — https://apify.com/steadyfetch/instagram-reel-transcript-scraper

### Limits and good manners

**Your settings are never refused for being too big.** This actor delivers up to 5,000 posts per run (`resultsLimit`) and runs for up to 1 hour (`maxRunSeconds`, minimum 30 seconds). Ask for more than that — or for a shorter clock — and the run still starts: it continues at the nearest limit and leaves one uncharged row saying what you asked for and what bound it. Nothing is refused before it starts.

Only public posts are returned. Instagram's hashtag and place surfaces serve a ranked slice rather than a complete archive, so a very deep ask on a small tag will run out — the run says so instead of padding. This actor may stop working if Instagram changes how the data is served; if it does, failed items are never charged.

### Support

Something off, or a column you need that is not here? Open an issue on the Issues tab — we usually reply within a couple of hours.

If it earned its keep, a rating helps other buyers find it, and saving the actor keeps it one click away.

# Actor input Schema

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

Instagram hashtags, one per line — "travel", "#travel" or an https://www.instagram.com/explore/tags/travel/ link all work; letters, numbers and underscores only, exactly as Instagram writes them, and "\_demo" is the sample switch, not a hashtag. A hashtag Instagram has nothing under, and a read that did not go through, are delivered as labelled rows and are never charged. A profile link or a reel link is refused with a row naming the actor that does take it. Leave it empty and the run returns built-in sample rows instead of collecting anything, so you can see the output shape at no result fee.

## `keywords` (type: `array`):

Free-text searches, one per line — "sourdough bread", "van life". Each returns the reels Instagram ranks top for that phrase. Spaces and punctuation belong here, not in Hashtags. Leave empty if you only want hashtags.

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

Instagram places, one per line — "Eiffel Tower", the location ID 103912118089363, or an https://www.instagram.com/explore/locations/103912118089363/ link all work, and every row comes back with the location ID it was collected under. A NAME costs one extra lookup and is resolved to the place Instagram ranks first: the run then adds one uncharged row naming that place and its ID, plus the other places ranked under the same name, so you can re-run with an exact ID. A place Instagram has nothing under, and a read that did not go through, are delivered as labelled rows and are never charged. Leave empty if you only want hashtags or keywords.

## `resultsLimit` (type: `integer`):

Hard cap on the posts this run delivers in total. With more than one hashtag or keyword the limit is split evenly between them, so 60 across 3 hashtags collects 20 from each; a term that runs short leaves an uncharged row saying so rather than borrowing another term's share. This actor delivers at most 5,000 posts in one run: ask for more and the run continues at 5,000, with one uncharged row saying so, rather than being refused.

## `maxRunSeconds` (type: `integer`):

The run stops cleanly before this many seconds and reports what is left, instead of being killed by a timeout. A time limit ends the collecting, never the delivering: posts already in hand are always written out. The clock runs between 30 seconds and 1 hour: set it outside that and the run continues at the nearest one, with one uncharged row saying so, rather than being refused.

## `includeSeen` (type: `boolean`):

OFF (default): a post already delivered to your account is skipped — no row, no charge — and the next new post takes its place, so you still get the number you asked for. ON: those posts are delivered again anyway, marked `repeat: true`, and still not charged. Posts older than 90 days in your account's memory count as new again either way.

## `resumeFromDatasetId` (type: `string`):

The dataset ID of a previous run of this actor. Posts already in it are treated as posts you already have and are skipped, so a follow-up run collects only what is new even from a different Apify account. Selecting it here is what grants this run permission to read it.

## Actor input object example

```json
{
  "hashtags": [],
  "keywords": [],
  "locations": [],
  "resultsLimit": 30,
  "maxRunSeconds": 600,
  "includeSeen": false
}
```

# Actor output Schema

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

One row per post or reel delivered: caption, hashtags, mentions, engagement, media links, owner, location and the permalink, plus the hashtag, keyword or location ID it was collected under. Every row carries `charged` and `status`, so the invoice reconciles from the dataset itself — only rows with charged = true were billed, at one price for the whole row with no second charge for details. Posts your account already had are skipped before they take a slot and are never charged again — that check reads a key-value store in your own account, so a run on a scoped API token without key-value store permission cannot make it and says so on its status line and on an uncharged row; with "Include posts you already have" they come back marked `repeat: true`, still uncharged.

## `misses` (type: `string`):

One uncharged row for every hashtag, keyword or location that returned less than it was asked for, saying which of nine things happened: `tag_not_found` (Instagram has no such hashtag, or no place under the name or ID you gave), `no_posts` (Instagram offered no next page — a definitive answer about the term for this run), `skipped.page_cap` (this actor's own 40-page-per-term ceiling ended the walk while the feed was still serving — a bound of ours, not a verdict about your term), `skipped.all_repeat` (this actor's own backstop ended it after three pages in a row that held only posts you already have), `stopped_at_limit` (one of your own limits or the run clock stopped it first), `vendor_unavailable` (the read failed and a re-run is the fix), `source_refused` (Instagram refused to look the term up at all, the same way every time — a re-run cannot change it), `unsupported_shape` (Instagram answered in a shape this actor does not read yet — on us, and a re-run gets the same answer), `vendor_budget` (this actor reached its own monthly collection allowance). None of these is charged.

## `summary` (type: `string`):

Posts delivered, what was asked for, posts your account already had, posts that appeared under more than one of your terms, what stopped the run, the charged-event totals, and — when this run's token could not open the repeat memory — `repeatCheck: "unavailable_scope"`.

# 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 = {
    "hashtags": [],
    "keywords": [],
    "locations": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("steadyfetch/instagram-hashtag-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 = {
    "hashtags": [],
    "keywords": [],
    "locations": [],
}

# Run the Actor and wait for it to finish
run = client.actor("steadyfetch/instagram-hashtag-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 '{
  "hashtags": [],
  "keywords": [],
  "locations": []
}' |
apify call steadyfetch/instagram-hashtag-scraper --silent --output-dataset

```

## MCP server setup

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