I run a visa agency in Bali, and the other half of my week goes into Apify Actors. One of them takes hashtags and returns one row per post: author, caption, the hashtags and mentions pulled out of the caption, likes, comments, play count, media link, location.

Hashtags are the part of Instagram that looks easiest and behaves worst. Here is ten days of evidence, a 410, and a billing bug of my own.

Ten daily reads of one hashtag

Every Actor I publish gets a scheduled check: one fixed input, once a day, make sure rows come out. For this one the input never changes — {"hashtags": ["travel"], "hashtagSort": "top", "maxItemsPerTarget": 60}. From 1 to 10 November 2026 that produced ten runs of 60 rows each: 600 rows, 17 to 31 seconds per run.

Then I compared the runs to each other, which I should have done much earlier.

Rows 600
Distinct posts 552
Posts shared by any two of the ten days 0
Median post age at scrape time 8.2 hours
Oldest post in all 600 rows 27.6 hours
Median likes 685
Lowest likes 3

Zero. Not "a low overlap" — all 45 pairs of days came back empty. Ten reads of the top posts of one of the biggest hashtags on the platform, and the ranking turned over completely every night.

That is not what a top-posts ranking does. A top-posts ranking is sticky: the best post under #travel this week should still be there tomorrow. What I had was a recency window with ranking inside it — posts from the last day, ordered by engagement among themselves. A post with 3 likes made the cut because it was new. A Venice reel with 838,000 likes never appeared once, because it is from 2024.

"Average engagement under #travel is 685 likes" is a sentence I could easily have put in a dashboard. It is wrong by an order of magnitude, and wrong in a direction nobody questions.

The 410 that explained it

On 15 November I ran my usual direct probe against the upstream endpoint, outside the Actor:

GET /v2/hashtag/medias/top?name=travel 410 {"detail":"Gone since 2026-11-10: Instagram no longer serves hashtag sections. Use /g2/hashtag/medias instead.","exc_type":"EndpointDeprecated"}  GET /v2/hashtag/medias/recent?name=travel 410 {"detail":"Gone since 2026-11-10: Instagram no longer serves hashtag sections. There is no replacement for the recent tab; /g2/hashtag/medias returns hashtag media ranked by relevance."} 

Instagram dropped the "sections" layout that hashtag pages used to be built from — the thing that produced the two tabs, Top and Recent. One feed is left, and it is ranked by relevance.

The replacement is a different planet. Nine pages of it for #travel, 168 posts:

old top feed (600 rows) new feed (168 rows)
Median likes 685 6,291
Maximum likes 108,604 22,743,295
Median post age 8.2 hours 71 days
Oldest post 27.6 hours 1,691 days
Older than 30 days 0 % 58 %
Reels / photos / carousels 485 / 20 / 95 101 / 42 / 25

The old feed was all-reels-from-today. The new one has photos in it, carries posts four and a half years old, and its weakest entries still beat the old feed's median. Coverage changed too: the old endpoint returned nothing at all for #wildlife, #bali and #balivilla — the sort of gap that makes you blame the tag. All three come back in full now, and the case-sensitivity is gone with it: the old source needed #Travel with a capital T on some days, a retry loop I have now deleted.

Page size is 8 on the first page and 20 after that, cursor in next_page_id, and page one is not stable: five reads in a row gave 12 distinct posts, of which 4 appeared in all five.

Five days of green runs on a dud fallback

The Actor has a fallback: when its own source has no posts for a hashtag, it asks a partner API. On 10 November the main feed died, the fallback took over, and every run kept finishing SUCCEEDED with rows in the dataset. My alert module fires on crashes, timeouts and empty results. It has nothing to say about rows that are merely worse.

How much worse: the fallback rows for #wildlife had a median of 1 like and playCount empty on every single one, where our own feed had been returning a median of 20,425 plays per video. For five days anyone pulling #wildlife got 27 rows that look like a hashtag feed and are nothing of the sort.

A green run is not a working run. What catches this is diffing today's run against last week's distribution — median likes, share of rows with a play count, share of rows from the fallback — not against zero.

