# Computed fields (/docs/computed-fields) Every profile, post, and comment response carries a computed block. Engagement_rate, language, content_category, estimated_reach. Here is exactly how each is calculated. Profile, post, and comment responses carry a `computed` block alongside the upstream data. It has the same shape on every platform, so a TikTok creator's engagement rate is directly comparable to an Instagram one. | Field | Type | Range | When it is `null` | | ------------------ | ---------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `engagement_rate` | `number \| null` | `0.0`, `1.0` | Divisor missing or zero; on posts, also when all of `likes`/`comments`/`shares` are `null`; on profiles, when the raw ratio exceeds `1.0` | | `language` | `string \| null` | ISO 639-1 (`en`, `ko`, `ja`, ...) | Latin scripts: cleaned prose under 30 chars, under 3 words, or a title-cased headline. Non-Latin scripts: never length-gated. Also when unrecognised | | `content_category` | `string \| null` | 14 categories or `"other"` | Input text under 10 trimmed chars (a looser, separate gate from `language`'s) | | `estimated_reach` | `number \| null` | integer ≥ 0 | Posts: `views` missing or `0` (so any post without a view count, e.g. Instagram photos). Profiles: always `null`, see below | Every value is a real number or an honest `null`. A `0` is never substituted for "we do not know", because the difference matters when you sort or filter. If a value was forced into range, typically `engagement_rate` exceeding `1.0`, an explanatory string lands in [`data._warnings`](/docs/response-schema.md#data_warnings-partial-data-channel) so you can see it happened. ## Where the block attaches - **`Author`**: `data.computed` on profile responses. - **`Post`**: `data.computed` on single-post responses **and on every item** of a `PostList`. - **`Comment`**: `data.computed` on single-comment responses **and on every item** of a `CommentList`. Comments carry a slimmer block, just `{ language }`. The other three fields need signals a comment does not have: `engagement_rate` needs a views divisor, and `content_category` and `estimated_reach` do not generalize to one-line replies. Always-null columns would be shape noise. The `Audience`, `Transcript`, and `SearchResult` archetypes carry no `computed` block. ## engagement_rate A normalised, comparable engagement signal in the range `[0.0, 1.0]`, rounded to 6 decimals. ### Author variant (profiles) ```text title="Formula" engagement_rate = author.likes_count / author.followers ``` Returns `null` when `followers` is `0` or `likes_count` is absent. "Zero engagement" and "we do not have the data" are different states, and the second one is reported honestly. **Instagram fallback.** Instagram's profile payload never populates `author.likes_count`. On an Instagram profile endpoint, up to ~12 recent posts embedded in the same response are read instead: ```text title="Formula" engagement_rate = (mean(post_likes) + mean(post_comments)) / followers ``` A post counts only if both `likes_count` and `comments_count` are present. Zero usable posts returns `null`. This fallback is Instagram-only today. ### Post variant (single posts and PostList items) ```text title="Formula" engagement_rate = (likes + comments + shares) / views ``` Returns `null` when `views` is missing or `0`. This is intentional. Pre-views-era tweets and platforms that do not report views must be honest. The previous implementation fell back to `divisor = 1`, which silently surfaced the numerator (for example `26,573`) as an engagement rate. That is worse than `null`. It also returns `null` when the numerator is structurally unavailable, meaning **all three** of `likes`, `comments`, and `shares` are `null` (Twitch VODs, Spotify tracks, Rumble list items). An explicit upstream `0` in any one of the three is real data, so a genuine zero-engagement post still reports `0`. A post can therefore carry a populated `estimated_reach` (views present) alongside a `null` engagement rate (no engagement signals). The two fields gate independently on `views`. **One formula, everywhere.** This is the same calculation on every platform. There is no per-platform variant of the post-level formula. What changes between platforms is which inputs the platform exposes: - **`likes`, `comments`, `shares` are treated as `0` when absent.** If a platform does not report a shares count (Instagram and YouTube do not), the numerator becomes `likes + comments`. A shares number is never invented, and the whole rate is not nulled because one addend is missing. This is the most common reason a hand-recomputed rate does not match: if you reproduce the formula assuming a non-null `shares` on Instagram or YouTube, your result differs, because on our side `shares` contributed `0`. - **`views` is treated as `null` when absent, and a `null` divisor makes the whole rate `null`.** Unlike the addends, a missing `views` is not coerced to `0`, because dividing by zero is meaningless, so the rate is honestly `null`. ### Why your recomputation might not match If you validate `engagement_rate` by recomputing it from the raw fields, this table explains every case of disagreement. In each case the returned value is correct, and the mismatch comes from an input you may not have. | Platform | Reproducible from `(likes+comments+shares)/views`? | Why | | -------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **TikTok** | Yes, exactly | All five engagement fields (`views, likes, comments, shares, saves`) are present. Your recomputation will match. | | **Twitter/X** | Yes, on rows where `engagement_rate` is not `null` | Where a rate is returned, it reproduces exactly. A large share of tweets return `engagement_rate: null` because Twitter did not report a native `views` count for that tweet (older tweets and many replies). `null` here means "no view count available", not "zero engagement". Filter on `engagement_rate IS NOT NULL` before using it as a feature. | | **Instagram** | No | `shares` (and `saves`) are not exposed on the public upstream, so `shares = 0` and the rate is effectively `(likes + comments) / views`. Photo posts also have no view count, so their rate is `null`; Reels have views and do get a rate. A shares-inclusive reproduction cannot match either branch. Use the returned value directly. | | **YouTube** | No | Same mechanism as Instagram: `shares` is not returned, so the rate is `(likes + comments) / views`. | | **Facebook** | Partially | The upstream `like_count` is mapped verbatim into `likes`. That field appears to be the aggregate reaction count (like, love, haha, and the rest), not likes alone, so the numerator can run higher than a like-only reproduction. This is being confirmed upstream; treat the Facebook rate as reaction-inclusive for now. | | **YouTube livestreams** | No (rate is `0` or `null`) | The live/upcoming list shape carries a view count but no like or comment counts, so the numerator is `0` (rate `0`) or the view count is absent pre-broadcast (rate `null`). Hydrate a finished stream through the single-video endpoint to get full engagement. | | **Reddit, Threads (list items)** | No (rate is `null`) | Neither exposes a per-post `views` count on list or feed items, so no rate is possible. For Reddit, use `score` (upvotes) as the engagement proxy. A single Threads post fetched by URL does carry a view count and will get a rate. | The small share of **post** rows where the raw math exceeds `1.0` are clamped to `1.0` with a `_warnings` note. That is a fraction of a percent of rows and does not affect the population. ### Out-of-range values: posts clamp, profiles go null The two archetypes handle a raw value above `1.0` differently, because the causes differ. **Post rows clamp to `1.0`.** A post ratio above `1.0` (an old tweet whose likes outweigh its under-reported views, or heavy reshare activity exceeding views) is a real if unbounded signal, so it is pinned to `1.0` and a warning is appended: ```json title="Response" { "data": { "computed": { "engagement_rate": 1.0 }, "_warnings": [ "computed.engagement_rate: value exceeded 1.0 (raw: 1.42); clamped" ] } } ``` **Profile (Author) rows return `null`.** An author ratio above `1.0` only ever arises from dividing a _cumulative lifetime_ like count (TikTok, YouTube, Facebook profiles) by the current follower count. That is not a real engagement rate, so instead of fabricating `1.0` the response returns `null` with an explanatory warning: ```json title="Response" { "data": { "computed": { "engagement_rate": null }, "_warnings": [ "computed.engagement_rate: author ratio exceeded 1.0 (raw: 86.32); returned null. A lifetime likes/followers ratio is not a real engagement rate" ] } } ``` The defensive lower clamp at `0` exists for the same reason but rarely fires, because the arithmetic cannot produce negatives from positive inputs. ## language An ISO 639-1 two-letter code. Regional forms are not used: `pt-BR` is never emitted, only `pt`. | Archetype | Input | | --------- | ---------------------------------------------------------------------------- | | `Author` | `author.bio` | | `Post` | `post.content.text` | | `Comment` | `comment.text`, via a comment-tuned detector described [below](#on-comments) | Returns `null` when the input is missing or non-string. Beyond that, the floors depend on the script: - **Non-Latin scripts** (Korean, Japanese, Chinese, Arabic, Devanagari, Thai) are detected by Unicode range and classify at **any length**. A three-character Korean bio returns `ko`. - **Latin scripts** go through trigram classification, which needs real prose to be trustworthy. URLs, `@mentions`, `#hashtags`, and list markers are stripped first, then the remaining prose must be at least **30 characters** and at least **3 alphabetic words**. A short, mostly title-cased string (a headline or a name, not prose) also returns `null`. Those floors are confidence gates. Below them the classifier misfires more often than it helps, so nothing is returned rather than a confident wrong code. ### Detection strategy Two passes: 1. **Unicode fast-path** for non-Latin scripts, applied first and at any input length. If the input contains characters in these ranges, the language code is returned directly: - Korean (`가` to `힯`, Hangul Jamo) gives `ko` - Japanese (Hiragana / Katakana) gives `ja` - CJK Unified Ideographs gives `zh` - Arabic gives `ar` - Devanagari gives `hi` - Thai gives `th` 2. **Trigram classification** via [`franc-min`](https://github.com/wooorm/franc) for everything else, run on the cleaned prose that cleared the floors above. franc returns `und` when below its internal confidence threshold, and that maps to `null` rather than a guess. ### Supported codes If franc's ISO 639-3 code maps to one of the codes below, you get the two-letter form. Anything else collapses to `null`, because an obscure three-letter code leaking through the public surface is worse than nothing. ```text title="Language codes" ar bg ca cs da de el en es fa fi fr he hi hu id it ja ko nl no pl pt ro ru sv th tr uk vi zh ``` 31 codes total. ### On comments Comments are far shorter and noisier than captions or bios. The trigram classifier misfires badly below its floors, which would leave roughly half of all real comments at `null`. Comment responses therefore use a separate detection stack, in order: **Prose strip.** URLs, `@mentions`, `#hashtags`, and emoji are removed first. A comment that is only emoji, only a date stamp, or only a handle returns `null`. **Unicode fast-path.** The same non-Latin script ranges as above, plus Hebrew (`he`) and Greek (`el`), at any length. `좋아요` gives `ko`. **Noise gate.** What is left must be at least 2 words and 4 letters of prose. Below that a comment is a name or an interjection, and no classifier is trustworthy there, so the result is `null` rather than a guess. **The platform's own label.** TikTok attaches a per-comment language label upstream, and Threads sometimes does. When it is one of the 31 published codes it is returned directly. **Short-text classification.** Everything else runs through [ELD](https://github.com/nitotm/efficient-language-detector-js), a detector built for short text, and only a reliable, clear-margin answer is returned. This is why `Great video!` gets `en` on a comment while the same string in a bio falls under the 30-character floor. The published code set is identical to the table above, and anything outside it collapses to `null`. Expect roughly a quarter of real-world comments to return `null`. Emoji-only reactions, date stamps, and one-word replies carry no language signal, and an honest `null` beats a confident wrong label when you are filtering a corpus. ## content_category One of 14 hand-curated categories or `"other"`. Returns `null` if the input text is missing or under 10 trimmed characters. That is a **different, looser gate** than `language`'s, so a bio can carry a category and no language, or the reverse. | Category | Sample matched keywords | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `tech` | programming, developer, software, ai, saas, blockchain, frontend | | `food` | cooking, recipe, chef, restaurant, baking, vegan | | `gaming` | gaming, esports, twitch, fortnite, valorant, fps | | `fashion` | fashion, outfit, designer, ootd, streetwear | | `beauty` | makeup, skincare, lipstick, serum, moisturizer | | `fitness` | workout, gym, cardio, yoga, marathon, protein | | `travel` | adventure, destination, vacation, backpacking, wanderlust | | `music` | song, artist, album, concert, producer, spotify | | `education` | learning, course, tutorial, university, lecture | | `entertainment` | movie, tv, netflix, celebrity, comedy, series | | `sports` | football, basketball, nba, olympics, championship | | `business` | entrepreneur, ceo, marketing, finance, fundraising | | `news` | politics, economy, election, journalist, parliament | | `lifestyle` | wellness, mindfulness, productivity, minimalism, diy | | `other` | fewer than two distinct in-category keywords matched (the input was long enough to evaluate, but nothing corroborated a single category) | ### Matching rules A category must match at least two distinct keywords to win at all. A single incidental word ("team", "protein", "producer") resolves to `"other"` rather than a confident wrong label. This is the most common reason a bio you would call obviously on-topic comes back as `"other"`. - **Short keywords** (3 characters or fewer, single word) such as `"ai"`, `"tv"`, and `"dj"` require an **exact token match**. They match `"building with ai"` but not `"hair"` or `"said"`. - Those same three tokens are ambiguous across languages (`"ai"` is colloquial Portuguese and French), so they count only when `computed.language` is `en` or `null`. `content_category` is therefore computed **after** `language` and can depend on it: the same bio scores differently once the text is known not to be English. - **Longer or multi-word keywords** use a Unicode word-boundary regex. `"machine learning"` matches inside `"I love machine learning!"` but does not bleed into adjacent words. - Among categories that clear the two-keyword bar, the highest-scoring one wins. Ties resolve to whichever category is declared first in the source order (`tech, food, gaming, fashion, beauty, fitness, travel, music, education, entertainment, sports, business, news, lifestyle`), not alphabetically. If you build product on top of `content_category`, treat it as a rough first-pass classifier, not a taxonomy. It is keyword-based: fast, deterministic, and noisy. For nuanced classification, layer your own model on top of the bio and text fields. ## estimated_reach A rough upper-bound estimate of how many distinct accounts a post is reaching. Always an integer `≥ 0`, or `null`. ### Author variant (profiles): always null On the `Author` archetype, `estimated_reach` is **always `null`**. Reach is not computable from a follower count plus a followers-normalised engagement rate. A profile payload carries no impressions or views signal the way the `Post` archetype has `views`. An earlier formula (`followers * engagement_rate * 0.1`) produced numbers that could fall _below the like count of a single post_ on large accounts, which is a fabrication rather than an estimate, so it was removed. The field is kept in the block for shape stability. ### Post variant ```text title="Formula" estimated_reach = round(views * 1.2) ``` For posts, views is already a stronger reach signal than impressions, so a modest multiplier estimates unique-account reach, assuming a small fraction of repeat views. Returns `null` whenever `views` is missing or `0`. **`views` is the only gate**, so whether reach populates on a given post is entirely a question of whether the platform exposes a view count for that media type: - **Instagram**: reels and videos carry a play count, so they get a reach estimate. **Photo posts and photo-only carousels have no view count on Instagram, so `estimated_reach` and `engagement_rate` are always `null` on them**, while `likes` and `comments` still populate normally. A carousel that contains a video child does carry a play count and does get a reach estimate. - **Twitter/X**: tweets without a native view count (older tweets, many replies) return `null`. - **Reddit, Threads list items**: no per-post views, so always `null` there. ### Caveats This is a heuristic, not a measurement. It is useful for: - **Sorting** posts or creators by approximate reach when the platform does not expose reach directly. - **Estimating** order-of-magnitude impact for influencer outreach. It is **not** useful for: - Forecasting paid-media ROI. - Comparing reach across platforms with very different view-counting rules, TikTok's auto-loop views against YouTube's 30-second threshold for example. ## Example response ```json title="Response" { "success": true, "platform": "tiktok", "endpoint": "/v1/tiktok/profile", "data": { "author": { "username": "mrbeast", "followers": 95000000, "likes_count": 8200000000, "bio": "I want to make the world a better place before I die." }, "computed": { "engagement_rate": null, "language": "en", "content_category": "other", "estimated_reach": null }, "_warnings": [ "computed.engagement_rate: author ratio exceeded 1.0 (raw: 86.315789); returned null. A lifetime likes/followers ratio is not a real engagement rate" ] }, "credits_used": 1, "credits_remaining": 8431 } ``` Three things to read off that response. The `likes_count / followers` ratio blows past `1.0` because TikTok reports cumulative lifetime hearts against current followers, so the author path reports `null` with a warning rather than fabricating `1.0`. `estimated_reach` is `null` because this is the `Author` archetype. And `content_category` is `"other"` because that bio, despite reading as broadly lifestyle-ish, matches no category keyword twice. ## When you should not trust the value The raw value blew through `[0, 1]`. `clamped` appears on post rows, `returned null` on profile rows. Decide whether you want the returned value or a recomputation from the raw upstream fields. The post lacks a `views` field. Instagram photo posts, pre-2020 tweets, some Reddit endpoints, and a few Facebook surfaces are the common offenders. On Instagram this is per-media-type: a reel gets a rate and a reach estimate, while the photo posted an hour later by the same account gets `null` for both. On Latin scripts, what remained after stripping URLs, handles, and hashtags was probably under 30 characters or under 3 words, or the bio reads as a title rather than prose. Read `author.bio` directly to confirm. This does not apply to Korean, Japanese, Chinese, Arabic, Devanagari, or Thai bios, which classify at any length. ## Next steps - [Response schema](/docs/response-schema.md#successful-response): Where the computed block sits in the envelope. - [Partial-data warnings](/docs/response-schema.md#data_warnings-partial-data-channel): How warning strings are surfaced on a response. - [Metric substitutions](/docs/metric-substitutions.md): Which canonical fields are structurally null per platform. - [Endpoint pricing](/docs/endpoint-pricing.md): Every endpoint and what each call costs.