Meta's Instagram API can't search. It has no account or keyword search, its Business Discovery feature needs the exact username of a professional account, and its Hashtag Search allows 30 unique hashtags per account in any 7 days. So "find the accounts, hashtags and posts for my niche" (the first step of creator outreach, competitor research or a content plan) needs another route.
This guide uses ScrapingBot's Instagram search endpoints to turn one keyword into three CSV files: accounts with followers, type and category, hashtags with their post counts, and the top reels with plays and likes. It also covers what the results leave out and where they mislead. Every response and output below is real, collected for "latte art" on October 20, 2026 at about 11:50 AM ET.
Before you start
- A ScrapingBot API key. Create a free account for 100 credits: enough for the default run below (90 credits at most). No card needed.
- Python 3.9 or newer and
pip install requests.
Five search endpoints, one keyword
Every Instagram call is a POST to https://scrapingbot.io/api/v1/instagram with the endpoint and its parameters in the body, and costs 5 credits. Here is what each search endpoint returned for "latte art":
| Endpoint | Returns | "latte art" |
|---|---|---|
/search/users_by_keyword | Accounts: username, display name, ID, verified badge, latest story time | 47 accounts |
/search/hashtags_by_keyword | Hashtags with their post counts | 55 hashtags |
/search/places_by_keyword | Places (Instagram locations) with an address line | 15 places |
/search/global | All three lists in one call | 47 + 55 + 15 |
/search/posts | Top posts, with plays, likes, comments and the author | 8 reels |
/search/global returned exactly the same three lists as the three separate calls, so it saves 10 credits whenever you want more than one of them. None of the five has a next page: one call is the whole answer.
curl -X POST https://scrapingbot.io/api/v1/instagram \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endpoint": "/search/global", "params": {"keyword": "latte art"}}'
{
"success": true,
"data": {
"status": "ok",
"query": "latte art",
"users": [
{ "position": 0, "user": { "id": "23778095", "username": "coffeestation_latteart", "full_name": "Asaf Rauch - Barista Swag Latte Art", "is_verified": false, "latest_reel_media": 1792486993, "profile_pic_url": "https://scontent-dus1-1.cdninstagram.com/…", … } },
…
],
"hashtags": [
{ "position": 0, "hashtag": { "id": "17841593500078274", "name": "lattéart", "media_count": 25854 } },
{ "position": 1, "hashtag": { "id": "17843719849050484", "name": "lattearttulip", "media_count": 32030 } },
…
],
"places": [
{ "position": 1, "place": { "title": "Latte Art", "subtitle": "Misgav Am St 12, Tel Aviv-Yafo", "location": { "pk": "102245109084938", "name": "Latte Art", … } } },
…
]
},
"duration": "0.92",
"statusCode": 200,
"creditsUsed": 5
}
What an account result tells you, and what it doesn't
An account result is short. It is enough to decide whom to look up, not to rank anyone:
| Field | In the 47 "latte art" results |
|---|---|
username, full_name, id | Always present. Key your records on id: it survives a rename. |
is_verified | 3 of 47 had the badge. |
latest_reel_media | A Unix timestamp of the account's latest story, or 0 when it has none up. 16 of 47 had posted a story in the previous 24 hours, a cheap sign of who is active right now. (The stories endpoint returned the same timestamp for the first account.) |
| Follower count, bio, category | Not in search results. Look each account up with /user/by_username (5 credits). |
Two things in the lookups matter for a shortlist. account_type says what kind of account it is: 1 personal, 2 business, 3 creator. Among the 47 it was 23 creators, 19 businesses and 5 personal accounts. is_business, which sounds like the same thing, was false on all 47, cafés included, so don't use it. And category, the label under the name, was set on 29 of 47: "Coffee shop", "Cafe", "Digital creator", "Personal blog" and so on.
Search matches names, not topics
Instagram's account search looks at usernames and display names. It doesn't know what an account posts, so a keyword brings in whatever is named after it. Among the 47 "latte art" accounts:
- A nail salon.
@latte_.nailartis a salon in Kaohsiung. Its bio is written in decorative Unicode letters (𝙣𝙖𝙞𝙡 𝙨𝙖𝙡𝙤𝙣), which a plain"nail" in biocheck does not match. Normalizing withunicodedata.normalize("NFKC", …)turns them back into ordinary letters first. - Cafés that are called Latte Art. Three accounts, in Lebanon, Iraq and a French-speaking market, all say they have served coffee, tea and desserts "since 2003". They are cafés with that name, not latte art accounts.
- Competition and school accounts, such as the Latte Art Grading System's national chapters: on topic, but small, and organizations rather than creators.
Search order isn't size order either. The largest account among the 47, @latteartcity with 1,049,629 followers, was the 21st suggestion. And the biggest names in a niche may not be in the account results at all, which is where post search helps.
Hashtags: choose by size
Hashtag search returns tags and their post counts, in Instagram's order rather than by size: the biggest, #latteart, came eighth. The 55 tags ranged from 1,264 posts to 12,184,668:
| Size | Tags | Examples (posts) |
|---|---|---|
| Over 1 million | 1 | #latteart 12,184,668 |
| 100K to 1M | 3 | #latteartist 508,836, #latteartvideo 240,158 |
| 10K to 100K | 31 | #latteartpractice 72,530, #latteartcompetition 23,156, #latteartfail 13,904 |
| Under 10K | 20 | #latteartbeginner 8,281, #latteartgradingsystem 6,870 |
The size tells you what a tag is for. A post in a 12-million-post tag is gone from its recent feed in minutes; a 10K to 100K tag like #latteartpractice is a community you can actually show up in, and the small ones name exact sub-niches (beginners, competitions, a country). Expect near-duplicates: #latteart☕️ (7,150 posts) and #latteart☕ (1,264) differ only in an invisible emoji variation selector, and #lattéart has its own 25,854.
Hashtag search gives you the tags, not their posts. To see what posts well for a keyword, use post search.
Top posts: what already works for the keyword
/search/posts takes a query and returns one page of top posts. For "latte art" that was 8 reels, each with the full post object (177 keys) and its author:
{
"success": true,
"data": {
"data": {
"count": 8,
"items": [
{
"id": "3903666952097665853",
"code": "DYsnGkRNpM9",
"media_type": 2,
"product_type": "clips",
"taken_at": 1779613783,
"like_count": 127943,
"comment_count": 332,
"play_count": 2411638,
"caption": { "text": "You need 🌹☕️ follow @djiibarista …", … },
"user": { "id": "56027267367", "username": "djiibarista", "is_verified": false, "follower_count": 16556, … },
…
},
…
]
},
"pagination_token": "GU1VCxopAAdzLB4TBl5bFgRKMEIy…"
},
"duration": "7.34",
"statusCode": 200,
"creditsUsed": 5
}
- These are top posts, not recent ones. The 8 were posted between August 31, 2024 and September 7, 2026. Use post search to learn what performs, not to monitor what's new.
- Plays were filled in.
play_countwas set on all 8 (195,954 to 2,411,638), and every item carries the author'sfollower_count. On profile feeds reel play counts are often empty, so don't count on them elsewhere. - Small accounts win here. The most-played reel, 2.4 million plays, came from
@djiibaristawith 16,556 followers. - It finds people account search misses. Only 1 of the 8 authors was among the 47 account results. The others included
@tannercolsoncoffee, with 1,936,170 followers, the largest account found for the keyword. - There is no next page. The response includes a
pagination_token, but the endpoint doesn't accept one: 8 posts is the answer.
The script
Save this as ig_search.py. It makes two search calls, looks up the authors of the top posts and then Instagram's suggested accounts up to a budget you set, within your plan's concurrency, sets aside accounts matching words you exclude, and writes three CSV files.
import csv
import sys
import threading
import time
import unicodedata
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
import requests
API = "https://scrapingbot.io/api/v1/instagram"
HEADERS = {"x-api-key": "YOUR_API_KEY"}
CONCURRENCY = 1 # your plan's limit: Free 1, Starter 10, Startup 50
TYPES = {1: "personal", 2: "business", 3: "creator"}
credits = {"used": 0}
lock = threading.Lock()
class OutOfCredits(Exception):
pass
def call(endpoint, params, tries=4):
"""One Instagram call: 5 credits on success. Failed calls are refunded."""
for attempt in range(tries):
res = requests.post(API, headers=HEADERS, timeout=60,
json={"endpoint": endpoint, "params": params})
body = res.json()
if body.get("success"):
with lock:
credits["used"] += body.get("creditsUsed", 0)
return body["data"]
if res.status_code in (401, 402): # bad key or out of credits
raise OutOfCredits(body.get("error"))
if res.status_code in (400, 404): # bad input or no such account
raise LookupError(body.get("error"))
time.sleep(2 ** attempt) # 408, 429 or 5xx: wait and try again
raise LookupError(f"kept failing: {body.get('error')}")
def plain(text):
"""Fold fancy Unicode letters (𝙣𝙖𝙞𝙡 -> nail) and case, so word checks work."""
return unicodedata.normalize("NFKC", text or "").casefold()
def profile(candidate):
try:
p = call("/user/by_username", {"username": candidate["username"]})
except (LookupError, OutOfCredits) as err: # keep going: what's paid for is saved
return {**candidate, "status": str(err)}
return {**candidate, "status": "ok", "name": p.get("full_name") or "",
"followers": p.get("follower_count"), "following": p.get("following_count"),
"type": TYPES.get(p.get("account_type"), ""), "category": p.get("category") or "",
"verified": p.get("is_verified"), "private": p.get("is_private"),
"website": p.get("external_url") or "",
"bio": " ".join((p.get("biography") or "").split())}
def save(path, rows, fields):
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
def research(keyword, profiles=16, exclude=()):
found = call("/search/global", {"keyword": keyword}) # accounts, hashtags, places
reels = call("/search/posts", {"query": keyword})["data"]["items"]
# Accounts: the authors of the top posts first, then Instagram's suggestions,
# up to `profiles` lookups (5 credits each).
now = datetime.now(timezone.utc).timestamp()
candidates = {}
for item in reels:
name = item["user"]["username"]
candidates.setdefault(name, {"username": name, "source": "top post", "story_24h": ""})
for r in found.get("users", []):
u = r["user"]
story = u.get("latest_reel_media") or 0 # time of the latest story, 0 if none
c = candidates.setdefault(u["username"], {"username": u["username"], "source": ""})
c["source"] = (c["source"] + " + " if c["source"] else "") + "account search"
c["story_24h"] = now - story < 86400
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
looked_up = list(pool.map(profile, list(candidates.values())[:profiles]))
accounts = [a for a in looked_up if a["status"] == "ok"]
failed = [a for a in looked_up if a["status"] != "ok"]
words = plain(keyword).split()
for a in accounts:
text = plain(" ".join([a["username"], a["name"], a["bio"], a["category"]]))
a["off_topic"] = any(w in text for w in exclude)
a["keyword_in_bio"] = all(w in plain(a["bio"]) for w in words)
keep = sorted((a for a in accounts if not a["off_topic"]),
key=lambda a: a["followers"] or 0, reverse=True)
hashtags = sorted(({"hashtag": r["hashtag"]["name"], "posts": r["hashtag"]["media_count"]}
for r in found.get("hashtags", [])), key=lambda h: -h["posts"])
posts = [{"url": f"https://www.instagram.com/p/{i['code']}/",
"author": i["user"]["username"], "author_followers": i["user"].get("follower_count"),
"plays": i.get("play_count"), "likes": i.get("like_count"),
"comments": i.get("comment_count"),
"date": datetime.fromtimestamp(i["taken_at"], timezone.utc).date().isoformat(),
"caption": " ".join(((i.get("caption") or {}).get("text") or "").split())[:80]}
for i in reels]
return keep, [a for a in accounts if a["off_topic"]], failed, hashtags, posts
if __name__ == "__main__":
keyword = sys.argv[1]
profiles = int(sys.argv[2]) if len(sys.argv) > 2 else 16 # 10 + 16 x 5 = 90 credits at most
exclude = [plain(w) for w in sys.argv[3].split(",")] if len(sys.argv) > 3 else []
try:
accounts, dropped, failed, hashtags, posts = research(keyword, profiles, exclude)
except OutOfCredits as err: # the searches themselves failed: nothing to save
sys.exit(err)
slug = keyword.replace(" ", "_")
print(f"Accounts for '{keyword}' ({len(accounts)} kept, {len(dropped)} off topic):")
for a in accounts[:12]:
story = "story today" if a["story_24h"] is True else ""
print(f" @{a['username']:<26} {a['followers'] or 0:>9,} {a['type']:<9} "
f"{a['category'][:18]:<18} {story}")
for a in dropped:
print(f" dropped @{a['username']} ({a['category'] or a['bio'][:30]})")
for a in failed:
print(f" not looked up @{a['username']}: {a['status']}")
bands = [("1M+", 1_000_000, float("inf")), ("100K-1M", 100_000, 1_000_000),
("10K-100K", 10_000, 100_000), ("under 10K", 0, 10_000)]
print(f"Hashtags ({len(hashtags)}):")
for label, low, high in bands:
band = [h for h in hashtags if low <= h["posts"] < high]
print(f" {label:<9} {len(band):>2} " + ", ".join(f"#{h['hashtag']}" for h in band[:4]))
print(f"Top posts ({len(posts)}):")
for p in sorted(posts, key=lambda p: -(p["plays"] or 0)):
print(f" {p['plays'] or 0:>10,} plays {p['likes'] or 0:>8,} likes @{p['author']:<20} {p['date']}")
save(f"{slug}_accounts.csv", accounts + dropped, ["username", "name", "followers", "following",
"type", "category", "verified", "private", "website", "story_24h", "keyword_in_bio",
"source", "off_topic", "bio"])
save(f"{slug}_hashtags.csv", hashtags, ["hashtag", "posts"])
save(f"{slug}_top_posts.csv", posts, list(posts[0]) if posts else ["url"])
print(f"{credits['used']} credits; wrote {slug}_accounts.csv, {slug}_hashtags.csv, {slug}_top_posts.csv")
- Two calls cover the search.
/search/globalbrings accounts, hashtags and places for 5 credits, and/search/poststhe top reels for 5 more. - Lookups are capped. Top-post authors come first, then Instagram's suggestions, up to
profileslookups (16 by default, so a run costs at most 90 credits). The number of top posts varies (8 here, 9 in our docs example), so the cap, not the post count, sets the cost. - "story today" comes from the search result (
latest_reel_mediawithin the last 24 hours), so it costs nothing extra. Top-post authors that weren't in the account results show it blank. plain()normalizes before matching. NFKC folds decorative letters into ordinary ones, andcasefold()handles case, for both the exclude list and thekeyword_in_biocolumn.- Failures are handled once, in
call(). A missing account (404) is skipped, a timeout (408) or a busy plan (429) is retried with a growing pause, and a bad key or an empty balance stops the lookups without losing what you paid for: the CSV files are still written, and the accounts not looked up are listed. Failed calls are refunded.
Run it
python ig_search.py "latte art"
Accounts for 'latte art' (16 kept, 0 off topic):
@tannercolsoncoffee 1,936,170 creator
@latte_art_tutorials 471,247 creator story today
@coffeestation_latteart 302,131 business Shopping & retail story today
@latteartmania_tokyo 156,266 business Cafe story today
@frenchpress.latteart 100,833 creator Art
@baristabrian 73,436 creator Artist
@worldoflatteart 48,488 creator
@shawn__1023 34,019 creator Digital creator
@latteart.iraq 22,962 business story today
@barista_chow 22,886 business Art
@latteartlebanon 18,816 creator Coffee shop story today
@latteartmania_ginza 17,812 business Cafe story today
Hashtags (55):
1M+ 1 #latteart
100K-1M 3 #latteartist, #latteartporn, #latteartvideo
10K-100K 31 #latteartpractice, #latteartsg, #latteartlicious, #latteartphilippines
under 10K 20 #latteartbeginner, #latteartcowboy, #latteartworkshop, #latteartchampionship
Top posts (8):
2,411,638 plays 127,943 likes @djiibarista 2026-05-24
1,477,194 plays 80,575 likes @tannercolsoncoffee 2026-06-04
1,068,349 plays 35,742 likes @worldoflatteart 2026-01-26
937,279 plays 25,377 likes @shawn__1023 2024-08-31
614,874 plays 26,318 likes @barista_chow 2026-09-07
338,069 plays 6,300 likes @latte_art_tutorials 2025-12-23
241,922 plays 4,779 likes @baristabrian 2025-11-10
195,954 plays 13,528 likes @b_o_n_p_s_y 2026-03-21
90 credits; wrote latte_art_accounts.csv, latte_art_hashtags.csv, latte_art_top_posts.csv
That is 2 searches and 16 lookups (the 8 top-post authors, then 8 suggestions; one author was also a suggestion): 90 credits, inside the free tier. To profile every account and drop the off-topic ones, raise the budget and pass the words to exclude:
python ig_search.py "latte art" 60 nail,salon
Accounts for 'latte art' (53 kept, 1 off topic):
@tannercolsoncoffee 1,936,170 creator
@latteartcity 1,049,629 creator Digital creator story today
@latte_art_tutorials 471,247 creator story today
@coffeestation_latteart 302,131 business Shopping & retail story today
@latteartmania_tokyo 156,266 business Cafe story today
@frenchpress.latteart 100,833 creator Art
@baristabrian 73,436 creator Artist
@worldoflatteart 48,488 creator
@shawn__1023 34,019 creator Digital creator
@latteartseruveni 27,434 creator Digital creator story today
@latteart.iraq 22,962 business story today
@barista_chow 22,886 business Art
dropped @latte_.nailart (𝙆𝙖𝙤𝙝𝙨𝙞𝙪𝙣𝙜 𝙣𝙖𝙞𝙡 𝙨𝙖𝙡𝙤𝙣𐙚 ᯓ近三民、左營 )
…
280 credits; wrote latte_art_accounts.csv, latte_art_hashtags.csv, latte_art_top_posts.csv
All 54 accounts cost 280 credits, about $0.05 on the Starter plan. @latteartcity, the 21st suggestion, is now second, and the nail salon is set aside even though its bio uses decorative letters. Accounts set aside are still written to the CSV with off_topic set, so you can check them.
What to do with the three files
latte_art_accounts.csv has one row per account:
username,name,followers,following,type,category,verified,private,website,story_24h,keyword_in_bio,source,off_topic,bio
tannercolsoncoffee,Tanner Colson,1936170,491,creator,,False,False,http://beacons.ai/tannercolsoncoffee/,,False,top post,False,Helping you make better coffee
latteartcity,Goran Dujic,1049629,1760,creator,Digital creator,True,False,http://latteartcity.com/app,True,True,account search,False,☕ The Home of Latte Art 🎓 Learn • Practice • Get AI Feedback …
latte_art_tutorials,Asaf Rauch | EVO + Vortex System,471247,64,creator,,False,False,http://www.baristaswag.com/complete-set,True,True,top post + account search,False,Latte art education by Asaf Rauch …
- Outreach to creators: keep
type= creator, sort by followers, and prefer rows withstory_24hor a top post (source): they are active and their content travels. In the full run, 28 of 54 accounts were creators and 27 mention latte art in their bio. - Local businesses: keep
type= business with a category like "Cafe" or "Coffee shop", and use thewebsitecolumn. - Content plan:
latte_art_hashtags.csvis sorted by post count, so pick a few from the 10K to 100K band, and read the captions inlatte_art_top_posts.csvfor formats that already reach millions of plays.
Then go deeper on the shortlist. Followers alone don't say whether an audience is engaged: calculate the engagement rate for a list of creators from their recent posts, or track their follower counts over time.
Places
/search/places_by_keyword searches Instagram locations by name. "latte art" returned 15, mostly places named Latte Art (in Tel Aviv, Amchit and Bushey, for example) and a few training venues, each with an address line in subtitle. "coffee tokyo" returned 13, such as Inoda Coffee Tokyo and Bicycle Coffee Tokyo. "blue bottle coffee" returned none at all, so search places by the words in their names, and treat an empty list as "no match", not an error.
Cost and limits
- 5 credits per call, whatever the number of results. A keyword is 10 credits for the two searches plus 5 per account you look up. Failed calls are refunded.
- No paging. About 50 accounts, 55 hashtags, 15 places and 8 posts per keyword is what you get. For more, search related keywords ("latte art tutorial", "home barista") and de-duplicate on the account ID.
- Results change. Search reflects Instagram's suggestions at that moment, and story times move every day. Save the date with each run if you compare runs.
- Optional fields are optional.
media_countwasnullon every profile we looked up,is_businesswas alwaysfalse, andcategorywas empty on 18 of the 47 suggestions. - Concurrency follows your plan: Free allows 1 request at a time, Starter 10 and Startup 50 (every plan's limit). Set
CONCURRENCYto match.
Where to go from here
- Check one account by hand first? The free Instagram profile stats tool shows followers, recent likes and comments and an engagement rate, with no sign-up.
- The same research on TikTok: search TikTok videos by keyword, with the top creators and sounds for a keyword, and the free TikTok engagement rate calculator.
- Comparing Instagram data providers? The best Instagram scraper APIs in 2026 compares seven on price and coverage.
- The search reference lists every parameter, and the Instagram API page lists every endpoint.
Common questions
Is there an Instagram search API?
Not from Meta for accounts or keywords. Its Instagram API has no account search, Business Discovery needs the exact username of a professional account, and Hashtag Search is limited to 30 unique hashtags per account in any 7 days. ScrapingBot has five search endpoints (accounts, hashtags, places, all three at once, and posts) at 5 credits a call, with no Instagram login.
How do I search Instagram users by keyword with Python?
POST {"endpoint": "/search/users_by_keyword", "params": {"keyword": "latte art"}} to https://scrapingbot.io/api/v1/instagram with your key in the x-api-key header. On October 20, 2026 it returned 47 accounts with their username, display name, numeric ID, verified badge and the time of their latest story, but no follower counts: look each account up with /user/by_username for those.
Can I get the posts for an Instagram hashtag?
Hashtag search returns hashtags and how many posts each has (55 hashtags for "latte art", from 1,264 to 12,184,668 posts), not the posts themselves. For posts, /search/posts returns one page of top posts for a keyword: 8 reels for "latte art", with play, like and comment counts and the author's follower count. It has no next page.
Why does Instagram search return accounts that are off topic?
Account search matches names and usernames, not what an account posts. "latte art" returned a nail salon called latte_.nailart and three cafés named Latte Art. Look the accounts up and filter on their bio and category; normalize the text first, because bios written in decorative Unicode letters do not match plain words.
How much does an Instagram keyword search cost?
5 credits per call. The script in this guide makes two search calls (10 credits) plus 5 credits per account it looks up: at most 90 credits for the default run (16 lookups), which fits in the 100 free credits, or 280 credits to profile all 54 accounts, about $0.05 on the Starter plan ($49.99 for 275,000 credits).
Do I need an Instagram account or login?
No. Search and profile lookups read public data; you only need a ScrapingBot API key.