Post schema
The unified SocialCrawl post schema. Every field, its type, per-platform availability, and a machine-readable JSON Schema, generated from the canonical Zod source.
Every SocialCrawl endpoint that returns a post gives you this exact shape, whatever the source platform. Write your parser once and the same code reads post data from every platform below. That is the unified schema: one contract instead of a dozen raw upstream formats.
Field reference
| Field | Type | Nullable | Description |
|---|---|---|---|
id | string | No | Platform-specific post ID (always a string; numeric upstream IDs are stringified) |
url | string | Yes | Direct URL to the post on the source platform |
content.text | string | Yes | Post caption, description, or text content |
content.media_urls | string or string[] | Yes | URL(s) of the primary media. Single string for video/photo posts; array of strings for carousels. |
content.thumbnail_url | string | Yes | URL to thumbnail image |
content.duration_seconds | number | Yes | Video/clip duration in seconds (null for non-video posts) |
author.username | string | Yes | Author username |
author.display_name | string | Yes | Author display name |
author.avatar_url | string | Yes | URL to author profile picture |
author.verified | boolean | Yes | Whether the author account is verified |
engagement.views | number | Yes | View count (if available). For Instagram video/reels this is the play count: the headline 'Views' the IG app shows. Instagram removed per-post play counts from its public web pages in August 2026, so GET /v1/instagram/post now fills the count automatically from a second source within the same call: videos and reels keep a numeric views with no extra endpoint or workaround. Note: since mid-July 2026 Instagram's play count is Instagram-only. It no longer includes Facebook crosspost views (Meta-side change; the app UI changed identically). For combined reach, also fetch the Facebook crosspost via /v1/facebook/post. |
engagement.likes | number | Yes | Like / reaction count |
engagement.comments | number | Yes | Comment count. Note: on GET /v1/instagram/post this is Instagram's edge_media_to_parent_comment.count (top-level comments); the IG list endpoints report the mobile comment_count total (includes replies). Both land on this same field. |
engagement.shares | number | Yes | Share / repost / retweet count. On Instagram this is reshare_count (the paper-plane Share / Send count) and it exists only on Instagram's mobile surface, so it is returned by /v1/instagram/post/stats, /v1/instagram/profile/reels/full, and /v1/instagram/profile/posts/full. The web-sourced Instagram list and search endpoints return null (never fabricated). |
engagement.saves | number | Yes | Save / bookmark count. null on Instagram: the save count is platform-private and Instagram exposes no numeric save metric on any surface (never fabricated). |
flags.nsfw | boolean | Yes | NSFW flag (null when platform does not surface) |
flags.spoiler | boolean | Yes | Spoiler flag (null when platform does not surface) |
flags.pinned | boolean | Yes | Pinned-to-profile flag (null when platform does not surface) |
flags.deleted | boolean | No | Whether the post is tombstoned (always present) |
flags.likes_hidden | boolean | Yes | Present and true when the creator has hidden the like count. engagement.likes is null in that case (never the platform's decoy preview number). Absent on normal posts. |
flags.comments_hidden | boolean | Yes | Present and true when the upstream reports the comment count as hidden. engagement.comments is null in that case. Absent on normal posts. |
flags.shares_hidden | boolean | Yes | |
flags.views_hidden | boolean | Yes | |
flags.saves_hidden | boolean | Yes | |
published_at | string or number | Yes | Post creation timestamp as an ISO 8601 UTC string. When the upstream sent a Unix epoch it is converted here and the raw epoch is preserved under post.ext.published_at_epoch for one deprecation cycle. |
Extension fields (ext)
Platform-specific passthrough. Each field is present only on the platforms that expose it and is absent everywhere else, so treat every ext.* field as optional. These carry the richer, per-platform signals the unified leaves cannot hold, and the join keys that chain endpoints together.
| Field | Type | Nullable | Description |
|---|---|---|---|
ext.music_id | string | Yes | TikTok music/clip id (exact string): pass to /v1/tiktok/song/videos?clipId= to find videos using the same sound |
ext.matched_song_id | string | Yes | TikTok only. The catalogue song TikTok matched the video's sound to, as an exact digit string. An official clip and the original sounds creators upload with it have different music_id values and the same matched_song_id, so group rows on it to count every use of one song. It is not a clipId: /v1/tiktok/song and /v1/tiktok/song/videos do not accept it. /v1/tiktok/search/music carries it on each sound, so a search on the song's title and artist, kept to the sounds with the same matched_song_id, gives the song's official sounds and their video_count. Absent when TikTok matched the sound to no song, and on rows served from a source that does not report the match. |
ext.matched_song_title | string | Yes | TikTok only. The title of the song in matched_song_id. Absent when there is no matched song. |
ext.matched_song_artist | string | Yes | TikTok only. The artist of the song in matched_song_id, as TikTok's catalogue names it. Absent when there is no matched song. |
ext.music_title | string | Yes | TikTok only. The title of the video's own sound (music_id). An uploaded sound is titled like original sound - <handle>; the song it plays, when TikTok recognised one, is matched_song_title. |
ext.music_artist | string | Yes | TikTok only. The artist TikTok shows for the video's own sound. On an original sound this is the uploader's display name, not the song's artist; use matched_song_artist for that. |
ext.music_is_original | boolean | Yes | TikTok only. true when the video's sound is an original sound a creator uploaded, false when it is a catalogue track. Absent when the source does not say. |
ext.author_id | string | Yes | The creator's platform-native numeric user id. On TikTok search and list items, pass to /v1/tiktok/profile?user_id= for the creator's current follower count (survives username changes). On Instagram /v1/instagram/search/reels, /v1/instagram/search/hashtag and /v1/instagram/location/posts items it is present on every row, accepted by /v1/instagram/profile/country?user_id= and /v1/instagram/basic-profile?userId=. On Facebook it appears when the upstream exposed a numeric actor id and no real handle. |
ext.author_url | string | Yes | The posting account's profile URL, so a matching post can be followed to its account. Present on /v1/facebook/search/posts rows: the vanity or /people/<name>/<id>/ URL when Facebook sends one, otherwise https://www.facebook.com/profile.php?id=<numeric id> built from the account id. Pass it as url to /v1/facebook/profile (or to /v1/prism/profiles with platform: facebook). Null when the row names neither an account URL nor a numeric account id. Absent on other endpoints. |
ext.author_followers | number | Yes | The creator's follower count as embedded in the search payload itself, when the search source happens to carry one. On Instagram search/reels it is null on a plain call, because Instagram stopped sending follower counts in its search payload in August 2026; send include=creator (or country) and it is filled from the creator's profile, 2 credits per creator looked up. It stays null when that lookup does not resolve. |
ext.author_following | number | Yes | |
ext.author_posts_count | number | Yes | |
ext.author_location | string | Yes | The free text the author wrote in their profile's location field, exactly as written (for example Johannesburg, South Africa). It is not a country code and is not checked; discover reads a country from it into geo.country (source: author_location). Present on X tweet and reply rows; null or absent when the author left it blank. |
ext.in_reply_to_id | string | Yes | |
ext.in_reply_to_username | string | Yes | |
ext.conversation_id | string | Yes | |
ext.author_country | string | Yes | The creator's country. On Instagram it is the country the creator declares on the About this account panel, as a country name (for example Spain), and it is not where the post was filmed. It is present on /v1/instagram/search/reels when the request sends include=creator, include=country or country, and on /v1/instagram/search/hashtag and /v1/instagram/location/posts when it sends include=country, filled by looking the creator up. On YouTube rows joined with include=channel it is the channel's own country as an ISO 3166-1 alpha-2 code (for example ZA). Null when the platform publishes no country for the creator or the lookup did not resolve, and nothing is guessed; absent on a plain call. |
ext.author_public_email | string | Yes | The public contact email on the creator's profile. Present only on /v1/instagram/search/reels when the request sends include=creator, filled by looking the creator up. Null when the creator lists no public email or the lookup did not resolve; absent on a plain call. |
ext.author_public_phone | string | Yes | The public contact phone number on the creator's profile. Present only on /v1/instagram/search/reels when the request sends include=creator, filled by looking the creator up. Null when the creator lists no public phone or the lookup did not resolve; absent on a plain call. |
ext.download_count | number | Yes | How many times the video has been saved to a device. TikTok only, and distinct from engagement.saves: a save keeps the video in a private collection on the platform, a download takes a copy off it, and TikTok counts the two separately. Present on TikTok post, search and post-list responses; absent everywhere else. |
ext.region | string | Yes | TikTok only: the country TikTok registers the video to, as an ISO 3166-1 alpha-2 code (normally the creator's account country when they posted). It is not the viewer's country, not the region you requested, and not a language. On /v1/tiktok/trending, /v1/tiktok/search, /v1/tiktok/search/top, /v1/tiktok/profile/videos, /v1/tiktok/search/hashtag and /v1/tiktok/song/videos it is how you tell which rows are from a given country: filter on it when you need only that country. A row whose source omits the country stays null. The region request parameter still only sets the proxy. |
ext.subreddit | string | Yes | Reddit subreddit name (search/list items): pass to /v1/reddit/subreddit/details?subreddit= |
ext.title | string | Yes | |
ext.selftext | string | Yes | |
ext.link_domain | string | Yes | |
ext.upvote_ratio | number | Yes | Reddit only: the share of a post's votes that are upvotes, as a fraction from 0 to 1 and usually close to 1 (six live rows measured 1, 1, 1, 0.86, 0.75, 1). Fractional despite the integer type this schema emits for every numeric leaf, the same caveat as author.ext.average_rating. Populated on /v1/reddit/search rows whose source carries it, and null everywhere else on Reddit including /v1/reddit/post and /v1/reddit/subreddit. |
ext.flair | string | Yes | |
ext.content_language | string | Yes | The language the platform itself tags the post with, when it sends one: Reddit's own language tag on Reddit lanes, and TikTok's caption-language tag (for example de) on /v1/tiktok/trending?feed=local. Null when the platform could not tell (Reddit und, TikTok un, common on short or emoji-only captions); absent where the source sends no tag. Never inferred from the text by us: that is post.computed.language. |
ext.type | string | Yes | |
ext.content_type | string | Yes | |
ext.ticker_symbols | string[] | Yes | |
ext.video_view_count | number | Yes | Instagram's legacy 3-second-view count. Historical: Instagram stopped serving it in August 2026 (removed from its web post surface along with the web play count), so fresh /v1/instagram/post lookups no longer carry it and no source can restore it. Use engagement.views (the play count) instead. |
ext.ig_play_count | number | Yes | Instagram-only play count (ig_play_count). Since mid-July 2026 Instagram's headline play count (engagement.views) no longer includes Facebook crosspost views and equals this value; it is surfaced explicitly so you can tell the Instagram-only figure apart and detect any future re-divergence. For combined Instagram + Facebook reach, also fetch the Facebook crosspost via /v1/facebook/post. Present on /v1/instagram/post/stats and, since the August 2026 views fix, on /v1/instagram/post for video posts. |
ext.repost_count | number | Yes | |
ext.media_type | string | Yes | |
ext.text_truncated | boolean | Yes | |
ext.ip_location | string | Yes | |
ext.carousel_count | number | Yes | |
ext.remix_count | number | Yes | |
ext.audio_cluster_id | string | Yes | Instagram's audio cluster for a reel, as an exact digit string: the id Instagram uses to group different uploads of the same sound. Present on /v1/instagram/audio/reels rows and on /v1/instagram/profile/reels with include=stats; absent on a row when it is not available and on every other surface. |
ext.facebook_likes | number | Yes | Instagram only: the Facebook cross-post share that was EXCLUDED from engagement.likes on this row. A post shared to Facebook arrives from some sources with its Facebook likes already folded into the Instagram count, so the share is subtracted and published here, which keeps engagement.likes the Instagram-only figure instagram.com shows on the post. Add the two together for the combined number. Absent when the read carried no Facebook share, and absent on a post whose owner hides the like count, because nothing can be subtracted from a null. A response that excluded a share also carries a note in data._warnings. |
ext.facebook_comments | number | Yes | Instagram only: the Facebook cross-post share that was EXCLUDED from engagement.comments on this row, the comment counterpart of post.ext.facebook_likes. Each leaf appears only for the count it actually changed, so a post whose likes are hidden but whose comments were reconciled carries this one alone. |
ext.instagram_views | number | Yes | Instagram only: the Instagram-only play count of a video, the same figure on every Instagram endpoint. engagement.views keeps each endpoint's established meaning: on /v1/instagram/profile/reels, /v1/instagram/audio/reels, /v1/instagram/search/reels and /v1/instagram/search/hashtag it can include the post's Facebook cross-post views, while on /v1/instagram/post and /v1/instagram/post/stats it is Instagram-only. Read this leaf when you compare one post across endpoints. Absent on photos and when the source did not report the Instagram-only figure. |
ext.facebook_views | number | Yes | Instagram only: the Facebook cross-post part of a video's play count, the counterpart of post.ext.instagram_views. Present only when the source reported the Facebook part, or a combined total above the Instagram figure. Absent does not mean zero: some sources report the Instagram figure alone even for a cross-posted video. |
ext.published_at_epoch | number | Yes | Raw Unix epoch for published_at (seconds, or milliseconds when the upstream sent millis). Present only when the upstream sent a numeric epoch that was normalised to the ISO 8601 published_at string. Kept for one deprecation cycle for integrations pinned to the numeric form. |
ext.usertags | any[] | Yes | |
ext.coauthors | any[] | Yes | Instagram collaborative posts (the native "Collab" feature). The full list of co-author accounts on the post, as { id, username, full_name, is_verified, profile_pic_url }. A collab post has ONE producer and appears in every co-author's grid, so post.author is whichever account created it, which is not necessarily the profile you queried. The complete set of accounts on a post is post.author.username plus every username in this array. An empty array means Instagram reports the post as NOT a collab; the field is absent on surfaces that carry no co-author signal, including /v1/instagram/post (its web source ships the field permanently empty, so use /v1/instagram/post/stats for a single post). |
ext.music | object | Yes | |
ext.sponsor_tags | any[] | Yes | Instagram only: the brand accounts the post tags as its paid partner, as usernames (["rolex"]). [] on a post with no partner tagged. Present on /v1/instagram/post/stats; absent on every other endpoint. |
ext.sponsored_disclosed | boolean | Yes | The platform's own disclosure that the post is paid or promotional: on Instagram the paid-partnership label (is_paid_partnership) or a tagged brand partner, on TikTok the label TikTok shows viewers ("Paid partnership", "Commission paid", "Promotional content"). false when the post carries the flag unset, null when it carries none. Present on /v1/instagram/post/stats, /v1/instagram/profile/posts, /v1/instagram/profile/reels, /v1/instagram/search/reels, /v1/tiktok/profile/videos and /v1/tiktok/search; absent on every other endpoint. |
ext.ai_label | string | Yes | Instagram only: Instagram's AI-content label on the post, as the method Instagram reports: classifier_score_high (Instagram detected it, shown as "Likely made with AI"), ai_created or ai_edited (declared or detected as made or edited with AI). Null when the post carries no AI label. Present on /v1/instagram/post/stats; absent on every other endpoint. |
ext.story_mentions | string[] | Yes | Instagram stories only: the accounts the story mentions with a mention sticker, as usernames in alphabetical order. [] when it mentions nobody. Present on /v1/instagram/stories; absent on every other endpoint. |
ext.story_links | string[] | Yes | Instagram stories only: where the story's link stickers lead, as URLs in alphabetical order. Instagram wraps each link in its own redirect with a per-view click id; both are removed, so this is the destination itself. [] when the story has no link sticker. Present on /v1/instagram/stories and /v1/instagram/highlight/detail; absent on every other endpoint. |
ext.story_reshared_posts | string[] | Yes | Instagram stories only: the feed posts the story reshares, as post URLs (https://www.instagram.com/p/{shortcode}/, which /v1/instagram/post takes). [] when it reshares none. Present on /v1/instagram/stories; absent on every other endpoint. |
ext.location | object | Yes | |
ext.media | object[] | Yes | |
ext.top_comments | object[] | Yes | |
ext.hashtags | string[] | Yes | |
ext.mentions | string[] | Yes | |
ext.mentioned_user_ids | string[] | Yes | |
ext.quoted_post | object | Yes | Threads quote posts: the post this one quotes, as { id, url, author: { id, username }, text, media_urls, thumbnail_url }. A quote post usually carries no caption or media of its own, and the text and video you see on the post's page belong to this attachment, by a different author. It is returned here rather than merged into post.content, so content.* always stays the outer post's own content and one account's media is never attributed to another. Absent on posts that are not quote posts. |
ext.retweeted_post | object | Yes | |
ext.quote_count | number | Yes | |
ext.all_media_urls | string[] | Yes | |
ext.reaction_counts | any[] | Yes | |
ext.share_urn | string | Yes | |
ext.post_type | string | Yes | |
ext.activity_id | string | Yes | |
ext.published_at_precision | string | Yes | |
ext.author_urn | string | Yes | |
ext.author_headline | string | Yes | |
ext.author_type | string | Yes | |
ext.is_repost_quote | boolean | Yes | |
ext.article | object | Yes | |
ext.reaction_type | string | Yes | |
ext.download_media_urls | object[] | Yes | |
ext.tags | string[] | Yes | |
ext.categoryId | string | Yes | |
ext.categoryTitle | string | Yes | |
ext.topicCategories | string[] | Yes | |
ext.duration | string | Yes | |
ext.license | string | Yes | |
ext.madeForKids | boolean | Yes | |
ext.defaultAudioLanguage | string | Yes | |
ext.hasPaidProductPlacement | boolean | Yes | |
ext.caption | string | Yes | |
ext.position | number | Yes | |
ext.playlistId | string | Yes | |
ext.videoOwnerChannelId | string | Yes | |
ext.videoPublishedAt | string | Yes | |
ext.description | string | Yes | |
ext.default_language | string | Yes | |
ext.localizations | object | Yes | |
ext.playlist_item_id | string | Yes | |
ext.playlist_owner_channel_id | string | Yes | |
ext.playlist_owner_title | string | Yes | |
ext.channel_id | string | Yes | |
ext.published_label | string | Yes | |
ext.published_precision | string | Yes | |
ext.title_maybe_translated | boolean | Yes | |
ext.video_count | number | Yes | |
ext.posts_count | number | Yes | /v1/instagram/hashtag only: the exact number of public posts Instagram counts under the hashtag. Live: it moves between calls minutes apart. |
ext.commerce | object | Yes | |
ext.on_screen_texts | string[] | Yes | |
ext.topic_tag | string | Yes | |
ext.reshare_count | number | Yes | |
ext.dsp_ids | object | Yes | |
ext.topic_tag_id | string | Yes | |
ext.amazon_shop_lists | object[] | Yes | |
ext.amazon_shop_trending_picks | object[] | Yes | |
ext.amazon_shop_curations | object[] | Yes | |
ext.amazon_shop_socials | string[] | Yes | |
ext.ad | object | Yes | Ad Library envelope (Facebook adlibrary/ad and adlibrary/company/ads items): page_id, currency, spend, reach_estimate, publisher_platforms, categories, targeted_or_reached_countries, cta_text, cta_type, link_url, is_active, end_date_iso, display_format, title, video_hd_url, video_sd_url. Absent on every non-ad surface. |
ext.apple_music | object | Yes | |
ext.feedback_id | string | Yes | |
ext.question_id | string | Yes | Naver KnowledgeiN (/v1/naver/kin/search) only: the question's id (Naver's docId), read from the row's link. Kin returns one row per answer, so several rows can share it; send group=question for one row per question. |
ext.answer_no | number | Yes | Naver KnowledgeiN only: which answer of the question this row is (Naver's answerNo), read from the row's link. |
ext.answer_nos | number[] | Yes | Naver KnowledgeiN with group=question only: every answer number of the question that was on the page, best-ranked first. |
ext.published_at_est | string | Yes | Naver KnowledgeiN only: the question's date ESTIMATED from its id (YYYY-MM-DD, Korea time), never a published date, so published_at stays null. Absent for questions older than the calibration table. |
ext.date_basis | string | Yes | How post.ext.published_at_est was derived. kin_docid_estimate: interpolated from the question id against question pages whose posted date was read. |
ext.event | object | Yes | |
ext.trend | object | Yes | Trend-board envelope: what TikTok's own trend board says about the row. On /v1/tiktok/hashtags/popular: rank, country_code, period_days, posts and views for the window (not lifetime totals), industry / industry_id / industry_label for the first board the hashtag was read from (null for the overall board), boards listing EVERY board it ranks on as { industry, industry_id, industry_label, rank } in board order (so a hashtag in the top 3 overall and on an industry board shows both), popularity_curve as { date, value } with value 0 to 100 relative to the window's peak, and top_creators. On /v1/tiktok/videos/popular: rank, country_code, period_days, order_by, period_views, organic_views, engagement_rate, six_second_view_through_rate, content_tags. Absent on every non-board surface. |
ext.updated_at | string | Yes | |
ext.alt_text | string | Yes | |
ext.board_name | string | Yes | |
ext.numeric_id | number | Yes | |
ext.outbound_url | string | Yes | |
ext.group | object | Yes |
Platform availability
28 platforms return the Post shape. id, flags.deleted are never null on any platform. The fields below vary: yes means the platform populates it (the value may still be null); a blank means the platform never provides it, so it is always null.
| Platform | author.verified | engagement.saves | content.duration_seconds | engagement.shares | engagement.views | author.avatar_url | content.media_urls | engagement.comments | url | content.thumbnail_url | engagement.likes | flags.pinned |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Amazon | yes | yes | yes | |||||||||
| Apple Music | yes | yes | yes | yes | ||||||||
| Bluesky | yes | yes | yes | yes | ||||||||
| Douyin | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | |
| yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | ||
| Gab | yes | yes | yes | yes | yes | yes | ||||||
| GitHub | yes | yes | yes | yes | yes | yes | ||||||
| yes | yes | yes | yes | |||||||||
| Hacker News | yes | yes | yes | |||||||||
| yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | ||
| Kick | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | ||
| Kwai | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | |
| yes | yes | yes | yes | yes | yes | yes | yes | yes | ||||
| Naver | yes | yes | yes | yes | yes | yes | yes | |||||
| yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | ||
| Product Hunt | yes | |||||||||||
| Quora | yes | yes | yes | yes | yes | |||||||
| yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | |
| Rumble | yes | yes | yes | yes | yes | |||||||
| Spotify | yes | yes | yes | yes | yes | |||||||
| Telegram | yes | yes | yes | yes | yes | |||||||
| Threads | yes | yes | yes | yes | yes | yes | yes | yes | yes | |||
| TikTok | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes |
| Truth Social | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | ||
| Twitch | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | ||
| Twitter/X | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | ||
| Xiaohongshu | yes | yes | yes | yes | yes | yes | yes | yes | yes | yes | ||
| YouTube | yes | yes | yes | yes | yes | yes | yes | yes |
Machine-readable schema
Validate responses programmatically against the JSON Schema (2020-12):
Point a validator (Ajv, jsonschema, or your framework's) at that URL, or hand it to an agent so it can check the shape without a live call.
Returned by
Endpoints with the Post / PostList archetype return this shape:
Amazon · Apple Music · Bluesky · Douyin · Facebook · Gab · GitHub · Google · Hacker News · Instagram · Kick · Kwai · LinkedIn · Naver · Pinterest · Product Hunt · Quora · Reddit · Rumble · Spotify · Telegram · Threads · TikTok · Truth Social · Twitch · Twitter/X · Xiaohongshu · YouTube
See how the same fields map to each platform's raw upstream names in the cross-platform field equivalence table.
