Threads Has No Public API in 2026: Here Is How to Get Post and Profile Data Anyway
Every field you can collect from Threads without an API: post text, engagement counts, media, profiles. No login required, from $1 per 1,000 posts, plus a comparison of the scrapers that do it.
The actor referenced in this article. Pay only for results delivered.
When Meta launched Threads in July 2023, it grew to 100 million users in under a week. That is a significant audience for brand monitoring, competitive research, and social listening. There is one problem: Meta has not released a public Threads API.
The Instagram Graph API does not cover Threads. The oEmbed endpoint covers only individual public posts you already have URLs for, which is not useful for discovery. The Meta Developer platform has no Threads-specific permission scope. If you want structured Threads data at scale, you need a different approach.
TL;DR: Threads has no public API as of mid-2026. Profile data and post content are publicly accessible via the web interface. A scraper navigates the public profile pages, extracts structured data from the embedded JSON, and paginates through posts without requiring authentication. Fields include post text, like counts, reply counts, timestamps, and profile metadata.
Why Meta Has Not Released a Threads API
The short answer is product maturity. Threads launched fast, built on Instagram’s infrastructure, and the engineering team has been iterating on core features rather than developer tooling. Meta’s public statement has been that API access is planned but not yet available.
There is also a regulatory dimension. The EU’s Digital Markets Act requires large platforms to open APIs for interoperability, and Meta is navigating how Threads fits into that framework alongside its existing platforms. This is a meaningful constraint. Until the regulatory picture settles, a formal API is less likely.
The ActivityPub integration (Threads supports the fediverse protocol) gives you some signal for public profiles that opt in, but it is incomplete and not useful for bulk data collection.
Meta does run a Threads API for accounts that want to publish and manage their own posts. It works through per-account access tokens and app review, and it is not a way to read other accounts’ posts at volume, which is what monitoring and research need.
What Is Publicly Accessible
Threads public profiles are accessible without authentication. For any public account, you can view:
- Profile information: username, display name, bio, follower count, following count, verification status
- Posts: text content, post type (thread, reply, repost), timestamps, like count, reply count, repost count
- Media attachments: image URLs and video thumbnails (not the video files themselves)
- Reply threads: the chain of replies in a thread conversation
What is not accessible without a logged-in session:
- Private accounts (obviously)
- Direct messages
- Notifications
- Draft content
- Historical data beyond what the web UI surfaces (a logged-out visitor sees only the latest handful of posts on a profile)
- Impression and reach counts, which only the post author sees
- Deep reply trees (you get the direct replies to a post, not every nested level)
- A real-time stream (there are no webhooks, so you poll on a schedule)
The data that is accessible covers most brand monitoring and competitive intelligence use cases. If you want to track what a brand is saying, how their audience is engaging, and how their follower count is trending, the public profile data is sufficient.
How the Scraper Navigates Without Auth
The Threads web interface is a React application that loads profile and post data from internal GraphQL endpoints. These are not documented, not versioned for external use, and not stable across releases. However, the page HTML includes a __NEXT_DATA__ JSON blob or a script tag with structured data that the React app uses to render the initial page state.
The scraper works by:
- Fetching the public profile URL (
https://www.threads.net/@username) - Extracting the embedded JSON from the page source
- Parsing the profile metadata and the initial post batch
- Following the pagination cursor to load additional posts
The scraper does not log in. It does not touch the internal GraphQL endpoints directly (which are more fragile). Instead it works from the public HTML in the same way a search engine crawler would. This approach is slower than an authenticated API call but is stable against minor API changes because the page HTML rendering is separate from the internal endpoint structure.
Rate limiting is handled by spacing requests and respecting the server’s response behavior. Threads will slow-down or temporarily block IPs that make requests at machine speed without any gaps. Good practice means building in delays, rotating requests if you are doing bulk pulls, and not hammering a single profile repeatedly.
Why Not Just Log In?
Most DIY Threads scripts you will find take the logged-in route instead. They copy the sessionid and csrftoken cookies from a browser signed in to Instagram, then call Threads’ internal GraphQL endpoint (/api/graphql) with a doc_id for each operation. A more stubborn version imitates the mobile app, sending the app’s user agent, an X-IG-App-ID header and a fixed device ID.
Both work for a while, and both need constant upkeep:
- The
doc_idhashes change with app releases, often every few weeks, so hard-coded IDs break without warning. - Session cookies expire, typically within about 90 days.
- Meta fingerprints devices and watches request timing across Instagram and Threads. A device ID that makes requests at machine speed gets flagged, and so does one that rotates too often.
- The account itself carries the risk. A flagged account can be rate limited or banned, so never use a personal one.
The logged-out route avoids all of that. The trade-off is that you only get what a logged-out visitor sees, which is the list under What Is Publicly Accessible.
Fields Returned Per Post
Here is the data structure for a single Threads post as returned by the scraper:
{
"post_id": "3391234567890123456",
"code": "abc123",
"url": "https://www.threads.com/@threadsindia/post/abc123",
"username": "threadsindia",
"user_full_name": "Threads India",
"user_verified": false,
"posted_at": "2026-06-18T10:23:14.000Z",
"text": "The next generation of conversation starts here.",
"like_count": 1842,
"reply_count": 203,
"repost_count": 94,
"quote_count": 11,
"has_media": true,
"media_type": "image",
"media_urls": [
"https://scontent-bom1-1.cdninstagram.com/v/t51.2885-15/..."
],
"is_reply": false,
"parent_post_id": null,
"is_repost": false,
"original_post_id": null,
"hashtags": [],
"mentions": [],
"urls": [],
"user_pic_url": "https://scontent-bom1-1.cdninstagram.com/...",
"posted_at_human": "Thu, 18 Jun 2026 10:23:14 GMT",
"scraped_at": "2026-06-18T11:02:50.117Z"
}
Threads field names and what the scraper calls them
Threads’ own web payload uses different names from the ones in the dataset
above, and those internal names are what you will find if you read the
network tab. The scraper flattens text_post_app_info and renames the
counters, so the mapping is worth having side by side.
| Name in the Threads payload | Field in the dataset | Notes |
|---|---|---|
pk or id | post_id | Numeric string, stable per post |
code | code | Short code used in the public post URL |
caption.text | text | Plain text, no entity markup |
taken_at | posted_at | Unix seconds in, ISO 8601 out |
like_count | like_count | Unchanged |
text_post_app_info.direct_reply_count | reply_count | Direct replies only, not the whole thread |
text_post_app_info.repost_count | repost_count | Unchanged |
text_post_app_info.quote_count | quote_count | Count of quotes, not the quoted post |
text_post_app_info.reply_to_author.pk | parent_post_id | Null when the post is not a reply |
text_post_app_info.share_info.reposted_post.pk | original_post_id | Null when the post is not a repost |
Two names people look for are not in the payload at all. There is no
quote_post_id and no reply_to_id on a public Threads post: you get
quote_count (how many times it was quoted) and reply_to_author.pk
(who it answered), which the dataset exposes as parent_post_id. To walk
a thread you follow parent_post_id back up, one call per level.
Profile-level fields come attached to each post rather than as a separate record:
{
"username": "threadsindia",
"user_full_name": "Threads India",
"user_verified": false,
"user_pic_url": "https://scontent-bom1-1.cdninstagram.com/..."
}
There is no follower count, following count or bio in there, and that is not an omission on our side. Threads only serves those numbers to a logged-in session, so no scraper can return them without an account, and anything that claims to is either logged in or guessing.
Python Example: Fetch a Profile
The managed Threads scraper handles the extraction and pagination. You call it through the Apify API:
import requests
import time
APIFY_TOKEN = "your_apify_token"
def fetch_threads_profile(username):
"""Fetch profile metadata and recent posts for a Threads username."""
# Start the actor run
run_url = f"https://api.apify.com/v2/acts/themineworks~threads-scraper/runs"
payload = {
"mode": "profile",
"profileUsernames": [username],
"maxPosts": 50,
"includeReplies": False,
}
resp = requests.post(
run_url,
json=payload,
params={"token": APIFY_TOKEN}
)
resp.raise_for_status()
run_id = resp.json()["data"]["id"]
# Wait for the run to finish
status_url = f"https://api.apify.com/v2/actor-runs/{run_id}"
while True:
status_resp = requests.get(status_url, params={"token": APIFY_TOKEN})
status = status_resp.json()["data"]["status"]
if status in ("SUCCEEDED", "FAILED", "TIMED-OUT", "ABORTED"):
break
time.sleep(3)
if status != "SUCCEEDED":
raise RuntimeError(f"Scraper run ended with status: {status}")
# Retrieve results
dataset_id = status_resp.json()["data"]["defaultDatasetId"]
results_url = f"https://api.apify.com/v2/datasets/{dataset_id}/items"
results = requests.get(results_url, params={"token": APIFY_TOKEN}).json()
# the last row of every run is an unbilled info record, not a post
return [r for r in results if not r.get("_type")]
profile_data = fetch_threads_profile("zuck")
print(f"Fetched {len(profile_data)} posts")
for post in profile_data[:3]:
print(f"[{post['posted_at']}] {post['like_count']} likes: {post['text'][:80]}")
Python Example: Pull Posts with Pagination
To cover several accounts, pass them all in profileUsernames and page through the dataset yourself. maxPosts caps the whole run (up to 500), and a logged-out profile only shows its latest 4 or 5 posts, so adding accounts is how you get a bigger total:
def bulk_monitor_accounts(usernames, posts_per_account=5):
"""Pull posts for a list of Threads accounts and return flat list."""
run_url = f"https://api.apify.com/v2/acts/themineworks~threads-scraper/runs"
payload = {
"mode": "profile",
"profileUsernames": usernames,
"maxPosts": min(posts_per_account * len(usernames), 500), # one cap for the whole run
"includeReplies": False,
}
resp = requests.post(
run_url,
json=payload,
params={"token": APIFY_TOKEN}
)
resp.raise_for_status()
run_id = resp.json()["data"]["id"]
# Poll until done
status_url = f"https://api.apify.com/v2/actor-runs/{run_id}"
while True:
r = requests.get(status_url, params={"token": APIFY_TOKEN}).json()
if r["data"]["status"] in ("SUCCEEDED", "FAILED"):
break
time.sleep(5)
dataset_id = r["data"]["defaultDatasetId"]
items_url = f"https://api.apify.com/v2/datasets/{dataset_id}/items"
all_posts = []
offset = 0
limit = 100
while True:
page = requests.get(
items_url,
params={"token": APIFY_TOKEN, "offset": offset, "limit": limit}
).json()
if not page:
break
all_posts.extend(p for p in page if not p.get("_type")) # skip the info row
if len(page) < limit:
break
offset += limit
return all_posts
# Monitor a competitor set
competitor_accounts = ["brandaccount1", "brandaccount2", "brandaccount3"]
posts = bulk_monitor_accounts(competitor_accounts, posts_per_account=5)
# Basic engagement analysis
import statistics
like_counts = [p["like_count"] for p in posts if "like_count" in p]
print(f"Total posts collected: {len(posts)}")
print(f"Median likes: {statistics.median(like_counts):.0f}")
print(f"Max likes: {max(like_counts)}")
Search, Hashtag and Single-Post Modes
Profile mode is one of four. The same actor also takes a keyword (search), a tag (hashtag) or a list of post URLs (post). Search and hashtag runs accept resultType of recent or top.
from apify_client import ApifyClient
client = ApifyClient(APIFY_TOKEN)
run = client.actor("themineworks/threads-scraper").call(run_input={
"mode": "search",
"searchQuery": "your brand",
"resultType": "recent",
"maxPosts": 200,
"monitorMode": True, # on scheduled re-runs, return only posts not delivered before
})
posts = [p for p in client.dataset(run["defaultDatasetId"]).iterate_items() if not p.get("_type")]
for p in posts[:5]:
print(p["username"], p["like_count"], p["reply_count"], p["url"])
For hashtags, swap in "mode": "hashtag", "hashtag": "ai" (no #). For single posts, use "mode": "post", "postUrls": [...], which returns each post with its quoted or parent context.
How many posts to expect
Threads does not serve every surface the same way to a logged-out visitor, so the yield depends on the mode:
| Mode | Typical yield per run |
|---|---|
| Search | tens to hundreds of posts |
| Hashtag | tens of posts |
| Post | one record per URL you pass |
| Profile | about 4 to 5 recent posts per username |
The profile figure is Threads’ limit, not the scraper’s. A logged-out profile shows its latest handful of posts and then repeats them, so raising maxPosts does not get you more. If you want many posts about a person or brand rather than by them, run a search on their name or handle and filter on username.
A single logged-out search feed stops at about 20 posts. The scraper gets past that by combining the recent and top feeds, the matching tag feed and repeated recency windows, then removing duplicates. Search and tag pages sometimes come back with no post data at all, which is a condition on Threads’ side. The run stops after one request when that happens and charges nothing, so retry after a short wait.
monitorMode is what makes a scheduled watch affordable. The scraper remembers which posts it already delivered for that input and bills only for new ones.
Use Cases
Brand monitoring. Track mentions of your brand name or product across public Threads accounts. Threads has no keyword search API, but search mode reads the same results a logged-out visitor gets, so you can watch a brand name or product term directly. Run it on a schedule with monitorMode on, and sort what comes back by likes and replies to see which mentions carry weight.
Social listening for competitive analysis. Pull posts from competitor accounts and analyze engagement patterns. Which content types (text-only vs. image) drive more replies? What posting cadence correlates with higher engagement? This analysis is straightforward once you have structured post data.
Content research for brand accounts. If you run a Threads presence, looking at what posts from similar accounts perform well gives you real data for content planning. Pulling a few hundred posts on your category’s keywords takes a few minutes. Doing it by hand takes hours.
Influencer vetting. Before a paid partnership, pull actual engagement numbers from a creator’s Threads account. Reported follower counts are easy to inflate. The ratio of follower count to actual reply and repost counts on recent posts is a useful signal for authentic reach. Also look at how often they post, how much is original versus reposted, and how they have handled other brands’ products in the past. Searching their handle surfaces the conversation around them as well as their own posts.
Newsroom and agency trend tracking. Media teams watch search and hashtag results to see what is gaining traction on Threads before it reaches bigger platforms. Threads is also where many brands now post informally, so a weekly pull can surface announcements that never get cross-posted to Instagram or Facebook.
Academic research. Threads is a significant venue for public discourse. Researchers studying social platform dynamics, misinformation spread, or public communication patterns need structured data. The scraper produces data in the format academic pipelines expect.
Social Listening With Claude
Once the posts are structured, a language model can do the reading. That matters on Threads because short posts trip up keyword classifiers (“I hate that I love this” reads as negative to a word list), while a model picks up the tone. The helpers below reuse client from the search example.
import json
import anthropic
claude = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
def search_threads(query, max_posts=100, result_type="recent"):
run = client.actor("themineworks/threads-scraper").call(run_input={
"mode": "search", "searchQuery": query,
"resultType": result_type, "maxPosts": max_posts,
})
return [p for p in client.dataset(run["defaultDatasetId"]).iterate_items() if not p.get("_type")]
def ask_claude(prompt, model="claude-opus-5", max_tokens=16000):
msg = claude.messages.create(model=model, max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}])
return next(b.text for b in msg.content if b.type == "text") # skip any thinking block
def engagement(p):
return p["like_count"] + 3 * p["reply_count"] # a reply takes more effort than a like
Brand mention triage
Classify mentions in batches of ten with a small, fast model, then pull out the ones that need a person to answer:
def classify_mentions(brand, posts, batch_size=10):
labelled = []
for i in range(0, len(posts), batch_size):
batch = posts[i:i + batch_size]
listing = "\n\n".join(
f"POST {n + 1} (@{p['username']}, {p['like_count']} likes, {p['reply_count']} replies):\n{p['text'][:500]}"
for n, p in enumerate(batch)
)
prompt = (
f"These Threads posts mention {brand}. For each post, in order, return an object with "
"sentiment (positive, negative, neutral or mixed), intent (complaint, praise, question, "
"comparison or mention), urgency (1-5) and needs_reply (true or false). "
f"Return only a JSON array of {len(batch)} objects.\n\n{listing}"
)
text = ask_claude(prompt, model="claude-haiku-4-5", max_tokens=2000)
try:
labels = json.loads(text.strip().removeprefix("```json").removesuffix("```"))
except json.JSONDecodeError:
labels = [{}] * len(batch)
labelled += [{**p, **label} for p, label in zip(batch, labels)]
return labelled
mentions = classify_mentions("YourBrand", search_threads("YourBrand"))
for m in mentions:
if m.get("needs_reply") and m.get("urgency", 0) >= 4:
print(m["urgency"], m["url"], m["text"][:100])
Trending conversations
Take the most engaged posts across a few category keywords and ask for the arguments people are having, not topic labels:
def trending_conversations(keywords, niche):
posts = {p["post_id"]: p for k in keywords[:5] for p in search_threads(k, 50, "top")}
top = sorted(posts.values(), key=engagement, reverse=True)[:40]
digest = "\n".join(
f"- [{p['like_count']} likes, {p['reply_count']} replies] @{p['username']}: {p['text'][:200]}"
for p in top
)
return ask_claude(
f"You follow the {niche} space. From these high-engagement Threads posts, name 4 to 6 "
"distinct conversations. For each, say what the argument is, why it is getting traction "
"now, how people feel about it, and what a brand could usefully add. Name the actual "
f"narratives, not broad categories.\n\n{digest}"
)
The same data also gives you share of voice. Search your category keywords, count how many posts mention each brand’s name variants, and hand the percentages plus a few sample posts per brand to Claude for a short read on who owns the conversation and whether those mentions are positive. With posts from competitor accounts added, the same approach produces draft posts in your own voice, based on what is already working in your niche.
A sensible cadence is the mention triage daily and the trend and share-of-voice reports weekly. Scraper cost scales with the number of posts delivered (see pricing).
Rate Limits and Responsible Scraping
There is no official rate limit because there is no official API. The practical limits come from Threads’ anti-bot infrastructure, which is similar to Instagram’s (they share infrastructure).
Sensible limits for sustained use: 200 to 500 posts per minute across all requests, with randomized delays between profile fetches (2 to 5 seconds). For bulk historical pulls of a single account, going faster than 100 posts per 30 seconds is where you start seeing increased error rates.
The scraper is built with these limits in mind. It does not exceed what a fast human user would do, which is the threshold that keeps it stable over time.
If you are building a scheduled monitoring workflow, running the scraper once every few hours over a watchlist of accounts is stable indefinitely. Running it at maximum speed continuously against the same accounts will eventually result in IP-level throttling.
Meta’s terms of service allow personal, non-commercial use of public Threads data. Commercial use of scraped data is a grey area that depends on what you are doing with it. This is worth reviewing against your specific use case before building production workflows around it.
Public posts can still contain personal data under the GDPR and US state privacy laws. Collect only what your use case needs, do not keep more than that, and leave private accounts alone.
For how the Threads scrapers on the Apify Store compare on price and reliability, see Best Threads scrapers compared. For Threads against X as a data source, see Threads vs Twitter/X data.
Explore the scraper referenced in this article: inputs, outputs, and pricing, then run it on Apify.
Best Threads Scrapers Compared (2026): Prices, Success Rates, and What Each One Misses
Eight Threads scrapers on the Apify Store compared with public data: price per 1,000 posts, start fees, run success, ratings, and modes. Including where ours is not the right pick.
Threads vs Twitter/X Data: A Developer Comparison for Social Listening
Twitter/X API access is paid and Threads has no public API. How the two platforms compare as data sources for social listening tools.
Instagram Profile Data Without the Meta API: Followers, Bio, and Posts at Scale
Meta restricts the Instagram Graph API to your own accounts. For researching public third-party profiles at scale, here is what data is available and how to collect it.
YouTube Channel Scraper: Subscribers, Video Stats and Channel Data Without a YouTube API Key
How to pull YouTube channel metadata and video lists: subscriber count, view counts, publish dates, video descriptions, without using the YouTube Data API or paying for quota.