48 of my 600 rows were the same post twice, and I charged for them

While diffing the runs I checked something else: duplicates inside a single run. The old feed stacked the same reel into more than one section of the page, and my pager faithfully delivered each copy.

  • 60 rows per run were 55 distinct posts in eight of the ten runs, 56 in the other two.
  • Across the ten runs: 48 duplicate rows out of 600.
  • They clustered at the top. Duplicate rows landed at positions 4–17, never deeper, except one at 35.
  • A 10-row read of #bali was 6 posts — 4 of the 10 rows were repeats. The smaller your maxItemsPerTarget, the bigger the share you lose.

The Actor charges per delivered row, so those were cents charged for nothing — and worse, the duplicates ate the row limit the buyer had set. A post nobody saw displaced a post they paid to see.

The fix is six lines: remember the media id, drop the repeat before delivery, do not count it toward the limit. Shipped the same day, same treatment for profile posts and comments. A 24-row verification run came back as 24 distinct posts.

What "Most recent" means now

Instagram removed the Recent tab and the upstream says plainly there is no replacement. I kept the input field — people have scheduled runs that use it — and made it do the only honest thing: sort the posts the one remaining feed returned, newest first, and say so in a free notice row:

"Most recent" now means "the newest of the posts this feed returned", not "the newest posts under the hashtag".

Relabelling a relevance ranking as a date ranking would have been invisible to every buyer and wrong for all of them.

A real row

From a run on 15 November, #balivilla — a tag the old feed had nothing for:

{ "type": "hashtag-post", "postUrl": "https://www.instagram.com/p/C-FZQ9zSFSr/", "mediaType": "video", "ownerUsername": "suasarealestate", "ownerFullName": "Bali Real Estate - Elevated living", "ownerIsVerified": false, "takenAt": "2024-07-31T10:49:51.000Z", "caption": "Be careful what you wish for 🤍✨\n\n5 Bedrooms | Leasehold | USD 1,350,000…", "hashtags": ["balivilla", "leaseholdbali", "canggu", "…30 in total"], "likeCount": 367771, "commentCount": 636, "playCount": 5270692, "videoDurationSeconds": 7.666, "locationName": "Canggu, Bali", "hashtag": "balivilla", "scrapedAt": "2026-11-15T13:17:58.648Z" } 

Field coverage, measured on the 600-row sample rather than promised: playCount on 485 of 485 videos, locationName on 338 of 600, caption on 597 of 600, 468 distinct owners among 552 posts, and 537 of 600 captions actually contained the tag I searched for. The other 63 are in the feed on Instagram's judgement, not on their caption text — tags in the first comment, edited captions, or the ranker's own idea of relevance. If your pipeline assumes the tag is in hashtags, that is a 10 % surprise.

Calling it

curl -X POST "https://api.apify.com/v2/acts/lergassy~instagram-hashtag-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"hashtags":["balivilla","canggu"],"hashtagSort":"top","maxItemsPerTarget":30}' 

That is Instagram Hashtag Scraper. Pay per event, no run fee: $0.0025 per hashtag looked up, $0.0004 per post delivered — 30 posts from two tags is $0.017. A hashtag that returns nothing is not charged and gets a free notice row naming itself, so a typo costs zero and tells you. Filter type != "notice" and the dataset is one post per row.

When this is the wrong tool

  • You want a time series of a hashtag. A relevance ranking cannot give you one. Collect your own: run daily, store by mediaId, treat each run as a sample.
  • You want "all posts under #tag". Nobody can give you that any more. The Recent tab was the closest thing and it is gone.
  • You want an engagement benchmark. Compute it from the rows' own likeCount and playCount and state the window. "Median 6,291 likes among 168 relevance-ranked posts" is defensible; "average engagement under #travel" is not.
  • You want one account's posts. Use a profile scraper. A tag feed shows you 468 strangers, not a brand's archive.

The whole investigation was a GET over run history and forty lines of Python, and it found a dead endpoint, a mislabelled feed, five days of silently degraded output and a billing bug. If you have a scheduled check that only asserts "rows > 0", go diff two of its runs against each other today.