Meta Threads Scraper
Pricing
from $1.50 / 1,000 results
Meta Threads Scraper
Collect public Meta Threads posts, profiles and search results without login. Stable IDs, UTC timestamps, honest nulls and per-input diagnostics.
Pricing
from $1.50 / 1,000 results
Rating
0.0
(0)
Developer
Scrapelab Max
Maintained by CommunityActor stats
0
Bookmarked
3
Total users
2
Monthly active users
4 days ago
Last modified
Categories
Security
Limited permissionsShare
Collect public Meta Threads posts, profiles and search results — no login, no cookies, no browser. Every row has a stable numeric ID, a canonical URL, an ISO-8601 UTC timestamp and honest nulls where Threads hides a number. Every run ends with a free per-input summary that tells you exactly what was requested, what came back, and why anything is missing.
- Price: $1.50 per 1,000 results ($0.0015 per emitted unique post or profile row). Failed inputs, duplicates, filtered rows and diagnostics are never charged.
- Modes: user posts, user replies, user reposts, keyword/hashtag post search, profile search, post URL lookup.
- Honest limits: Threads exposes at most 20 search results per keyword, about 13 profiles per profile search, a single page of replies/reposts, and no view counts to logged-out visitors. We report these in the output instead of hiding them. See Limitations.
Table of contents
- Quick start
- Modes and examples
- Input reference
- Output
- Run summary and statuses
- Pricing
- Limitations
- FAQ and troubleshooting
- Compliance
- Running locally and contributing
Quick start
- Pick a Mode.
- Fill in the one list that mode uses:
usernames,keywordsorpostUrls. - Optionally set limits and filters. The defaults (50 rows per input, 1,000 per run) keep a first run cheap.
- Run. Rows land in the default dataset; the diagnostic summary is in the key-value store under
RUN_SUMMARY.
Minimal input:
{"mode": "user_posts","usernames": ["zuck"],"maxResultsPerInput": 20}
Modes and examples
| Mode | Reads | Emits | Pagination |
|---|---|---|---|
user_posts | usernames | The account's own posts from its Threads tab (+ optional profile row) | Yes, until maxResultsPerInput / maxRequestsPerInput / end of feed |
user_replies | usernames | Replies the account posted | First public page only (about 4 to 10 items) |
user_reposts | usernames | Posts the account reposted | First public page only |
search_posts | keywords | Posts matching each keyword or #hashtag, Top or Recent | At most 20 per keyword and sort order |
search_profiles | keywords | Public profiles matching each keyword | About 13 per keyword |
post_urls | postUrls | The linked post, its author's own continuation posts and (optionally) direct replies | Replies paginate under the same caps |
User posts
{"mode": "user_posts","usernames": ["zuck", "@mosseri", "https://www.threads.com/@instagram"],"maxResultsPerInput": 100,"includeProfiles": true,"since": "2026-01-01"}
Handles, @handles and profile URLs are all accepted and normalized to a lowercase handle. With includeProfiles: true you also get one profile row per account before its posts; that row is billed separately and does not count toward maxResultsPerInput.
User replies and reposts
{ "mode": "user_replies", "usernames": ["zuck"] }
{ "mode": "user_reposts", "usernames": ["zuck"] }
Logged-out Threads serves a single page for these tabs and offers no cursor. Each input finishes with the warning Logged-out access exposes only the first page … so the gap is visible in RUN_SUMMARY. Reposted rows carry reposted_post_id and source_tab: "reposts".
Keyword and hashtag search
{"mode": "search_posts","keywords": ["open source", "#threadsapi"],"sort": "recent","minLikes": 5,"maxResultsPerInput": 20}
Each keyword is one input. A post returned by several keywords is written once, with every keyword in matched_keywords, and billed once. If you ask for more than 20 per keyword the input still succeeds but carries the warning Public search exposes at most 20 results per keyword.
Profile search
{ "mode": "search_profiles", "keywords": ["ux designer", "indie hacker"] }
Returns profile rows (username, name, bio, follower count when public, bio links). No contact enrichment: if Threads does not show it, it is null.
Post URLs
{"mode": "post_urls","postUrls": ["https://www.threads.com/@zuck/post/CuXFPIeLLod","https://www.threads.net/@zuck/post/CuXFPIeLLod/media"],"includeReplies": true,"maxResultsPerInput": 50}
threads.com, legacy threads.net and /media deep links resolve to the same canonical post, and equal posts are deduplicated within the run. The target post and the author's own thread continuation have source_tab: "post"; replies have source_tab: "post_replies" and parent_post_id. Nested reply trees are not followed.
Input reference
| Field | Type | Default | Notes |
|---|---|---|---|
mode | enum | user_posts | See table above |
usernames | string[] | — | User modes. Handle, @handle or profile URL |
keywords | string[] | — | Search modes. One input per keyword |
postUrls | string[] | — | post_urls mode. Threads hosts only |
sort | top | recent | top | search_posts only |
maxResultsPerInput | 1–1000 | 50 | Rows accepted per input before pagination stops. Profile rows excluded |
maxTotalResults | ≥1 | 1000 | Global cap; remaining inputs are not started once reached (budget_reached) |
maxRequestsPerInput | 1–200 | 30 | Page fetches per input including the first |
since / until | ISO date (UTC) | — | Inclusive bounds on created_at; applied before billing |
minLikes | ≥0 | — | Posts with hidden like counts are dropped when set |
includeProfiles | boolean | false | User modes: emit a billable profile row per account |
includeReplies | boolean | false | post_urls: emit and paginate direct replies |
onlyNew / monitorName | boolean / string | false / — | Beta repeat-run dedup, see docs/monitoring.md |
proxyConfiguration | object | Apify Proxy | Keep enabled to avoid rate_limited |
Input is validated before any request is made; contradictory or unsafe input (non-Threads hosts, bad dates, empty list for the mode) fails fast with a readable message and zero charges.
Output
Each dataset row is either a post or a profile, told apart by record_type. Every row also carries provenance: source_mode, source_input, matched_keywords, source_tab, fetched_at, schema_version.
Sample post record
{"record_type": "post","post_id": "3141592653589793238","post_code": "CuXFPIeLLod","post_url": "https://www.threads.com/@zuck/post/CuXFPIeLLod","text": "Threads now has more than 100 million monthly actives.","created_at": "2026-10-20T16:04:11.000Z","author": {"id": "314216","username": "zuck","full_name": "Mark Zuckerberg","is_verified": true,"profile_pic_url": "https://scontent.cdninstagram.com/.../avatar.jpg"},"like_count": 48210,"reply_count": 3120,"repost_count": 1875,"quote_count": 640,"view_count": null,"view_count_status": "unavailable","media": [{ "type": "image", "url": "https://scontent.cdninstagram.com/.../image.jpg", "width": 1080, "height": 1350, "thumbnail_url": null }],"links": ["https://about.fb.com/news/"],"is_reply": false,"parent_post_id": null,"root_post_id": null,"quoted_post_id": null,"reposted_post_id": null,"reply_to_username": null,"language": "en","like_and_view_counts_disabled": false,"source_mode": "user_posts","source_input": "zuck","matched_keywords": [],"source_tab": "threads","fetched_at": "2026-11-01T10:00:00.000Z","schema_version": "1.0"}
Sample profile record
{"record_type": "profile","profile_id": "314216","username": "zuck","full_name": "Mark Zuckerberg","profile_url": "https://www.threads.com/@zuck","biography": "Building Threads at Meta.","follower_count": 6500000,"is_verified": true,"is_private": false,"profile_pic_url": "https://scontent.cdninstagram.com/.../avatar.jpg","bio_links": ["https://about.meta.com/"],"source_mode": "search_profiles","source_input": "zuck","matched_keywords": ["zuck"],"source_tab": "search","fetched_at": "2026-11-01T10:00:00.000Z","schema_version": "1.0"}
Null semantics
null means "Threads did not expose this to a logged-out visitor" — never an inferred zero. 0 is an observed zero. view_count is currently always null and view_count_status says "unavailable"; when an author hides counts, like_and_view_counts_disabled is true and like_count is null. Full field semantics and the versioning policy are in docs/schema.md. The machine-readable schema is .actor/dataset_schema.json.
Media and avatar URLs are signed by Meta's CDN and expire after some hours; download them promptly if you need the files.
Run summary and statuses
Every run writes RUN_SUMMARY to the default key-value store (never to the dataset, never billed):
{"schema_version": "1.0","run_status": "partial","mode": "user_posts","started_at": "2026-11-01T10:00:00.000Z","finished_at": "2026-11-01T10:00:41.000Z","inputs_total": 3,"inputs_ok": 2,"inputs_failed": 1,"results_emitted": 148,"results_filtered": 12,"duplicates_suppressed": 0,"requests_made": 9,"budget_limit_reached": false,"inputs": [{ "mode": "user_posts", "input": "zuck", "status": "ok", "requested_count": 100, "fetched_count": 110, "emitted_count": 98, "filtered_count": 12, "duplicates_suppressed": 0, "invalid_count": 0, "requests_made": 5, "error_code": null, "warning": null },{ "mode": "user_posts", "input": "mosseri", "status": "ok", "requested_count": 100, "fetched_count": 50, "emitted_count": 50, "filtered_count": 0, "duplicates_suppressed": 0, "invalid_count": 0, "requests_made": 3, "error_code": null, "warning": null },{ "mode": "user_posts", "input": "not_a_real_handle_123", "status": "not_found", "requested_count": 100, "fetched_count": 0, "emitted_count": 0, "filtered_count": 0, "duplicates_suppressed": 0, "invalid_count": 0, "requests_made": 1, "error_code": "not_found", "warning": null }],"warnings": []}
Per-input statuses
| Status | Meaning | Billed rows? |
|---|---|---|
ok | Input processed; rows emitted (or all candidates filtered/duplicate — check the counts) | Yes, emitted_count |
partial | Some pages succeeded, then a later page failed (e.g. a 429). Rows fetched so far were kept | Yes, what was emitted |
no_results | Threads returned nothing for this input. A valid empty result, not an error | No |
not_found | Unknown handle or deleted/unavailable post | No |
private | Private account or login-walled content. Not collected by design | No |
rate_limited | Threads throttled or blocked the requests. Retry later, keep Apify Proxy on | No |
unsupported | Input we do not handle, e.g. a non-Threads URL or a profile link in postUrls | No |
failed | Any other failure (network, format change source_changed, unexpected). Also used with error_code: "not_started" for inputs skipped after the global limit was reached | No |
Run statuses
run_status | Rule |
|---|---|
succeeded | Every input is ok or no_results |
partial | Some inputs produced rows, others failed or stopped early |
failed | Every input failed (failed, not_found, private, rate_limited, unsupported). The Actor run is marked failed so schedules and integrations notice |
budget_reached | maxTotalResults or your run spending limit stopped the run; rows emitted so far are valid |
Pricing
This Actor uses Apify pay-per-event with one event:
| Event | Price | Charged when |
|---|---|---|
result | $0.0015 | A unique post or profile row is written to the dataset |
Not charged, ever: diagnostics (RUN_SUMMARY), rows dropped by since/until/minLikes, duplicates within a run (including a post matched by several keywords), rows suppressed by onlyNew, invalid records, and inputs that failed (not_found, private, rate_limited, unsupported, failed). A run that finds nothing costs nothing in results.
Worked examples: 3 usernames × 50 posts → 150 results → $0.225. Ten keywords in search_posts → at most 200 results → ≤ $0.30. includeProfiles adds one profile result per username.
Set a maximum cost per run in the Apify run options to cap spending; the Actor stops cleanly with run_status: "budget_reached" and keeps everything it already delivered. Details, including the Console checks we do before each release so no synthetic event is priced, are in docs/billing.md. Apify platform usage (compute, proxy, storage) is billed by Apify according to your plan and is separate from the result price.
Limitations
These come from what Threads shows to a logged-out visitor. We measure them, document them and surface them as warnings; we do not work around them.
- Search: at most 20 posts per keyword per sort order. There is no public cursor for more. No historical or exhaustive coverage is possible; use several specific keywords and both
topandrecentruns if you need breadth. - Profile search: about 13 profiles per keyword, single page.
- Replies and reposts tabs: one page each (typically 1 to 10 items). No pagination is exposed.
- View counts: never exposed →
view_count: null,view_count_status: "unavailable". - Hidden counts: authors can hide like counts; those rows have
like_count: nulland are dropped byminLikes. - Private accounts, login walls, deleted content: reported as
private/not_found, never scraped. - Reply depth:
post_urlswithincludeRepliesreturns direct replies only, not nested threads. - Media: URLs expire; files are not downloaded.
- Source changes: Threads changes its pages without notice. When our parsers no longer recognize a page they fail the input with
error_code: "source_changed"instead of returning empty or fabricated rows. Please open an issue with the run ID.
Full list: docs/limitations.md.
FAQ and troubleshooting
My search returned fewer than 20 rows / zero rows. Threads returns what it returns. no_results means the search page was empty for that keyword; try the other sort, a broader keyword or a hashtag. Check fetched_count vs filtered_count in RUN_SUMMARY: a strict since or minLikes may have filtered real matches.
I asked for 100 results per keyword and got 20. That is the public cap; see the warning on the input. Price is per delivered row, so you are not charged for the 80 that do not exist.
Inputs are rate_limited (HTTP 429 / blocked). Threads throttled the IP. Keep proxyConfiguration on Apify Proxy, lower the number of inputs per run or space scheduled runs out. The run keeps rows fetched before the throttle (partial). Nothing is charged for the failed inputs.
not_found vs private. not_found: the handle or post does not exist (Threads redirects to a login shell). private: the account exists but is private or the content sits behind a login wall; we do not collect it.
A /media URL or a threads.net link. Both are accepted and normalized to the canonical threads.com/@user/post/<code>; the row is the same post and is deduplicated against the plain link.
Why is view_count null everywhere? Threads does not show views to logged-out visitors. We would rather say null than guess.
Why did I get a profile row I did not ask for? You will not: profile rows only appear with includeProfiles: true (user modes) or in search_profiles.
Scheduled runs keep returning the same posts. Enable onlyNew with a monitorName (beta) so repeat runs only emit — and only bill — posts not seen before by that monitor. Read docs/monitoring.md first for the caveats.
The run failed with no rows. Every input failed; RUN_SUMMARY.inputs[].error_code says why for each one. Total failure is reported as a failed run on purpose, so you do not mistake it for an empty success.
Compliance
- Collects publicly visible data only, exactly as shown to any logged-out visitor. No login, cookies, session reuse, CAPTCHA solving or any other access-control bypass — by policy and by code (the input schema has no place for credentials).
- Private accounts, login-walled and deleted content are skipped and reported.
- Rate limits are respected with bounded concurrency, timeouts and backoff; when Threads says no, we stop.
- You are responsible for using the data in line with Meta's Terms of Service and the data-protection law that applies to you (GDPR, CCPA and others). Store only what you need; this Actor keeps the fields in the schema and nothing else. See docs/security-policy.md.
Running locally and contributing
Requirements: Node 22 (.nvmrc), npm, optionally the Apify CLI.
npm cinpm run typecheck && npm run lint && npm test # offline; tests use sanitized fixtures and fakesnpm run build# run against the live site with a local inputecho '{"mode":"user_posts","usernames":["zuck"],"maxResultsPerInput":5}' > storage/key_value_stores/default/INPUT.jsonnpm run dev # or: apify run
ACTOR_TEST_PAY_PER_EVENT=true (see .env.example) makes the SDK log charges to storage/datasets/charging-log instead of billing. Tests never touch the network. Architecture, schema, billing and release notes live in docs/; release steps in docs/release-checklist.md. PRs follow the template in .github/ and need green CI (typecheck, lint, tests, build, audit).