# Threads (/docs/threads) Threads profiles, posts, replies, keyword search, and user search behind the unified schema Meta's Threads behind the unified SocialCrawl envelope: profiles, a user's posts, one post's detail, the replies on a post, keyword search over public posts, and user search. Posts come back as the same canonical `Post` object you get from TikTok or Instagram, so one parser covers all three. Base URL: `/v1/threads/...` **Threads search is a sampled window, not an index, and it is nondeterministic run to run.** Running the identical query three times in a row on 09/08/2026 returned 20 posts each time and matched itself at only **0.693 Jaccard**: 26 unique posts across the three runs. This is upstream behaviour and every Threads provider has it. A second run that returns a slightly different set is not data loss, and comparing two vendors on a single query measures the sampling, not the coverage. ## Quickstart ### 1. Fetch a profile ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/threads/profile?handle=zuck" \ -H "x-api-key: YOUR_API_KEY" ``` ### 2. Fetch their content ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/threads/user/posts?handle=zuck" \ -H "x-api-key: YOUR_API_KEY" ``` ### 3. Read computed fields When an endpoint supports a computed field and the required source inputs are present, the unified response includes that optional field. Depending on the endpoint, optional fields can include `engagement_rate`, `language`, `content_category`, and `estimated_reach`. See [Computed fields](/docs/computed-fields.md) for formulas, clamping rules, and null semantics. Where an endpoint supports them, list rows also carry judged labels (`computed.labels`, `computed.relevance`) by default at no extra credits; see [Labels](/docs/labels.md). ## Accounts and posts | Endpoint | Credits | What it returns | Key parameters | | ---------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- | | `GET /v1/threads/profile` | 1 | Followers, bio, avatar, verification, profile URL, and the bio link | `handle` | | `GET /v1/threads/user/posts` | 1 to 165 | Roughly 15 recent posts, as one fixed window; `include=engagement` adds the view count and display name to each of them | `handle`, `trim`, `include`, `limit` (1-50) | | `GET /v1/threads/post` | 1 | One post's full detail object | `url`, `trim` | Handles are passed without the `@`. The external website a user sets in their bio comes back at `author.ext.bio_link`, `null` when they have not set one. `author.url` is the account's Threads permalink, built from the handle on both `profile` and `search/users`. Four `Author` leaves are always `null` on Threads, because Threads does not publish them: `following`, `posts_count`, `likes_count` and `joined_at`. Treat them as structurally absent rather than missing data. The follower count and bio are a different case: they are absent from the account-search index but present on `profile`, so `search/users` fills them on request (see [Filling the numbers a list leaves out](#filling-the-numbers-a-list-leaves-out)). `user/posts` has no cursor, because the upstream offers none. It has a depth lever instead: leave `limit` off, or send 15 or less, and the call is the bundled window at exactly 1 credit, while `limit` above 15 collects up to 50 posts of the same feed from a second source, metered at 3 credits per post returned. Those deeper rows already carry the view count and display name that `include=engagement` fills on the bundled window. For older posts still, run `search` with the handle or a distinctive phrase and a date window, or reach for [`/v1/search/everywhere`](/docs/search/everywhere.md), which includes Threads as a source. ## Replies | Endpoint | Credits | What it returns | Key parameters | | ------------------------------- | -------- | ------------------------------------------------------------------ | ----------------------------- | | `GET /v1/threads/post/comments` | 1 to 10 | The reply window bundled with the post, typically about 20 replies | `url`, `trim`, `limit` (1-50) | Each reply carries text, like count, direct reply count, author handle, display name, avatar, verification, pinned flag, and timestamp. This is the same upstream call as `GET /v1/threads/post`. One request returns the post and the replies bundled together, and the two endpoints just pick different halves. A post and its replies therefore cost one credit each, and there is no way to save by combining them. That bundling has three consequences worth knowing before you build on it: - It is **one window with no cursor**, so there is no way to page deeper into a long conversation. Roughly 20 replies is what exists. - `comment.url` is constructed from the reply's shortcode and its author handle, because the upstream sends no permalink on reply items. - `comment.post_id` and `comment.parent_id` are **`null`** for the same reason: the upstream surfaces neither id on a reply. If you need to attach a reply to its parent post, carry the post url through yourself. ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/threads/post/comments?url=https://www.threads.com/@zuck/post/DZpPDXbCeTt" \ -H "x-api-key: YOUR_API_KEY" ``` ## Search | Endpoint | Credits | What it returns | Key parameters | | ------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `GET /v1/threads/search` | 1 to 26 | Public posts matching a keyword; `include=engagement` adds the view count and pinned flag to the first 20 | `query`, `limit` (1-100), `start_date`, `end_date`, `cursor`, `expand`, `include`, `label`, `relevance`, `judgments` | | `GET /v1/threads/search/users` | 1 to 13 | Accounts matching a term, with display name, avatar and verification; `include=profile` adds the follower count, bio, private flag and bio link | `query`, `include`, `limit` (1-12) | `search` returns a single result window, typically 15 to 20 posts. Threads gives the upstream no cursor at all; its only paging lever is the date filter, so the endpoint synthesises pagination for you. There are two ways to go deeper: - **Send the cursor back.** Pass `pagination.next_cursor` from the previous response as `cursor` to get the next, older window. One window, 1 credit. - **Ask for a count and let the API walk.** Set `limit` and the API re-queries shrinking date windows server-side, de-duplicates by post id, and returns **at least** that many unique posts in one call. It bills 1 credit per window consumed, about 15 to 20 posts each, and refunds the unused window budget. A `limit=100` call that runs dry after three windows costs 3 credits, not 7. `limit` is a collection target, not a page size. The walk stops at the first whole window that reaches your number, so `limit=40` typically returns 50 to 60 posts, and nothing you were billed for is trimmed away. Slice your own copy if you need exactly N. Values outside 1 to 100 are rejected with a free `400` rather than clamped. `start_date` and `end_date` are both `YYYY-MM-DD` and inclusive. The walk starts at `end_date` and works backwards, and never crosses `start_date`, which makes the pair the right way to page a fixed period without runaway cost. A relaxed query obeys the same bounds. Anything that is not `YYYY-MM-DD`, and any window whose start is after its end, is a free `400`: Threads treats the date pair as a ranking hint rather than a filter, so the window is enforced on our side and a post we cannot date is treated as outside it. The first call below is the cheapest possible probe, one window. The second collects up to 60 unique posts from a bounded period in a single call. ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/threads/search?query=artificial%20intelligence" \ -H "x-api-key: YOUR_API_KEY" curl "https://www.socialcrawl.dev/v1/threads/search?query=artificial%20intelligence&limit=60&start_date=2026-07-01&end_date=2026-07-31" \ -H "x-api-key: YOUR_API_KEY" ``` If you need a stable set, collect with `limit` over a bounded date window and de-duplicate on post id your side. ### Long queries are handled for you Threads matches a search against the topic tags it already knows rather than scoring individual terms. A phrase it does not recognise therefore returns **nothing at all** instead of fewer results, and the longer the phrase, the less likely it is to be a tag. This is not a keyword limit: `machine learning engineer` and `new york city` both return a full window, while `the best coffee` returns zero. When a query comes back empty, the API relaxes it into its adjacent word pairs, runs those searches in parallel, and returns one merged, de-duplicated list ordered by how many of your terms each post contains. Measured on 31/08/2026, `best coffee machine for a small office` went from 0 posts to 75, and `cari vendor mesin kopi jakarta` from 0 to 56. ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/threads/search?query=best%20coffee%20machine%20for%20a%20small%20office" \ -H "x-api-key: YOUR_API_KEY" ``` Three things to plan around: - **You are billed 1 credit for each relaxed search that returned posts**, and nothing for the ones that did not. A query that already worked is never relaxed, so it still costs exactly 1 credit. The worst case is 4 relaxed searches on top of the original. - **A relaxed response is a single page** with no `pagination.next_cursor`, because the cursor would resume the exact phrase that just returned nothing. For the same reason, a request that **carries** a cursor is never relaxed, so the last hop of a pagination loop is the honest end of the walk rather than a surprise page. - **Relaxation respects your date window.** Posts outside `start_date`/`end_date` are dropped from every sub-query, and a sub-query left with nothing inside the window is not billed. - **`data._warnings` names every sub-query that ran**, so you can always see what was actually searched. Send `expand=false` to switch this off and search the exact phrase only, which restores the older behaviour of an empty result at 0 credits. ## Filling the numbers a list leaves out Three Threads lists are thin by construction: the account-search index carries no follower count or bio, and neither post list carries a view count. Each of those numbers is on a sibling endpoint, so instead of calling it once per row yourself, add `include` and the API does the join inside the same call. | Call | Token | What lands on each row | Cost | | ---- | ----- | ---------------------- | ---- | | `search/users` | `include=profile` | `author.followers`, `author.bio`, `author.private`, `author.ext.bio_link` | 1 per account filled, 12 at most | | `user/posts` | `include=engagement` | `engagement.views`, `post.author.display_name`, and the `computed` block that divides by views | 1 per post filled, 15 at most | | `search` | `include=engagement` | `engagement.views`, `post.flags.pinned` on the first 20 posts | 1 per post whose view count landed, 20 at most | ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/threads/search/users?query=tech&include=profile" \ -H "x-api-key: YOUR_API_KEY" ``` Four things to plan around: - **You pay for rows that come back filled, and nothing else.** A row the lookup could not fill is refunded and named in `data._warnings`; a row we fetched recently is served from cache for nothing. `limit` on `search/users` caps the rows and the credits together, so `limit=5&include=profile` costs at most 6. - **It adds seconds, because it is doing the calls you would have made.** Measured on 11/09/2026: about 9 seconds for 11 accounts, 10 for 15 posts, 10 for 17 search results. Each lookup is bounded, so a slow one is dropped and refunded instead of holding the response. - **`data.hydration` explains the bill.** Every response that used `include` reports the rows on the page and how many were looked up, filled, served from cache and left unfilled, alongside the credits held and kept. - **On `search` it is one window.** `include=engagement` joins the first 20 posts and is not accepted with `limit` above 20; page with the cursor instead, 20 posts a call. - **A brand-new post has no view count yet, and it is free.** Threads publishes a view count only once it has one, so a post that is minutes old comes back without it, on this endpoint and on `GET /v1/threads/post` alike. That row still gets its pinned flag, and it costs nothing: on `search` you are billed only for posts whose view count (or missing display name) actually landed. Measured 12/09/2026 on `artificial intelligence`, a page of very fresh posts: 11 of 19 filled and billed, the other 8 filled their pinned flag for free. ## Quote posts A Threads post that quotes another usually has no caption or media of its own, so a naive reader sees an empty post. The quoted content is returned whole at `post.ext.quoted_post` as `{ id, url, author, text, media_urls, thumbnail_url }`. It belongs to a different account, so it stays there rather than being merged into `content`, and `post.content` is always this post's own content. The field is absent on posts that are not quote posts, and it is present on all three post surfaces: `post`, `user/posts` and `search`. ## Topic tags Posts filed under a Threads topic tag carry the tag name at `post.ext.topic_tag`, with values that look like `bali` or `dokter gigi`, and its stable id at `post.ext.topic_tag_id`. Both fields are present on `search`, `user/posts`, and `post`. Two things to plan around. Both fields are **absent on untagged posts, which are the majority**, so treat them as optional and never assume they exist. And **Threads exposes no tags-only search mode upstream**, so there is no way to ask for "every post under `bali`". The supported pattern is to run a keyword search and group or filter the results by `topic_tag` client-side. ## All endpoints ## Platform notes **A Threads profile only exists if the account opted in.** Many well-known Instagram usernames have no Threads account, so a `404` is a genuine "no such profile" rather than an error. **Nothing here pages except search.** `user/posts`, `post/comments` and `search/users` each return one window with no cursor to resume from. The incumbent source exposes no depth lever at all on `user/posts`: `cursor`, `next_cursor`, `page`, `offset`, `limit`, `amount`, `count` and `max` were each probed on 04/09/2026 and every one returned the same 15 rows. `limit` above that boundary therefore moves the call to a second source (up to 50 posts on `user/posts`, up to 50 replies on `post/comments`), and the response is still a single page. **Threads publishes `engagement.views` on a single post's page and not on any list surface**, so `user/posts` and `search` return `null` for it on a plain call. Add `include=engagement` and each row is looked up on `GET /v1/threads/post` inside the same call, at 1 credit per row filled. Fetching the one post you need still works and still costs 1 credit. **Reshares split three ways.** `engagement.shares` is the plain repost count. A repost that added the resharer's own commentary is counted at `post.ext.quote_count`, and the boosted figure Threads shows in-app is at `post.ext.reshare_count`. Add them yourself if you want one number; we do not, because the three mean different things. **`post.flags.pinned`** says whether the author pinned the post to their profile. It is populated on `user/posts` and `post`. On `search` the upstream omits the field rather than reporting false, so it is `null` on a plain call and filled on the first 20 rows when you send `include=engagement`. **Media and avatar URLs are signed Instagram CDN links** carrying `oh=` and `oe=` expiry parameters. They work for a matter of hours, not indefinitely. Download what you need to keep, and re-fetch the post rather than storing the URL. **Carousels and video land in the same place.** Carousel posts return every slide as an array in `post.content.media_urls`; video posts return a playable video URL there, with the cover frame in `post.content.thumbnail_url`. **`trim=true` costs the same and removes more than whitespace.** It works on `user/posts`, `post`, `post/comments` and `search`, and the trimmed record the upstream returns carries only the id, text, shortcode, like count, timestamp and author. Everything else comes back `null`: media, reply count, share count, view count, topic tag, pinned flag and quoted post. It is the right flag when you want text and likes at a smaller payload, and the wrong one any other time, because it costs the same 1 credit either way. ## Labels These lists label their rows by default, at no extra credit. On `/v1/threads/search`, each row carries `sponsored`, `intent` and `niche`. `/v1/threads/search` also scores each row's relevance to your `query` on `computed.relevance`, without dropping or reordering anything; add `relevance=filter` to drop the rows that are about something else, still free. Send `judgments=off` for the page without them. See [Labels](/docs/labels.md) for the field shapes, rows still `pending`, and the labels you can add with `label=`. ## Next steps - [Pagination](/docs/pagination.md): Cursor and page walks, and what has_more actually means. - [Credits](/docs/credits.md): What each tier costs and when a call is refunded. - [API reference](/docs/api-reference.md): Every parameter and response field, endpoint by endpoint. - [Instagram](/docs/instagram.md): The Meta network Threads accounts are attached to. - [Bluesky](/docs/bluesky.md): The other text network, same canonical Post object.