Meta Threads Scraper avatar

Meta Threads Scraper

Pricing

from $1.50 / 1,000 results

Go to Apify Store
Meta Threads Scraper

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

Scrapelab Max

Maintained by Community

Actor stats

0

Bookmarked

3

Total users

2

Monthly active users

4 days ago

Last modified

Categories

Security

Limited permissions

Share

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

  1. Quick start
  2. Modes and examples
  3. Input reference
  4. Output
  5. Run summary and statuses
  6. Pricing
  7. Limitations
  8. FAQ and troubleshooting
  9. Compliance
  10. Running locally and contributing

Quick start

  1. Pick a Mode.
  2. Fill in the one list that mode uses: usernames, keywords or postUrls.
  3. Optionally set limits and filters. The defaults (50 rows per input, 1,000 per run) keep a first run cheap.
  4. 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

ModeReadsEmitsPagination
user_postsusernamesThe account's own posts from its Threads tab (+ optional profile row)Yes, until maxResultsPerInput / maxRequestsPerInput / end of feed
user_repliesusernamesReplies the account postedFirst public page only (about 4 to 10 items)
user_repostsusernamesPosts the account repostedFirst public page only
search_postskeywordsPosts matching each keyword or #hashtag, Top or RecentAt most 20 per keyword and sort order
search_profileskeywordsPublic profiles matching each keywordAbout 13 per keyword
post_urlspostUrlsThe linked post, its author's own continuation posts and (optionally) direct repliesReplies 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".

{
"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.

{ "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

FieldTypeDefaultNotes
modeenumuser_postsSee table above
usernamesstring[]—User modes. Handle, @handle or profile URL
keywordsstring[]—Search modes. One input per keyword
postUrlsstring[]—post_urls mode. Threads hosts only
sorttop | recenttopsearch_posts only
maxResultsPerInput1–100050Rows accepted per input before pagination stops. Profile rows excluded
maxTotalResults≥11000Global cap; remaining inputs are not started once reached (budget_reached)
maxRequestsPerInput1–20030Page fetches per input including the first
since / untilISO date (UTC)—Inclusive bounds on created_at; applied before billing
minLikes≥0—Posts with hidden like counts are dropped when set
includeProfilesbooleanfalseUser modes: emit a billable profile row per account
includeRepliesbooleanfalsepost_urls: emit and paginate direct replies
onlyNew / monitorNameboolean / stringfalse / —Beta repeat-run dedup, see docs/monitoring.md
proxyConfigurationobjectApify ProxyKeep 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

StatusMeaningBilled rows?
okInput processed; rows emitted (or all candidates filtered/duplicate — check the counts)Yes, emitted_count
partialSome pages succeeded, then a later page failed (e.g. a 429). Rows fetched so far were keptYes, what was emitted
no_resultsThreads returned nothing for this input. A valid empty result, not an errorNo
not_foundUnknown handle or deleted/unavailable postNo
privatePrivate account or login-walled content. Not collected by designNo
rate_limitedThreads throttled or blocked the requests. Retry later, keep Apify Proxy onNo
unsupportedInput we do not handle, e.g. a non-Threads URL or a profile link in postUrlsNo
failedAny other failure (network, format change source_changed, unexpected). Also used with error_code: "not_started" for inputs skipped after the global limit was reachedNo

Run statuses

run_statusRule
succeededEvery input is ok or no_results
partialSome inputs produced rows, others failed or stopped early
failedEvery input failed (failed, not_found, private, rate_limited, unsupported). The Actor run is marked failed so schedules and integrations notice
budget_reachedmaxTotalResults 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:

EventPriceCharged when
result$0.0015A 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 top and recent runs 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: null and are dropped by minLikes.
  • Private accounts, login walls, deleted content: reported as private / not_found, never scraped.
  • Reply depth: post_urls with includeReplies returns 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 ci
npm run typecheck && npm run lint && npm test # offline; tests use sanitized fixtures and fakes
npm run build
# run against the live site with a local input
echo '{"mode":"user_posts","usernames":["zuck"],"maxResultsPerInput":5}' > storage/key_value_stores/default/INPUT.json
npm 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).