# Instagram (/docs/instagram) Instagram profiles, posts, reels, comments, stories, highlights, followers, hashtags, locations, and audio through one API key Public Instagram data without a Meta app review: profiles and their whole feed, reels with view and share counts, comment threads, story trays and saved highlights, the follower graph, hashtag and location feeds, and the audio library. Handles are passed without the leading `@`. Base URL: `/v1/instagram/...` Share counts and follower lists come from a heavier upstream than the rest of the platform. `/v1/instagram/post` carries no share count and no cheap endpoint does. Use `/v1/instagram/post/stats` for one post, or `/v1/instagram/profile/posts/full` and `/v1/instagram/profile/reels/full` for a whole feed. View counts are different: they stay on the 1-credit reads, with `post`, `profile/posts`, and `profile/reels` all carrying them on video. ## Quickstart ### 1. Fetch a profile ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/instagram/profile?handle=instagram" \ -H "x-api-key: YOUR_API_KEY" ``` ### 2. Fetch their content ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/instagram/profile/posts?handle=instagram" \ -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 | Endpoint | Credits | What it returns | Key parameters | | --------------------------------- | ------- | ---------------------------------------------------------------------------------- | ------------------------------------------------ | | `GET /v1/instagram/profile` | 1 | Bio, exact integer follower count, following and post counts, avatar, verification | `handle`, `trim` | | `GET /v1/instagram/basic-profile` | 1 | Name and picture only, for when all you hold is a numeric id | `userId` | | `GET /v1/instagram/profile/full` | 5 | Profile plus recent posts plus computed analytics in one flat call | `handle`, `posts` (1-100, default 25, at most 12 per call), `include` | | `GET /v1/instagram/engagement` | 5 | An overall engagement rate, totals, and a per-post breakdown | `handle` | | `GET /v1/instagram/followers` | 5 or 10 | Who follows this account, about 50 a page | `handle` or `user_id`, `cursor`, `coverage` | | `GET /v1/instagram/following` | 5 or 10 | Who this account follows, about 50 a page | `handle` or `user_id`, `cursor`, `coverage` | | `GET /v1/instagram/similar` | 5 | The accounts Instagram itself suggests as comparable, one fixed list | `handle` or `user_id` | | `GET /v1/instagram/user/embed` | 1 | An embeddable HTML snippet for dropping a profile onto your own page | `handle` | | `GET /v1/instagram/profile/country` | 1 | The country an account declares on its About this account panel | `user_id` | `profile` also returns `author.last_post_at`, the publish time of the newest post among the recent posts the profile read carries, as an ISO 8601 timestamp, at no extra cost. Pinned posts count, and the newest is taken whatever the order. It is null for a private account, for an account with no posts, and when the profile read returned no recent posts, so treat null as unknown rather than as inactive. It is also null with `trim=true`, which skips that read. `profile/full` collapses the first two Quickstart calls into one and adds two average engagement rates, posting cadence, top post, and format mix. One call reads one page of recent posts, which on Instagram is 12, and computes everything over that page: `posts` can shorten the window but a value above 12 still returns 12, with a `_warnings` note, and `posts_cursor` sent back as `cursor` reads the next page for another 5 credits. `computed.avg_engagement_rate` is view-based, the mean of (likes + comments + shares) / views per post, and Instagram publishes no view count on photos and carousels, so on a mostly-photo grid it is null. `computed.avg_engagement_rate_by_followers` divides the mean likes and comments in that window by the follower count instead, the same formula as `computed.engagement_rate` on `profile`. It is null when the follower count is missing and when no post in the window carries a like or comment count, for example an account that hides its likes. `include=computed` only omits the raw posts array from the response; the metrics are computed from the same posts either way. If the posts leg fails the profile still comes back, with the post-dependent metrics null. `profile/country` returns the declared country as `author.ext.country` (`Nigeria`, `South Africa`) and the month the account joined as `author.ext.account_created`. It takes the numeric `user_id`, which every Instagram search row carries as `post.ext.author_id`. A username is refused at no charge with a pointer to `profile/about`. The country is null when the account shows no panel. A repeat call for the same account inside 30 days is served from cache at 0 credits. To put the country on a whole page of rows at once, send `include=country` to a search (see [Creator country on search rows](#creator-country-on-search-rows)). ## Posts and reels | Endpoint | Credits | What it returns | Key parameters | | -------------------------------------- | ------- | --------------------------------------------------------------------------------- | --------------------------------------- | | `GET /v1/instagram/profile/posts` | 1 | The cheap feed pull: likes, comments, and views on video | `handle`, `next_max_id`, `trim`, `label`, `judgments` | | `GET /v1/instagram/profile/reels` | 1 | The same for reels | `handle` or `user_id`, `max_id`, `trim`, `label`, `judgments` | | `GET /v1/instagram/profile/posts/full` | 5 to 25 | The feed plus a per-item share count and a `coverage` figure | `handle` or `user_id`, `limit` (max 50) | | `GET /v1/instagram/profile/reels/full` | 5 to 25 | The same for reels | `handle` or `user_id`, `limit` (max 50) | | `GET /v1/instagram/post` | 1 | Caption, media, tagged users, engagement, and the play count on video | `url`, `download_media` | | `GET /v1/instagram/post/stats` | 5 | Adds the paper-plane share count on video | `url` | | `GET /v1/instagram/post/likers` | 5 | A ranked sample of who liked a post | `url` | | `GET /v1/instagram/media/transcript` | 10 | The spoken words in a video or reel up to 10 minutes long | `url` | | `GET /v1/instagram/tagged` | 5 | Posts other people tagged an account in, a different question from what it posted | `handle` or `user_id`, `cursor` | The two `full` variants are metered: they consume upstream pages of 12 items and bill 5 credits per page, from 5 up to 25 for a `limit=50` walk. Both need a `handle` for the share leg. A `user_id`-only call still returns views, likes and comments, with shares null and a partial refund. Instagram removed play counts from its public web pages in August 2026, so `post` now fills the count from a second source inside the same call. You still make one request and pay one credit. `media/transcript` covers videos up to 10 minutes (600 seconds). A longer video returns 400 with `error.details.reason: video_too_long`, and a reel with no speech returns 404 `no_speech`. Neither costs credits, and both are final for that URL, so check `post.content.duration_seconds` on `post` first when lengths vary. See [error reasons](/docs/errors.md). ## Comments | Endpoint | Credits | What it returns | Key parameters | | ---------------------------------------- | ------- | -------------------------------------------------------------- | ---------------------------------------------- | | `GET /v1/instagram/post/comments` | 5 | A post's comment section, ranked by popularity by default | `url`, `sort`, `cursor`, `label`, `judgments` | | `GET /v1/instagram/post/comment/replies` | 1 | One comment's replies, not a second copy of the section | `url` **and** `comment_id`, both required; optional `label`, `judgments` | | `GET /v1/instagram/comment` | 5 or 15 | One named comment, or comments matching an author or a snippet | `comment_url`, or `post_url` plus `comment_id` | Pass `sort=recent` whenever you intend to walk a whole thread. Instagram only ranks the most-liked head of the list, so a `top` walk starts repeating comments once you page past it. Page deeper with the `next_cursor` from each response. `GET /v1/instagram/comment` is a bounded server-side scan rather than a direct fetch, because Instagram exposes no fetch-one-comment read. It costs 5 credits, or 15 with `deep_scan` for comments buried deep in a large thread. Passing back the `lookup.position_hint` from an earlier call replays the last-known sort chain first, which makes re-checking a comment you have already found much cheaper. ## Stories and highlights | Endpoint | Credits | What it returns | Key parameters | | ------------------------------------ | ------- | ----------------------------------------- | ---------------------------- | | `GET /v1/instagram/stories` | 5 | The tray a user has live right now | `handle` or `user_id` | | `GET /v1/instagram/story/download` | 5 | One story's full-resolution media | `user_id` **and** `story_id` | | `GET /v1/instagram/highlights` | 1 | The saved collections pinned to a profile | `handle` or `user_id` | | `GET /v1/instagram/highlight/detail` | 1 | One highlight opened by id | `id` | An account with nothing live returns an empty list rather than an error. Highlights are the permanent version of the same thing. ## Search and discovery | Endpoint | Credits | What it returns | Key parameters | | ---------------------------------------- | ------- | ---------------------------------------------------------------- | ------------------------------ | | `GET /v1/instagram/search` | 1 | Instagram's own mixed search box: accounts, hashtags, and places | `query` | | `GET /v1/instagram/search/profiles` | 1 | Accounts found by bio or caption keyword | `query`, `cursor` | | `GET /v1/instagram/search/popular` | 1 | The popular-posts feed for a keyword | `query`, `cursor` | | `GET /v1/instagram/search/reels` | 1 | Reels matching a keyword or phrase, page-paginated | `query`, `date_posted`, `page`, `region`, `country`, `include`, `label`, `relevance`, `judgments` | | `GET /v1/instagram/search/hashtag` | 5 | A tag feed, with `type` selecting `top`, `recent`, or `clips` | `hashtag`, `type`, `cursor`, `include` | | `GET /v1/instagram/search/location` | 5 | Turns a place name into `location.pk` for `location_id` | `query` | | `GET /v1/instagram/location/posts` | 5 | What was posted at that place, newest first, about 60 a page | `location_id`, `cursor`, `include` | | `GET /v1/instagram/reels/trending` | 5 | A global sample of Instagram's public trending page | none | | `GET /v1/instagram/username-suggestions` | 5 | Invented available handle ideas from a keyword | `query` | `search/profiles` reads Google's index of Instagram rather than Instagram's own account search, so treat it as discovery rather than a lookup. `username-suggestions` is unrelated to it despite the name: it does not look anything up. `reels/trending` takes no parameters, sends a small batch per call, and repeats items, so call again to see more. **Neither trending endpoint takes a region.** Instagram builds the trending reels feed and the trending music chart for the account viewing them, not for a place, so there is no country to pass and none is planned. For posts from one region, look up places with `search/location` (a city, a neighbourhood, a venue) and page each place's posts with `location/posts`, or send `region` to `search/reels` (below). ### Hashtag feeds: every ranking pages `GET /v1/instagram/search/hashtag` takes `type=top` (the default), `type=recent`, or `type=clips`, at 1 credit a page. All three paginate: forward the `next_cursor` from each response and send the same `type` again with it, until `has_more` is `false`. `recent` walks back through the feed newest first; `top` and `clips` page through Instagram's ranked posts and reels. To collect many pages in one call, send `max_items` (up to 5000) or `max_pages` (up to 50), with `max_credits` to cap the spend. `instagram/hashtag` reports Instagram's own post count for the tag, which counts every post ever tagged. The feeds expose only part of that, so on a large tag a walk ends well before that number. ### Reel search ordering is relevance, not a stable list `GET /v1/instagram/search/reels` returns Instagram's own relevance ranking for the keyword, and that ranking is live. The same query re-run minutes later can come back with a different, partially overlapping set of reels, and pages can overlap at their boundaries. No parameter pins an ordering, and `date_posted` narrows the window rather than sorting it. `date_posted` accepts `last-week`, `last-month`, and `last-year`. `last-day` and `last-hour` stopped being served around 26/08/2026, and a request carrying either is rejected before any call is made, at no charge, with an error that names the three values that work. Note that a filtered search returns `engagement.views` as null, because the only surface that offers a date filter no longer carries play counts; if you need both a date window and view counts, omit `date_posted` and filter on `published_at` yourself. Build on that rather than against it. Rank on `engagement.views` in your own code instead of trusting result order, deduplicate by `post.id` across pages and across runs, and if you need a stable view of a keyword over time, accumulate results into your own store across repeated runs. For a creator's complete back catalogue in a fixed order, walk `profile/reels` instead: a profile listing is stable in a way a relevance search cannot be. ### Local results and the creator card on reel search `GET /v1/instagram/search/reels` takes three optional parameters. Each works alone, and they combine. Without them the call is unchanged. ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/instagram/search/reels?query=restaurantes®ion=ES&country=ES" \ -H "x-api-key: YOUR_API_KEY" ``` - **`region`** (`ES`, `MX`, `DE`, `BR`, `KR`, `FR`, `ZA`, `NG`, `KE`, `AE` or `SA`) localises the results. Instagram does not rank search by country, so the market's own name is added to your query: `fitness` with `region=ES` searches `fitness españa`. A query that already names the market, or is already written in the market's own script (Hangul for KR), which Instagram localises by itself, is sent as it is. `data.region.query_sent` shows the query that ran. Same price as a plain call. It favours creators from that market but does not promise every creator is from it. - **`include=creator`** adds each reel's creator card from the creator's About this account panel: `post.ext.author_country`, `author_followers`, `author_following`, `author_posts_count`, `author_public_email` and `author_public_phone`. It never drops, adds or reorders rows. 2 extra credits per creator looked up; two reels by one creator are one lookup, a creator that cannot be found is refunded, and a creator looked up in the last 15 minutes is free. `data.hydration` reports the lookups and credits. - **`country`** keeps only the reels whose creator declares that market, and reports what it dropped in `data.country` (`kept`, `dropped_other`, `dropped_unknown`). It adds the creator card at the same price, charged for every creator looked up whether their reels are kept or not, and localises the query too when `region` is not sent. A page where no creator matches returns `items: []` with the warning `country_no_match` in `data._warnings`, and its lookups are still billed. A page left empty only because the creator lookups failed costs nothing. `author_country` is the country the creator declares on Instagram, not where the reel was filmed. A null means Instagram does not publish one for that creator, and nothing is guessed. The same parameter names mean the same thing on TikTok search; only the way `region` works differs, because TikTok searches from the market and Instagram needs the query localised. For the Gulf, `AE` adds `uae` and `SA` adds `الرياض`, to English and Arabic queries alike: on 28/10/2026 `mattress®ion=AE` searched `mattress uae`, and `مراتب®ion=SA` searched `مراتب الرياض`. In a [Discover](/docs/prism/discover.md) run, `instagram.region` does the same for every Instagram reel search. The [Instagram reels from one market](/docs/recipes/instagram-reels-by-market.md) recipe walks through it with code. Topics about a place, such as travel or eSIMs, are often made by visitors, so `region` alone finds fewer local creators there. A phrasing locals use helps (`esim móvil` rather than `esim`), and `country` keeps only the market's creators. Any other market code, or a typo in any of the three, is refused at no charge with the list of valid values. `post.ext.author_followers` is null on a plain call, because Instagram stopped sending follower counts in its search payload in August 2026. `include=creator` fills it. For creators you already hold, `GET /v1/instagram/profile` returns `author.followers`, and `POST /v1/prism/profiles` resolves up to 50 handles per call at 1 credit per resolved profile, failed rows refunded. One call can mix platforms, so TikTok and Instagram creators resolve together. Match rows to your input by each row's `index`. ### Creator country on search rows Send `include=country` to `search/reels`, `search/hashtag` or `location/posts` and each row gets `post.ext.author_country`, the country its creator declares on Instagram, read from `profile/country`. Rows from `search/hashtag` and `location/posts` always carry `post.ext.author_id`, with or without the join. ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/instagram/search/reels?query=funny®ion=ZA&include=country" \ -H "x-api-key: YOUR_API_KEY" ``` The page keeps its own price, and each creator costs 1 more credit. Two rows by one creator are one lookup, a creator looked up in the last 30 days is free, and a creator whose country could not be read is refunded. On 19/10/2026 that call returned 30 reels by 19 creators for 20 credits, and 29 of the 30 rows came back `South Africa`. `data.hydration` reports the join: its `looked_up`, `filled`, `cached` and `unfilled` count rows, and `extra_credits` is the credits it kept. Use `include=creator` on `search/reels` when you also need follower counts and contact fields. ## Audio | Endpoint | Credits | What it returns | Key parameters | | ---------------------------------- | ------- | ------------------------------------------------- | -------------------- | | `GET /v1/instagram/search/music` | 5 | Tracks matching a name, each with an audio id | `query`, `cursor` | | `GET /v1/instagram/music/trending` | 5 | A chart of licensed tracks trending now | none | | `GET /v1/instagram/audio/reels` | 1 | The reels using one sound, cursor-paged | `audio_id`, `cursor` | `audio_id` is the number in an `instagram.com/reels/audio/{audio_id}/` URL. That chain is how you measure a sound's spread rather than its chart position. ## All endpoints ## Platform notes **Media URLs expire.** The `media_urls` on any post are short-lived signed CDN links. For archiving, call `post` with `download_media=true` and read the durable links from `data.post.ext.download_media_urls`, each entry carrying `post_id`, `cdn_url`, `type`, and `cached`. It adds a few seconds of latency while the media is fetched. **Likers are a ranked sample, now about a thousand rows.** `post/likers` returns a ranked slice of a post's likers in one response, regardless of how many likes the post has, at 5 credits. On 14/09/2026 a post with 94,090 likes returned 1,019 of them; earlier releases returned roughly the first 100. The full like _count_ is in the same response as `data.total`, and Instagram exposes no cursor on this surface, so the list is a sample rather than the whole thing. **Follower and following lists, and `coverage=full`.** A plain walk of `followers` or `following` can end with `has_more: false` before the profile's count. On most accounts it now ends within a few rows of that count; on large verified accounts Instagram caps every route at about 50 rows with no cursor, and a plain walk ends there. `data.total` on every page carries the profile's own count, so compare the rows you collected against it. Send `coverage=full` with `handle` to walk a merged list instead: each page is drawn from several reads of the list and carries no account twice inside a page, until the rows reach the count or no read has more. Across consecutive pages a handful of accounts can repeat on the pages one read covered on its own; wherever a single read cannot cover the list, the merged walk runs instead and never returns an account it has already given you. A full-coverage page is quoted at 10 credits instead of 5 and settled back down to 5 on any page a single read covered in full, so the mode costs its full price only on the accounts that need it. A page carries about 50 accounts and usually takes about 8 seconds, occasionally up to 45. A full list therefore costs at most roughly its account count divided by 50, times 10 credits: on 13/09/2026 a following list of 2,652 took 54 pages and a follower list of 2,360 took 59, since the later pages of a follower walk add fewer new accounts. Keep `coverage=full` on every call of the walk, since its cursor only continues that mode, and retry a `503` with the same cursor. ```bash title="cURL" curl "https://www.socialcrawl.dev/v1/instagram/following?handle=billboard&coverage=full" \ -H "x-api-key: YOUR_API_KEY" ``` **A private account is a 404.** `followers` and `following` return `404 RESOURCE_NOT_FOUND` with `details.reason: account_private`, refunded, when the account exists but its lists are hidden. **Like and comment counts are Instagram's own.** For a post also shared to Facebook, the likes and comments it collected on Facebook are not included in `post.engagement`, whichever source answers. That is the figure instagram.com shows under the post. Views follow the same rule. **Photo posts have no views and no shares.** Both come back null on `post/stats`, which is Instagram's behaviour rather than a gap. **A location grid pages back in time.** `location/posts` returns about 60 posts a page, newest first, and `pagination.next_cursor` fetches the next, older page until `has_more` is false. How far one page reaches depends on the place: a busy landmark fills a page in about a day, a neighbourhood covers a week or two, and a quiet spot can span years. Instagram orders the grid by when each post was created, so a scheduled post that went live later sits among older posts, while its `published_at` shows the real publish time. On 13/09/2026 that was about 1 post in 75, up to 13 days apart. To collect a time window, keep paging until a whole page is older than the window's start and filter on `published_at`, rather than stopping at the first older post. Each page is a separate call at 5 credits, and a page takes about 5 to 12 seconds. When the first source fails on a later page, the page is read from a second source and carries `continuation_rescued` in `data._warnings`. Such a page can miss a few posts at its edge. **`music/trending` reflects the viewing account's market.** Instagram serves its music chart to an account, and some accounts are shown Instagram's royalty-free sound library instead of a chart. The endpoint returns only a licensed chart; when none comes back it answers `503` with your credits refunded, so retry after a minute. The market a chart belongs to is not exposed and cannot be selected. **`safe_url=true` where you are embedding.** Several endpoints accept it and return URL-safe profile picture and media links, suitable for dropping straight into a page. **Carousels return every slide.** A sidecar post's `media_urls` is the full array, not just the cover image. **`trim=true` costs the same.** `profile`, `profile/posts`, and `profile/reels` accept it. On `profile` it skips the recent-posts read, so the call returns faster and `author.last_post_at` and `computed.engagement_rate` are null. On the two feed endpoints it returns the full list; only `post.ext.coauthors` is omitted and `post.flags.pinned` is null, because the trimmed record carries neither signal. **`post.id` is the exact 19-digit media id.** On `profile/reels`, `tagged`, and `stories` the last two or three digits were rounded on every row in earlier releases, and `tagged` page 2 repeated page 1. If you stored ids from those three endpoints before this change, re-sync them. Ids from `profile/posts`, `search/hashtag`, and `location/posts` were always exact. **A handle that does not exist is a 404, not an empty list.** `profile`, `followers`, and `following` return `404 RESOURCE_NOT_FOUND` with `details.reason: handle_unresolved`, refunded, when the account cannot be resolved. An existing account whose list is empty still returns `200` with `items: []`. ## Labels These lists label their rows by default, at no extra credit. On `/v1/instagram/post/comments` and `/v1/instagram/post/comment/replies`, each comment carries `sentiment`, `question`, `purchase_intent` and `complaint` on `computed.labels`. On `/v1/instagram/profile/posts`, `/v1/instagram/profile/reels` and `/v1/instagram/search/reels`, each row carries `sponsored`, `intent` and `niche`. `/v1/instagram/search/reels` 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. - [TikTok](/docs/tiktok.md): The other short-form surface most crawls pair with Instagram. - [Threads](/docs/threads.md): Meta