# For coding agents URL: https://herascrape.com/docs/agents If you are pointing an agent at herascrape, you do not need it to crawl these pages. ## Machine-readable docs [#machine-readable-docs] | URL | What it is | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------- | | [`/docs/llms.txt`](https://herascrape.com/docs/llms.txt) | An index: every page, with its title and description. | | [`/docs/llms-full.txt`](https://herascrape.com/docs/llms-full.txt) | Every page's full text, concatenated. One fetch, whole docs. | | [`https://api.herascrape.com/openapi.json`](https://api.herascrape.com/openapi.json) | The OpenAPI (Swagger) 3.1 schema the API serves about itself. | [`llms-full.txt`](https://herascrape.com/docs/llms-full.txt) is the one to hand an agent: one fetch, the whole documentation. The [OpenAPI (Swagger) schema](https://api.herascrape.com/openapi.json) describes the paths, parameters and auth, and is emitted by the running service, so it cannot drift from what is actually served. ### Response bodies are deliberately untyped [#response-bodies-are-deliberately-untyped] The schema declares every successful response as a bare object. That is a choice, not an omission. We hand Instagram's payloads back with as little reshaping as we can manage, because reshaping is where a scraping API silently lies to you: a field we renamed or flattened is a field we have to keep renaming correctly forever, and the day Instagram changes it you get our stale guess instead of their data. The shape is theirs, so it is not ours to freeze in a schema. Publishing a handful of real models beside thirty-odd untyped ones was worse than publishing none: it implied a guarantee for those few and made codegen look viable when it is not. Generate a client for the *paths* from this schema if it helps, but treat every response body as JSON and read the field documentation on each endpoint page for what is actually in it. ## Migrating an existing client [#migrating-an-existing-client] If the codebase already talks to HikerAPI, the [migration guide](https://herascrape.com/docs/guides/hikerapi-migration) ends with a prompt written to be pasted into an agent. It points the agent at [`llms-full.txt`](https://herascrape.com/docs/llms-full.txt), so it pulls these docs itself, and it handles the base URL, the header rename, the path translation and the pagination rewrite. ## Writing a client from scratch [#writing-a-client-from-scratch] The shortest correct brief: ```text Write a client for the herascrape Instagram API. - Base URL https://api.herascrape.com, auth via the `X-Api-Key` header. - Every endpoint is a GET. - List endpoints return {items, next_cursor, has_more}. Page by passing next_cursor back as ?cursor= and looping while has_more is true. Treat the cursor as opaque: never parse or construct one. - Two endpoints page differently: /posts/{id}/comments uses `min_id` (read `next_min_id`, branch on `has_more`), and /users/{id}/posts pages on `cursor` taken from `feed.profile_grid_items_cursor`. - Object responses are Instagram's raw JSON. Do not define response types; return parsed JSON. - Retry 502 and 503 with backoff. Do not retry 4xx. Only 2xx responses are billed. - Users are addressed by numeric id. Resolve a username once via GET /users/username/{username}, cache the `pk`, and use the id after that. Full documentation: https://herascrape.com/docs/llms-full.txt Full schema: https://api.herascrape.com/openapi.json ``` Give the agent [`llms-full.txt`](https://herascrape.com/docs/llms-full.txt) alongside that brief and it has everything: every endpoint, the pagination and error contracts, and a real example response for each. ## Rate limits an agent should respect [#rate-limits-an-agent-should-respect] 10 requests per second per key. An agent doing a bulk crawl should serialise its paging loop rather than fanning out — one Instagram page per call means concurrency buys less than it costs in [`429`s](https://herascrape.com/docs/getting-started/errors#too_many_requests). ## The three links again [#the-three-links-again] * [`https://herascrape.com/docs/llms.txt`](https://herascrape.com/docs/llms.txt) — index of every page * [`https://herascrape.com/docs/llms-full.txt`](https://herascrape.com/docs/llms-full.txt) — the whole docs, one file * [`https://api.herascrape.com/openapi.json`](https://api.herascrape.com/openapi.json) — OpenAPI (Swagger) schema # Overview URL: https://herascrape.com/docs herascrape reads Instagram's public data through 37 REST endpoints. One call fetches one Instagram page. Responses are Instagram's own JSON, passed through untouched. ## Start here [#start-here] A key, a call, and a paging loop. Path-by-path translation, plus a prompt that rewrites your client. Every endpoint, with a playground. $0.0004 per call. Failed calls are free. ## The basics [#the-basics] **Base URL is `https://api.herascrape.com`.** Every endpoint is a `GET`. **Authentication is the `X-Api-Key` header.** Keys come from the [dashboard](https://herascrape.com/api-keys). See [Authentication](https://herascrape.com/docs/getting-started/authentication). **Responses are Instagram's own JSON, in full.** Fields are not renamed or trimmed. Every reference page shows a real example under *What comes back*. **So the shape follows Instagram, not us.** Most endpoints read from Instagram's REST API and look alike. A few read from a different internal source and look different: one post's data can arrive split across two parts of a single response, and the token that turns the page is not always called the same thing. Nothing is missing when that happens — but reading one key and assuming the rest matches the last endpoint will quietly lose data. Skim the endpoint's own *What comes back* before you map it, and treat that page as authoritative over any pattern you inferred elsewhere. **Most lists paginate on one opaque cursor.** These endpoints return the same envelope: ```json { "items": [], "next_cursor": "eyJtYXhfaWQiOiIyNSJ9", "has_more": true } ``` Pass `next_cursor` back as `?cursor=` and stop when `has_more` is `false`. A few endpoints answer in Instagram's own envelope instead and name their own paging token; each says so on its page. See [Pagination](https://herascrape.com/docs/getting-started/pagination). ## Endpoint groups [#endpoint-groups] | Group | Endpoints | What it covers | | ------------------------------------------------- | --------- | ------------------------------------------------------ | | [Users](https://herascrape.com/docs/api/users) | 11 | Profiles, followers, posts, reels, stories, highlights | | [Posts](https://herascrape.com/docs/api/posts) | 3 | A post, its comments, its likers | | [Comments](https://herascrape.com/docs/api/comments) | 2 | Threaded replies and comment likers | | [Search](https://herascrape.com/docs/api/search) | 5 | Users, hashtags, audio, places, blended top | | [Hashtags](https://herascrape.com/docs/api/hashtags) | 4 | Hashtag info, top, recent, reels | | [Locations](https://herascrape.com/docs/api/locations) | 3 | Location info, top, recent | | [Stories and highlights](https://herascrape.com/docs/api/stories-highlights) | 2 | Reel media by id | | [Audio](https://herascrape.com/docs/api/audio) | 3 | Track info, reels using it, trending | | [Utility](https://herascrape.com/docs/api/utility) | 4 | pk⇄code, oEmbed, balance, health | ## Two surfaces, one API [#two-surfaces-one-api] These pages are the reference. The API also serves a generated **OpenAPI (Swagger)** UI at [api.herascrape.com/docs](https://api.herascrape.com/docs) — the same endpoints with no prose, useful for poking the live service. # Audio URL: https://herascrape.com/docs/api/audio Audio ids come from [`GET /search/audio`](https://herascrape.com/docs/api/search#audio), from `clips_metadata` on any reel, or from the trending list. ## Track info [#track-info] Metadata for a track or an original sound: title, artist, duration, and how many reels use it. ### What comes back [#what-comes-back] ```json { "music_canonical_id": "18234567890123456", "audio_page_reporting_id": "...", "formatted_media_count": "1.2M", "is_music_page_restricted": false, "metadata": { "music_info": { "music_asset_info": { "title": "Song Title", "display_artist": "Artist", "duration_in_ms": 29000, "cover_artwork_uri": "https://..." } } }, "spotify_track_metadata": null } ``` Licensed tracks carry `metadata.music_info`; a creator's original sound carries `metadata.original_sound_info` instead, with the reel it came from. Check which is present rather than assuming. ## Reels using an audio [#reels-using-an-audio] The feed behind a sound's page. Standard [cursor](https://herascrape.com/docs/getting-started/pagination); items are media objects identical in shape to [`/posts/{id}`](https://herascrape.com/docs/api/posts). ### What comes back [#what-comes-back-1] The standard list envelope, with media objects in the same shape as [a post](https://herascrape.com/docs/api/posts#what-comes-back). All of them are reels (`product_type: "clips"`). ## Trending [#trending] Instagram's trending-audio list, as shown in the composer. Standard cursor. ### What comes back [#what-comes-back-2] The standard list envelope, with items in the same shape as [audio search](https://herascrape.com/docs/api/search#what-comes-back-2) — a `track` or an `original_sound`, whichever the entry is. ## Errors [#errors] | Code | When | | ---------------------------------------------------------- | -------------------------------------------------------------- | | [`not_found`](https://herascrape.com/docs/getting-started/errors#not_found) | No such audio id, or the sound was removed for rights reasons. | | [`invalid_cursor`](https://herascrape.com/docs/getting-started/errors#invalid_cursor) | The `cursor` was not one we issued. | | [`upstream_error`](https://herascrape.com/docs/getting-started/errors#upstream_error) | Transient. Retry. | # Comments URL: https://herascrape.com/docs/api/comments Comment ids come from [`GET /posts/{id}/comments`](https://herascrape.com/docs/api/posts#comments) — each item's `pk`. ## Replies [#replies] Instagram scopes replies to the parent media, so **`media_id` and `user_id` are required** alongside the comment id: the parent post's id (pk or shortcode) and its owner's numeric id. ```bash GET /comments/18012345678901234/replies?media_id=2168384974851429640&user_id=25025320 ``` Paginated on the standard [cursor](https://herascrape.com/docs/getting-started/pagination). ### What comes back [#what-comes-back] The standard list envelope. Items are comment objects in the same shape as [a post's comments](https://herascrape.com/docs/api/posts#comments) — `pk`, `text`, `created_at`, `comment_like_count`, `user`. ## Likers [#likers] Keyed by comment id alone — no `media_id`. Returns one page; `has_more` is always `false`. ### What comes back [#what-comes-back-1] The standard list envelope, with compact user objects identical to [followers](https://herascrape.com/docs/api/users#what-comes-back-2). ## Errors [#errors] | Code | When | | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | [`not_found`](https://herascrape.com/docs/getting-started/errors#not_found) | The comment was deleted, or the id is wrong. | | [`invalid_parameter`](https://herascrape.com/docs/getting-started/errors#invalid_parameter) | `media_id` is not a valid post id, or names a different owner than `user_id`. | | [`unprocessable_entity`](https://herascrape.com/docs/getting-started/errors#unprocessable_entity) | `media_id` or `user_id` is missing, or `user_id` is not numeric. | | [`invalid_cursor`](https://herascrape.com/docs/getting-started/errors#invalid_cursor) | The `cursor` was not one we issued. | | [`upstream_error`](https://herascrape.com/docs/getting-started/errors#upstream_error) | Transient. Retry. | # Hashtags URL: https://herascrape.com/docs/api/hashtags Hashtags are addressed by **name, without the `#`**. Names are case-insensitive. ```bash GET /hashtags/travel GET /hashtags/travel/top ``` ## Hashtag info [#hashtag-info] Post count, profile media, and Instagram's metadata for the tag. ### What comes back [#what-comes-back] ```json { "id": "17841562887056054", "name": "travel", "media_count": 683000000, "formatted_media_count": "683M", "search_result_subtitle": "683M posts", "is_trending": false, "profile_pic_url": "https://scontent.cdninstagram.com/..." } ``` `media_count` is Instagram's own estimate and moves in large steps on big tags. Treat it as an order of magnitude, not a count. ## Top, recent and reels [#top-recent-and-reels] Three feeds over the same tag: ranked, chronological, and clips-only. All three use the standard [cursor](https://herascrape.com/docs/getting-started/pagination) and return media objects identical in shape to [`/posts/{id}`](https://herascrape.com/docs/api/posts). `top` is Instagram's ranking and is not stable between calls — the same page number can return different posts minutes apart. `recent` is chronological and the right choice for monitoring a tag. ### What comes back [#what-comes-back-1] The standard list envelope, with media objects in the same shape as [a post](https://herascrape.com/docs/api/posts#what-comes-back). ## Errors [#errors] | Code | When | | ---------------------------------------------------------- | ----------------------------------------- | | [`not_found`](https://herascrape.com/docs/getting-started/errors#not_found) | No such tag, or Instagram has blocked it. | | [`invalid_cursor`](https://herascrape.com/docs/getting-started/errors#invalid_cursor) | The `cursor` was not one we issued. | | [`upstream_error`](https://herascrape.com/docs/getting-started/errors#upstream_error) | Transient. Retry. | # Locations URL: https://herascrape.com/docs/api/locations Location ids come from [`GET /search/places`](https://herascrape.com/docs/api/search#places), or from the `location` field on any post. ## Location info [#location-info] Name, category, address, coordinates, and the linked business account where there is one. ### What comes back [#what-comes-back] ```json { "name": "Blue Bottle Coffee", "category": "Coffee Shop", "phone": "+15551234567", "website": "https://bluebottlecoffee.com", "price_range": "$$", "location_address": "1 Ferry Building", "location_city": "San Francisco", "location_zip": "94111", "location_region": "CA", "hours": {} } ``` Business fields are only populated where the place is a claimed business account; a plain geotag returns `name` and little else. ## Top and recent [#top-and-recent] Media posted at the location, ranked and chronological. Both use the standard [cursor](https://herascrape.com/docs/getting-started/pagination) and return media objects identical in shape to [`/posts/{id}`](https://herascrape.com/docs/api/posts). ### What comes back [#what-comes-back-1] The standard list envelope, with media objects in the same shape as [a post](https://herascrape.com/docs/api/posts#what-comes-back). ## Errors [#errors] | Code | When | | ---------------------------------------------------------- | ----------------------------------- | | [`not_found`](https://herascrape.com/docs/getting-started/errors#not_found) | No such location id. | | [`invalid_cursor`](https://herascrape.com/docs/getting-started/errors#invalid_cursor) | The `cursor` was not one we issued. | | [`upstream_error`](https://herascrape.com/docs/getting-started/errors#upstream_error) | Transient. Retry. | # Posts URL: https://herascrape.com/docs/api/posts `/posts/{id}` accepts **either** the numeric media pk **or** the shortcode. The two are interconvertible, so there is no separate by-code or by-URL endpoint — pull the code out of an `instagram.com/p//` URL and pass it straight in. ```bash GET /posts/2168384974851429640 # pk GET /posts/B4XpqViUFUI # shortcode — same post ``` ## The post [#the-post] One call returns the whole object, which is more than it looks. Instagram packs into it what other providers split across extra endpoints: | Looking for | It is already in `/posts/{id}` | | ------------------------ | ----------------------------------- | | Media file URLs | `image_versions2`, `video_versions` | | Carousel children | `carousel_media` | | Reel audio / music | `clips_metadata` | | Tagged accounts (photos) | `usertags` | | Collaborators | `coauthor_producers` | | Like and comment counts | `like_count`, `comment_count` | | Location | `location` | So there is no download, clips-metadata, usertags or comment-count endpoint: fetching the post gives you all of it for one call. On **video** posts, `usertags` comes back empty — Instagram does not populate it for clips. Collaborators still surface in `coauthor_producers`, and caption mentions are in the caption text. If you need a clean tagged-users list for videos, tell us. ### What comes back [#what-comes-back] Instagram's media object, roughly 240 top-level fields. Abridged: ```json { "pk": "2168384974851429640", "id": "2168384974851429640_25025320", "code": "B4XpqViUFUI", "taken_at": 1699999999, "media_type": 8, "product_type": "carousel_container", "caption": { "text": "A caption", "created_at": 1699999999, "user": { "pk": "25025320", "username": "instagram" } }, "like_count": 120345, "comment_count": 892, "play_count": null, "user": { "pk": "25025320", "username": "instagram", "is_verified": true }, "image_versions2": { "candidates": [{ "width": 1080, "height": 1350, "url": "..." }] }, "video_versions": [], "carousel_media": [{ "pk": "...", "media_type": 1, "image_versions2": {} }], "usertags": { "in": [] }, "coauthor_producers": [], "location": { "pk": "213385402", "name": "New York, New York", "lat": 40.7, "lng": -74.0 }, "clips_metadata": null } ``` | Field | Note | | ---------------------------- | ---------------------------------------------------------------------------------------------- | | `media_type` | `1` photo, `2` video, `8` carousel. | | `product_type` | `feed`, `clips` (a reel), or `carousel_container`. | | `caption` | An object, not a string. The text is `caption.text`, and it is `null` for an uncaptioned post. | | `image_versions2.candidates` | Sized variants, largest first. | | `video_versions` | Present on videos and reels; empty on photos. | | `carousel_media` | Child items, each with its own versions. Only on carousels. | | `clips_metadata` | Reel audio and music. `null` unless `product_type` is `clips`. | URLs in `image_versions2` and `video_versions` are signed and expire within hours. Download promptly rather than storing the URL. ## Comments [#comments] Pass the post's owner as `user_id` — the numeric `user.pk` on the post, or the id of the profile you listed the post from: ```bash GET /posts/2168384974851429640/comments?user_id=25025320 ``` This endpoint keeps Instagram's own comment-stream shape rather than the standard list envelope, because its paging token is structural to the response: ```json { "comments": [], "has_more": true, "next_min_id": "...", "media_pk": "2168384974851429640_25025320", "...": "..." } ``` Page by passing `next_min_id` back as `min_id`, and stop when `has_more` is `false`. Treat `next_min_id` as opaque — pass back exactly what you were given. Instagram's own `has_more_comments` appears in this response and does **not** mean what it says: it tracks the inline preview strip, so it reads `false` on a post with thousands of comments. Branch on `has_more` above. Replies are not included — fetch them per comment through [`/comments/{id}/replies`](https://herascrape.com/docs/api/comments). For a carousel, `carousel_media_id` and `carousel_index` scope comments to one child. ### What comes back [#what-comes-back-1] ```json { "comments": [ { "pk": "18012345678901234", "text": "Great shot", "created_at": 1699999999, "comment_like_count": 12, "child_comment_count": 2, "is_liked_by_media_owner": false, "user_id": "51065942432", "user": { "pk": "51065942432", "username": "example" } } ], "next_min_id": "eyJzZXJ2ZXJfY3Vyc29yIjo...", "media_pk": "2168384974851429640_25025320", "has_more_comments": true } ``` `child_comment_count` is how many replies a comment has — fetch them with [`/comments/{pk}/replies`](https://herascrape.com/docs/api/comments), passing the post id as `media_id` and its owner as `user_id`. ## Likers [#likers] Requires the post's owner as `user_id`, the same as [comments](#comments). ```bash GET /posts/2168384974851429640/likers?user_id=25025320 ``` A single page of accounts that liked the post — `has_more` is always `false`. Instagram does not expose deep pagination here, and on high-engagement posts it hides the list entirely. ### What comes back [#what-comes-back-2] The standard list envelope, with compact user objects identical to [followers](https://herascrape.com/docs/api/users#followers-and-following). ## Errors [#errors] | Code | When | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | [`not_found`](https://herascrape.com/docs/getting-started/errors#not_found) | Deleted, or no such pk or shortcode. | | [`private_account`](https://herascrape.com/docs/getting-started/errors#private_account) | The owner is private. | | [`invalid_parameter`](https://herascrape.com/docs/getting-started/errors#invalid_parameter) | The id is neither a valid pk nor a valid shortcode, or a composite id names a different owner than `user_id`. | | [`unprocessable_entity`](https://herascrape.com/docs/getting-started/errors#unprocessable_entity) | `user_id` is missing or not numeric on comments or likers. | | [`upstream_error`](https://herascrape.com/docs/getting-started/errors#upstream_error) | Transient. Retry. | # Search URL: https://herascrape.com/docs/api/search Five searches, all taking `query`. Four paginate on the standard [cursor](https://herascrape.com/docs/getting-started/pagination); places returns a single page. ## Search journey identifiers [#search-journey-identifiers] Accounts, hashtags, audio, and top search require two client-created UUIDs: `search_session_id` and `serp_session_id`. Audio also requires `browse_session_id`. Create them before the first page and repeat the exact same values with every continuation cursor. Use fresh values for a new query or an independent search journey. ```js const searchSessionId = crypto.randomUUID(); const serpSessionId = crypto.randomUUID(); const params = new URLSearchParams({ query: "travel", search_session_id: searchSessionId, serp_session_id: serpSessionId, }); ``` The API validates UUID syntax and normalizes returned IDs to uppercase. It does not require the search and SERP IDs to be equal. A continuation cursor is bound to the exact endpoint, query, and IDs that created it; changing any of them returns `400 invalid_cursor`. Cursors created before this contract are not accepted—restart those searches at page one. ## Accounts [#accounts] Typeahead account search. Items are compact user objects — take `pk` and fetch [`/users/{id}`](https://herascrape.com/docs/api/users) for the full profile. This is also the resolver behind [`GET /users/username/{username}`](https://herascrape.com/docs/api/users#profile-by-username), which adds an exact-match check so a fuzzy hit becomes a clean `404` rather than the wrong account. Prefer that endpoint when you have a specific handle; use this one when you genuinely want a list of candidates. ### What comes back [#what-comes-back] The standard list envelope, with compact user objects identical to [followers](https://herascrape.com/docs/api/users#what-comes-back-2) — `pk`, `username`, `full_name`, `is_private`, `is_verified`, `profile_pic_url`. Search list responses add a `search_context` object that echoes the normalized journey IDs, including on the last page. ## Hashtags [#hashtags] Names with post counts. Use the name with [`/hashtags/{name}`](https://herascrape.com/docs/api/hashtags) for the media. ### What comes back [#what-comes-back-1] ```json { "items": [ { "id": "17841562887056054", "name": "travel", "media_count": 683000000, "formatted_media_count": "683M", "search_result_subtitle": "683M posts", "is_trending": false, "profile_pic_url": "https://scontent.cdninstagram.com/..." } ], "next_cursor": null, "has_more": false, "search_context": { "search_session_id": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", "serp_session_id": "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", "browse_session_id": null } } ``` `name` has no `#`, and that is the form [`/hashtags/{name}`](https://herascrape.com/docs/api/hashtags) expects. ## Audio [#audio] Tracks and original sounds. Take the audio id and fetch [`/audio/{id}/medias`](https://herascrape.com/docs/api/audio) for reels using it. ### What comes back [#what-comes-back-2] ```json { "items": [ { "track": { "id": "18234567890123456", "title": "Song Title", "display_artist": "Artist", "duration_in_ms": 29000, "cover_artwork_uri": "https://..." }, "original_sound": null, "metadata": {} } ], "next_cursor": "eyJwYWdlX3Rva2VuIjoiLi4uIn0", "has_more": true, "search_context": { "search_session_id": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", "serp_session_id": "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", "browse_session_id": "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC" } } ``` Each item is either a licensed `track` **or** an `original_sound` (a creator's own audio) — the other is `null`. Check which is present before reading it. ## Places [#places] Venue search, optionally biased toward a point with `lat` and `lng`. Instagram retired its standalone location-search screen, which is why some providers no longer offer this. The capability survives in the composer's place picker, and that is what this calls. Results are venues — take the location id and fetch [`/locations/{id}`](https://herascrape.com/docs/api/locations) for its media. Single page: `has_more` is always `false`. ### What comes back [#what-comes-back-3] Venue objects from Instagram's place index, each wrapping a `location`: ```json { "items": [ { "location": { "pk": "213385402", "name": "New York, New York", "address": "", "city": "New York, New York", "lat": 40.71427, "lng": -74.00597, "external_source": "facebook_places", "facebook_places_id": 106224666074625 } } ], "next_cursor": null, "has_more": false } ``` Take `location.pk` and use it with [`/locations/{id}`](https://herascrape.com/docs/api/locations). ## Top [#top] Instagram's blended search — accounts, hashtags, places and media in one ranked list. ### What comes back [#what-comes-back-4] ```json { "edges": [ { "node": { "__typename": "XDTUserDict", "pk": "25025320", "username": "instagram" } }, { "node": { "__typename": "XDTHashtag", "name": "travel", "media_count": 683000000 } } ], "next_cursor": "eyJhZnRlciI6Ii4uLiJ9", "search_context": { "search_session_id": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", "serp_session_id": "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", "browse_session_id": null } } ``` This is the one endpoint that does not use the `{items, next_cursor, has_more}` envelope: results are mixed types, so branch on `node.__typename`. ## Errors [#errors] | Code | When | | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | [`unprocessable_entity`](https://herascrape.com/docs/getting-started/errors#unprocessable_entity) | `query` or a required search UUID is missing/malformed. | | [`invalid_parameter`](https://herascrape.com/docs/getting-started/errors#invalid_parameter) | `lat`/`lng` out of range. | | [`invalid_cursor`](https://herascrape.com/docs/getting-started/errors#invalid_cursor) | The cursor is malformed, legacy, or does not match the endpoint, exact query, and journey IDs. | | [`too_many_requests`](https://herascrape.com/docs/getting-started/errors#too_many_requests) | Over 10 requests/second on this key. | | [`upstream_error`](https://herascrape.com/docs/getting-started/errors#upstream_error) | Transient. Retry. | # Stories and highlights URL: https://herascrape.com/docs/api/stories-highlights Both endpoints return the media items of one reel, as a single page — `has_more` is always `false` and `next_cursor` is `null`. ## A user's active stories [#a-users-active-stories] `GET /stories/{reel_id}` takes the **story owner's user id**, not an individual story's id. It is an alias for [`GET /users/{id}/stories`](https://herascrape.com/docs/api/users#stories-and-highlights) served through a different Instagram endpoint. There is no fetch-one-story-by-its-own-id endpoint. If you are migrating from a provider that had one, fetch the owner's reel and match on the item's `pk`. ### What comes back [#what-comes-back] The standard list envelope, with story media items — see [the highlight example](#what-comes-back-1) below, which is the same shape. ## A highlight's media [#a-highlights-media] Highlight ids come from [`GET /users/{id}/highlights`](https://herascrape.com/docs/api/users#stories-and-highlights). The bare id and the `highlight:` prefixed form both work. ### What comes back [#what-comes-back-1] Both endpoints return the standard list envelope with media objects in the same shape as [a post](https://herascrape.com/docs/api/posts#what-comes-back), plus the story-specific fields Instagram adds — `expiring_at`, and `story_*` sticker arrays where the item carries a poll, question, link or mention. ```json { "items": [ { "pk": "3012345678901234567", "media_type": 2, "taken_at": 1699999999, "expiring_at": 1700086399, "image_versions2": { "candidates": [{ "url": "..." }] }, "video_versions": [{ "width": 720, "height": 1280, "url": "..." }], "user": { "pk": "25025320", "username": "instagram" } } ], "next_cursor": null, "has_more": false } ``` ## Downloading the media [#downloading-the-media] There is no download endpoint, and none is needed: every item carries its file URLs in `image_versions2` and `video_versions`. Fetch the highest-quality entry directly. Those URLs are signed and expire within hours, so download promptly rather than storing the URL. ## Starting from a share link [#starting-from-a-share-link] A `/s/` link resolves through [`GET /oembed?url=`](https://herascrape.com/docs/api/utility#oembed-and-share-links), which returns the `reel_id` to pass here. ## Errors [#errors] | Code | When | | ------------------------------------------------------------ | ------------------------------------- | | [`not_found`](https://herascrape.com/docs/getting-started/errors#not_found) | The reel expired, or the id is wrong. | | [`private_account`](https://herascrape.com/docs/getting-started/errors#private_account) | The owner is private. | | [`upstream_error`](https://herascrape.com/docs/getting-started/errors#upstream_error) | Transient. Retry. | # Users URL: https://herascrape.com/docs/api/users Users are addressed by **numeric id** on the base path. The one exception is the username lookup, which resolves a handle and returns the profile — take `pk` from it and use that everywhere else. ```bash # Once, to resolve the handle: GET /users/username/instagram # -> { "pk": "25025320", ... } # From then on: GET /users/25025320/followers GET /users/25025320/stories ``` There are no username-addressed sub-resources. Resolving costs double (two Instagram requests), so caching the id is worth doing — see [Pricing](https://herascrape.com/docs/getting-started/pricing). Instagram caps pagination depth on followers and following, most visibly for large and business accounts: `has_more` goes false well before the list is exhausted. This is the platform's limit and no provider can page past it. ## Profile by username [#profile-by-username] Resolves the handle via typeahead, then fetches the full profile. Returns [`404 profile_not_found`](https://herascrape.com/docs/getting-started/errors#profile_not_found) when the typeahead hit does not match the requested handle exactly, so a near-miss is a clean miss rather than the wrong account. ### What comes back [#what-comes-back] The same profile object as [by id](#what-comes-back-1) below — this endpoint only differs in how it finds the user. ## Profile by id [#profile-by-id] ### What comes back [#what-comes-back-1] A profile is Instagram's full user object — roughly 250 top-level fields. Abridged, with the ones most callers use: ```json { "pk": "25025320", "username": "instagram", "full_name": "Instagram", "biography": "Discover what's new on Instagram 🔎✨", "biography_with_entities": { "raw_text": "Discover what's new on Instagram 🔎✨", "entities": [] }, "external_url": "https://about.instagram.com/", "bio_links": [{ "url": "https://about.instagram.com/", "title": "" }], "follower_count": 692000000, "following_count": 176, "media_count": 7712, "is_private": false, "is_verified": true, "is_business": true, "category": "Internet company", "public_email": "", "profile_pic_url": "https://scontent.cdninstagram.com/...", "hd_profile_pic_versions": [{ "width": 1080, "height": 1080, "url": "..." }], "has_highlight_reels": true, "pronouns": [] } ``` | Field | Note | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `pk` | The numeric id. This is what every other endpoint wants. | | `biography` | The bio as plain text. `biography_with_entities.raw_text` carries the same string, with any @mentions and #hashtags itemised alongside. | | `bio_links` | The link-in-bio entries, richer than `external_url`. | | `is_private` | If true, sub-resources return [`403`](https://herascrape.com/docs/getting-started/errors#private_account). | | `hd_profile_pic_versions` | Sized variants; pick the largest. URLs are signed and expire. | The rest are Instagram's own flags — shopping eligibility, Threads state, moderation settings. They pass through untouched, so anything Instagram adds appears without us shipping a release. ## Followers and following [#followers-and-following] Paginated on the standard [cursor](https://herascrape.com/docs/getting-started/pagination). Items are Instagram's compact user objects, not full profiles — fetch `/users/{id}` for the rest. ### What comes back [#what-comes-back-2] ```json { "items": [ { "pk": "51065942432", "username": "example", "full_name": "Example Account", "is_private": false, "is_verified": false, "profile_pic_url": "https://scontent.cdninstagram.com/...", "latest_reel_media": 0, "account_badges": [] } ], "next_cursor": "eyJtYXhfaWQiOiIxMDAifQ", "has_more": true } ``` Compact user objects — about 15 fields, not the \~250 of a full profile. There is no follower/following count here; fetch `/users/{pk}` for that. ## Posts [#posts] The profile grid. Instagram serves this as a streamed GraphQL response whose shape differs from the other list endpoints, and we pass it through as-is: ```json { "feed": { "more_available": true, "next_max_id": "...", "profile_grid_items": [{ "media": {} }], "pinned_profile_grid_items_ids": [""] }, "posts": { "": {} } } ``` `feed.profile_grid_items` is the ordered list — that array is the ordering key, and pins are called out in `feed.pinned_profile_grid_items_ids`. `posts` is a lookup keyed by media id. **Page with `cursor`, taking the value from `feed.profile_grid_items_cursor`.** Keep going while `feed.more_available` is true: ``` GET /users/13460080/posts GET /users/13460080/posts?cursor= GET /users/13460080/posts?cursor= ``` `feed.next_max_id` is present in the response but **is not a paging token for this endpoint** — Instagram's profile-timeline query keys on the cursor and ignores `max_id`, so feeding `next_max_id` back returns the first page again. Passing `max_id` here returns `400 invalid_parameter` rather than looping. ### What comes back [#what-comes-back-3] The `{feed, posts}` shape above — and it is **not** the same media object that [`/posts/{id}`](https://herascrape.com/docs/api/posts#what-comes-back) returns. This endpoint is the only one backed by Instagram's GraphQL profile timeline rather than their REST API, and we pass that through rather than reshaping it, so the difference is real and worth planning for. The media arrives split across the two halves, and neither is complete alone: | Where | Has | Does not have | | --------------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | `posts[""]` | `code`, `caption`, `like_count`, `comment_count`, `play_count`, **complete `carousel_media` children** | `image_versions2`, `video_versions`, and only a stub `user` | | `feed.profile_grid_items[].media` | `image_versions2`, **`video_versions` for reels**, the full `user` (`full_name`, `profile_pic_url`) | `code`, and carousel children may be truncated | Join the two on the media id to get one usable post. Which half holds the media depends on the post type, so take each from the side that actually carries it: | Post type | Take the media from | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Single image (`product_type: "feed"`) | either half's `image_versions2` | | Reel / video (`product_type: "clips"`) | **`feed.profile_grid_items[].media.video_versions`** — the `posts` half has no video renditions at all | | Carousel (`product_type: "carousel_container"`) | **`posts[""].carousel_media`** — the grid half sometimes drops the renditions on children after the first, and can report a video child as an image | Measured on one profile across four pages: every one of the 23 reels carried `video_versions` in the grid half and none in the `posts` half, while all 85 carousel children were complete in the `posts` half against 81 in the grid. Nothing is missing from the response — but reading only one half loses video on reels, or the trailing items of a carousel. Some fields also arrive with a short type prefix — `1ltaken_at`, `1fvideo_duration`, `1flat`, `1flng`, `1ldevice_timestamp`, `1lbrs_severity`. That is Instagram's own disambiguation in this GraphQL response, not a transport artifact, and it is what the mobile client receives. We leave the keys exactly as they arrive, because renaming them here would mean guessing on your behalf and re-guessing every time Instagram changes something. If you want the flat REST media shape for a user's videos, [`/users/{user_id}/reels`](#reels-and-tagged) is served by Instagram's REST clips endpoint and returns `taken_at`, `image_versions2`, `video_versions` and `video_duration` unprefixed. ## Reels and tagged [#reels-and-tagged] Reels is the clips tab; tagged is media the user was tagged in. Both use the standard cursor envelope. ### What comes back [#what-comes-back-4] The standard list envelope, with media objects in the same shape as [a post](https://herascrape.com/docs/api/posts#what-comes-back). Reels are all `product_type: "clips"`; tagged mixes photos, carousels and reels. Pass `sort_by_views=true` on the reels endpoint to order a profile's reels by view count instead of newest-first — the app's "most viewed" sort. It pages with the same cursor. ## Reposts [#reposts] Content the user has reposted. Standard cursor envelope. ### What comes back [#what-comes-back-5] The standard list envelope, with media objects in the same shape as [a post](https://herascrape.com/docs/api/posts#what-comes-back) — the reposted originals, each carrying its own author in `user`. ## Stories and highlights [#stories-and-highlights] Both return a single page — `has_more` is always `false` and `next_cursor` is `null`. Stories returns the currently-active reel, which is empty for a user with nothing posted in the last 24 hours. Highlights returns the tray; fetch one highlight's media through [`GET /highlights/{id}`](https://herascrape.com/docs/api/stories-highlights). Both are billed at double, because Instagram needs a second hop to serve them. ### What comes back [#what-comes-back-6] Stories returns media items (same shape as [a post](https://herascrape.com/docs/api/posts)). Highlights returns tray entries: ```json { "items": [ { "id": "highlight:17913155365000000", "title": "Behind the scenes", "media_count": 12, "created_at": 1699999999, "is_pinned_highlight": false, "cover_media": { "cropped_image_version": { "url": "..." } }, "user": { "pk": "25025320", "username": "instagram" } } ], "next_cursor": null, "has_more": false } ``` Pass `id` straight to [`GET /highlights/{id}`](https://herascrape.com/docs/api/stories-highlights) — the `highlight:` prefix is accepted, and so is the bare number. ## About this account [#about-this-account] Instagram's "About this account" panel — join date, country, former usernames where shown. Best-effort. Instagram ships this screen as a rendered UI tree rather than JSON, so we extract the visible text: `details` holds every line, and `date_joined` is pulled from the labelled one. Treat `details` as the reliable field and the rest as convenience. ### What comes back [#what-comes-back-7] Instagram does not serve this screen as JSON, so the response is assembled from the rendered panel: ```json { "date_joined": "March 2010", "former_usernames": [], "former_usernames_count": 0, "details": ["Date joined", "March 2010", "Account based in", "United States"] } ``` ## Errors [#errors] | Code | When | | ---------------------------------------------------------------- | ----------------------------------------------- | | [`profile_not_found`](https://herascrape.com/docs/getting-started/errors#profile_not_found) | No such user, or deleted or banned. | | [`private_account`](https://herascrape.com/docs/getting-started/errors#private_account) | The profile exists; its content is not visible. | | [`invalid_cursor`](https://herascrape.com/docs/getting-started/errors#invalid_cursor) | The `cursor` was not one we issued. | | [`too_many_requests`](https://herascrape.com/docs/getting-started/errors#too_many_requests) | Over 10 requests/second on this key. | | [`upstream_error`](https://herascrape.com/docs/getting-started/errors#upstream_error) | Transient. Retry. | # Utility URL: https://herascrape.com/docs/api/utility Four endpoints that support the rest. **All four are free** — none of them costs a billed Instagram request. ## pk ⇄ shortcode [#pk--shortcode] Converts a media pk to its shortcode and back. Pass exactly one of `pk` or `code`. ```bash GET /resolve/media?code=B4XpqViUFUI ``` ```json { "pk": "2168384974851429640", "code": "B4XpqViUFUI" } ``` Free and instant: the shortcode is base64 of the pk over Instagram's alphabet, so this is arithmetic done locally with no network call. You rarely need it — [`/posts/{id}`](https://herascrape.com/docs/api/posts) accepts either form directly — but it is useful for normalising ids before storing them. ### What comes back [#what-comes-back] The `{ pk, code }` pair shown above — the only response on the API we define ourselves, because it is computed rather than proxied. ## oEmbed and share links [#oembed-and-share-links] Two jobs in one endpoint, because Instagram serves both from the same place. For a post or reel URL, you get the oEmbed block — title, author, thumbnail, embed HTML. For a `/s/` **share link**, you get what it points at: `reel_id`, `author_id` and `can_view`. That is how you turn a shared story or highlight link into something you can fetch: ```bash GET /oembed?url=https://instagram.com/s/aGlnaGxpZ2h0OjE4MDI... # -> { "reel_id": "highlight:18021...", "author_id": "25025320", ... } GET /highlights/18021... ``` ### What comes back [#what-comes-back-1] ```json { "author_id": "25025320", "author_name": "instagram", "reel_id": "highlight:18021...", "media_id": null, "can_view": true, "title": "A caption", "html": "
…
", "thumbnail_url": "https://scontent.cdninstagram.com/..." } ``` A post URL populates `title` / `html` / `thumbnail_url`; a `/s/` share link populates `reel_id` / `author_id` / `can_view`. The unused half is `null`. ## Balance [#balance] Your account's remaining USD balance, read live from the ledger, so a top-up shows up immediately. ### What comes back [#what-comes-back-2] ```json { "balance_usd": "12.4832", "currency": "USD" } ``` A string, not a number — money must not round-trip through binary floating point. ## Health [#health] Liveness. The only endpoint that needs no API key, so it is safe to use as an uptime probe. ### What comes back [#what-comes-back-3] ```json { "status": "ok" } ``` ## Errors [#errors] | Code | When | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------- | | [`invalid_parameter`](https://herascrape.com/docs/getting-started/errors#invalid_parameter) | Neither `pk` nor `code` given, both given, or the value will not convert. | | [`unprocessable_entity`](https://herascrape.com/docs/getting-started/errors#unprocessable_entity) | `url` missing. | | [`not_found`](https://herascrape.com/docs/getting-started/errors#not_found) | The URL points at something deleted. | # Authentication URL: https://herascrape.com/docs/getting-started/authentication Every request carries your key in the `X-Api-Key` header: ``` X-Api-Key: hs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` There are no scopes or permissions. A key either authenticates or it does not, and every key reaches every endpoint. ## Managing keys [#managing-keys] Keys live in the [dashboard](https://herascrape.com/api-keys). Create as many as you have places to run code — one per environment or service makes revoking a leaked key a small event rather than an outage. **The plaintext is shown once**, at creation. We store a SHA-256 hash and an eight-character prefix, so after that we can tell you *which* key a row is but not what it says. If you lose one, revoke it and create another. Revoking is immediate in intent and takes effect within about thirty seconds in practice: the API caches the active key set in memory and refreshes it on a timer rather than querying the database on every request. ## What comes back on every response [#what-comes-back-on-every-response] | Header | Meaning | | -------------- | ------------------------------------------------------------------------------------------ | | `x-request-id` | Unique per request. Quote it in support threads — it is how we find your call in the logs. | ## Rate limits [#rate-limits] 10 requests per second per key. Exceeding it returns [`429`](https://herascrape.com/docs/getting-started/errors#too_many_requests) with a `Retry-After` hint in the message, and costs nothing. The limit is per key, not per account, so splitting work across several keys raises your ceiling. If you need a higher ceiling on one key, ask. ## Keeping a key safe [#keeping-a-key-safe] * Keys are bearer credentials. Anyone holding one can spend your balance. * Never ship one in browser JavaScript, a mobile app, or a public repository. Call the API from your own backend and keep the key there. * Rotate by creating the new key, deploying it, then revoking the old one — in that order, so there is no window without a working key. ## The unauthenticated endpoint [#the-unauthenticated-endpoint] [`GET /system/health`](https://herascrape.com/docs/api/utility) needs no key. It is a liveness check and returns `{"status": "ok"}`. Everything else requires one. # Errors URL: https://herascrape.com/docs/getting-started/errors Failures return a consistent JSON envelope. Branch on `error_code`, not on `detail` — the wording can change, the code will not. ```json { "status": "client_error", "error_code": "profile_not_found", "detail": "Profile not found or unavailable" } ``` `status` is derived from the HTTP status class: `client_error` for 4xx, `server_error` for 5xx. Only errors carry this envelope. A successful call returns the object, or the list envelope, directly — so branch on the HTTP status code first. ## Codes [#codes] ### 400 · invalid\_cursor [#invalid_cursor] The `cursor` you sent is not a cursor we issued. Cursors are opaque and must be passed back byte-for-byte from a previous `next_cursor`. Not retryable — restart the list from the first page. ### 400 · invalid\_parameter [#invalid_parameter] A parameter is present but unusable: a malformed media id, a `pk`/`code` pair where exactly one was required, an unknown value. The `detail` names the problem. Not retryable. ### 401 · unauthorized [#unauthorized] Missing or invalid `X-Api-Key`. Also returned briefly if key verification is offline — the API fails closed rather than serving unauthenticated requests. Check the key, then retry once. ### 403 · private\_account [#private_account] The account exists but its content is private. Nothing to retry: this is Instagram's answer, and no amount of retrying changes it. ### 404 · profile\_not\_found [#profile_not_found] No such username or user id — deleted, banned, renamed, or never existed. Not retryable. ### 404 · not\_found [#not_found] The requested object is gone: a deleted post, an expired story, a hashtag or location with no such id. Not retryable. ### 422 · unprocessable\_entity [#unprocessable_entity] Request validation failed before the call ran — a required query parameter is missing, or a value has the wrong type. `detail` lists the offending fields. Not retryable. ### 429 · too\_many\_requests [#too_many_requests] You exceeded your per-key rate limit of 10 requests per second. `detail` carries the retry delay. Back off and retry. ### 502 · upstream\_error [#upstream_error] The call reached Instagram and could not be completed. Retry — it usually succeeds on the next attempt. ### 503 · upstream\_unavailable [#upstream_unavailable] No capacity right now, or a transient failure that needs backing off. Retryable after a short wait. ## Retry policy [#retry-policy] | Code | Retry? | How | | ---------------------- | ------ | ----------------------------- | | `upstream_error` | Yes | Immediately, up to \~3 times. | | `upstream_unavailable` | Yes | Back off — 1s, 2s, 4s. | | `too_many_requests` | Yes | Wait the delay in `detail`. | | `unauthorized` | Once | After checking the key. | | everything else | No | Fix the request. | ```python import time import httpx RETRYABLE = {502, 503} def call(http: httpx.Client, path: str, **params) -> dict: for attempt in range(3): response = http.get(path, params=params) if response.status_code not in RETRYABLE: response.raise_for_status() return response.json() time.sleep(2**attempt) response.raise_for_status() return response.json() ``` ## Failed calls are free [#failed-calls-are-free] Only `2xx` responses are billed, so a retry loop cannot drain your balance. See [Pricing](https://herascrape.com/docs/getting-started/pricing). ## Reporting a problem [#reporting-a-problem] Every response carries `x-request-id`. Include it — with the endpoint and roughly when it happened — and we can find the exact call in the logs. # Pagination URL: https://herascrape.com/docs/getting-started/pagination Instagram paginates almost every list differently: an integer offset here, a base64 blob there, a compound key-plus-page somewhere else. herascrape hides most of it behind a single `cursor`. ## The envelope [#the-envelope] Most list endpoints return the same three fields: ```json { "items": [], "next_cursor": "eyJtYXhfaWQiOiIyNSJ9", "has_more": true } ``` | Field | Meaning | | ------------- | ------------------------------------------------------------------- | | `items` | This page's objects, exactly as Instagram returned them. | | `next_cursor` | Pass back as `?cursor=` for the next page. `null` on the last page. | | `has_more` | `false` means you have reached the end. Stop. | ## The loop [#the-loop] ```python cursor = None while True: params = {"cursor": cursor} if cursor else {} page = http.get(f"/users/{user_id}/followers", params=params).json() handle(page["items"]) if not page["has_more"]: break cursor = page["next_cursor"] ``` Branch on `has_more`, not on whether `items` is empty. Instagram occasionally returns a short or empty page mid-sequence and still has more to give. ## The cursor is opaque [#the-cursor-is-opaque] Pass it back verbatim. Never parse, build or edit one, and do not reuse a cursor from one endpoint on another — both return [`400 invalid_cursor`](https://herascrape.com/docs/getting-started/errors#invalid_cursor). Search cursors have one extra continuity rule. For `/search/users`, `/search/hashtags`, `/search/audio`, and `/search/top`, repeat the exact `query`, `search_session_id`, and `serp_session_id` from page one; audio also repeats `browse_session_id`. The response's `search_context` echoes the normalized values. A changed query or ID, an endpoint swap, or a cursor issued before this contract returns `400 invalid_cursor`; start a new search without a cursor instead. ## One page per call [#one-page-per-call] One request fetches one Instagram page. There is no `limit` parameter and no server-side aggregation, so N pages is always N calls. Page size is Instagram's choice, typically 12 to 50 items. Instagram caps how deep some lists can be paged, most visibly on followers and following for large or business accounts: `has_more` goes false while the account clearly has more. That ceiling is Instagram's. ## The endpoints that don't use the envelope [#the-endpoints-that-dont-use-the-envelope] A few endpoints hand back Instagram's own response rather than the envelope, because reshaping them would mean discarding structure the payload carries. They paginate on a token named in their own response, and their reference page says which one. Two rules cover it. Take the paging token from the endpoint's own *What comes back* rather than assuming `next_cursor`, and never feed back a field just because its name looks like a cursor — a token the endpoint does not page on is ignored rather than rejected, so the same page returns forever while `more_available` stays `true`. Where that trap exists we now answer with a `400` naming the right parameter instead of looping. ## Lists that never paginate [#lists-that-never-paginate] Some endpoints return everything at once and always report `has_more: false` with a `null` cursor — active stories, a highlights tray, a single reel's media, comment likers, and place search. The same loop works; it exits after one pass. # Pricing URL: https://herascrape.com/docs/getting-started/pricing Dollars per call, deducted from a dollar balance. No credits, no tokens, no internal currency to convert into, and no subscription. Your monthly bill is calls times price. ## The rate card [#the-rate-card] | Call | Price | | -------------------------------------------------------------------------------------------------- | ----------- | | Every single-page read — profile, post, followers page, comments, search, hashtag, location, audio | **$0.0004** | | `GET /users/{id}/stories`, `GET /users/{id}/highlights` | **$0.0008** | | `GET /users/username/{username}` | **$0.0008** | | `GET /resolve/media`, `GET /oembed`, `GET /system/balance`, `GET /system/health` | **Free** | That is **$0.40 per 1,000 requests**, the same price at zero balance as at ten thousand dollars. There are no tiers to unlock and balance never expires. ### Why three endpoints cost double [#why-three-endpoints-cost-double] Because they cost us two Instagram requests instead of one. Stories and highlights need a second hop; a username lookup resolves the handle to an id before fetching the profile. Both are avoidable. Hold the numeric user id and [`GET /users/{id}`](https://herascrape.com/docs/api/users) is single-price — resolve a username once, store the `pk`, and never pay the resolve again. See [Resolving ids](https://herascrape.com/docs/guides/resolving-ids). ### Why four are free [#why-four-are-free] They never touch Instagram. ## Only successful calls are billed [#only-successful-calls-are-billed] A `2xx` is billed. **`400`, `403`, `404`, `422`, `429`, `502` and `503` cost nothing**, so a retry loop cannot drain your balance. ## One page per call [#one-page-per-call] Cost is arithmetic you can do in advance: paging 10,000 followers at roughly 50 per page is about 200 calls, or $0.08. ## Top-up bonuses [#top-up-bonuses] | Deposit | Bonus balance | Effective rate | | ------- | ------------- | -------------- | | $100 | +5% | $0.000381 | | $200 | +10% | $0.000364 | | $500 | +15% | $0.000348 | | $1,000 | +20% | $0.000333 | Bonus balance is ordinary balance: same price, no expiry. Running higher volume than the table covers? Get in touch — there is room below $0.0004 for committed volume. ## Free balance to start [#free-balance-to-start] New accounts get free balance once Telegram is connected, which is enough to build a real integration rather than curl one endpoint. No card required. ## Checking your balance [#checking-your-balance] In the [dashboard](https://herascrape.com/payments), or from the API: ```bash curl https://api.herascrape.com/system/balance \ -H "X-Api-Key: $HERASCRAPE_KEY" ``` ```json { "balance_usd": "12.4832", "currency": "USD" } ``` The call is free and reads the ledger live, so a top-up shows up immediately. ## Paying [#paying] Crypto, as a prepaid dollar balance. USDT and USDC are credited 1:1. Other coins (BTC, ETH, SOL, TRX, BNB, LTC, TON, POL) are credited at the dollar rate quoted when you generate the address, so the balance does not move with the coin afterwards. Each address is valid for one hour. Minimum deposit is $5. USDT on Tron has a higher minimum, shown at checkout. # Quickstart URL: https://herascrape.com/docs/getting-started/quickstart ## 1. Get a key [#1-get-a-key] Create one in the [dashboard](https://herascrape.com/api-keys). The plaintext is shown once at creation and never again — we store a hash, so a lost key means a new key. New accounts start with free balance, so you can run through this page before paying anything. ## 2. Make a call [#2-make-a-call] ```bash curl https://api.herascrape.com/users/username/instagram \ -H "X-Api-Key: $HERASCRAPE_KEY" ``` You get Instagram's profile object back, in full. Take `pk` from it — that numeric id addresses every sub-resource: ```bash curl https://api.herascrape.com/users/25025320/followers \ -H "X-Api-Key: $HERASCRAPE_KEY" ``` Looking up by username costs two Instagram requests (a resolve, then the profile), so it is billed at double. Cache the numeric id and the second call onward is single-price. See [Pricing](https://herascrape.com/docs/getting-started/pricing). ## 3. Page through a list [#3-page-through-a-list] One call returns one Instagram page. Loop on `next_cursor` until `has_more` goes false: ```python import httpx BASE = "https://api.herascrape.com" headers = {"X-Api-Key": KEY} def paginate(path: str, **params): cursor = None with httpx.Client(base_url=BASE, headers=headers, timeout=60) as http: while True: if cursor: params["cursor"] = cursor page = http.get(path, params=params).raise_for_status().json() yield from page["items"] if not page["has_more"]: return cursor = page["next_cursor"] for follower in paginate("/users/25025320/followers"): print(follower["username"]) ``` ```javascript const BASE = 'https://api.herascrape.com' async function* paginate(path, params = {}) { let cursor = null for (;;) { const query = new URLSearchParams({ ...params, ...(cursor ? { cursor } : {}) }) const response = await fetch(`${BASE}${path}?${query}`, { headers: { 'X-Api-Key': process.env.HERASCRAPE_KEY }, }) if (!response.ok) throw new Error(`${response.status} ${await response.text()}`) const page = await response.json() yield* page.items if (!page.has_more) return cursor = page.next_cursor } } ``` Treat `cursor` as opaque. It encodes whichever token the underlying Instagram endpoint uses, and that differs per endpoint — parsing or constructing one will break. ## 4. Handle the errors that matter [#4-handle-the-errors-that-matter] Three you should branch on, and one you should retry: ```python response = http.get(f"/users/{user_id}") if response.status_code == 404: ... # gone, deleted, or never existed elif response.status_code == 403: ... # private account elif response.status_code == 429: ... # your rate limit; back off elif response.status_code in (502, 503): ... # transient on our side; retry ``` Non-2xx responses are never billed. Full list in [Errors](https://herascrape.com/docs/getting-started/errors). ## Where to go next [#where-to-go-next] * [Authentication](https://herascrape.com/docs/getting-started/authentication) — key handling and headers. * [Resolving ids](https://herascrape.com/docs/guides/resolving-ids) — usernames, shortcodes, share links. * [Migrating from HikerAPI](https://herascrape.com/docs/guides/hikerapi-migration) — if you are coming from there, start at the translation table. # Migrating from HikerAPI URL: https://herascrape.com/docs/guides/hikerapi-migration Same data, fewer endpoints, lower price. HikerAPI's 154 paths collapse to 37 here, because they expose their internal transport — the same capability appears under `/v1`, `/v2`, `/v3`, `/gql`, `/g1`, `/g2` and `/a2` — and we pick the source internally. **Fastest path:** skip to the [agent prompt](#agent-prompt) at the bottom. Paste it into Claude Code, Cursor or your agent of choice and it rewrites your client — the prompt points the agent at [`llms-full.txt`](https://herascrape.com/docs/llms-full.txt), so it pulls these docs itself. Everything above is that reference, for reading by hand. ## Three mechanical changes [#three-mechanical-changes] | | HikerAPI | herascrape | | ----------- | -------------------------- | ---------------------------- | | Base URL | `https://api.hikerapi.com` | `https://api.herascrape.com` | | Auth header | `x-access-key` | `x-api-key` | | Paths | 154 versioned | 37 RESTful | Then two rules: [one cursor for paging](#pagination), and [drop the noise parameters](#parameters-to-delete). ## Pagination [#pagination] HikerAPI uses a different paging parameter on almost every endpoint. We use one opaque `cursor`. | HikerAPI | herascrape | | ------------------------------------------------- | ---------- | | `page_id`, `next_page_id` | `cursor` | | `max_id`, `next_max_id`, `min_id`, `reels_max_id` | `cursor` | | `end_cursor` | `cursor` | | `page_token` | `cursor` | | `profile_grid_items_cursor`, `repost_next_max_id` | `cursor` | Read the next token from `next_cursor` and loop while `has_more` is true. Full details in [Pagination](https://herascrape.com/docs/getting-started/pagination). Two endpoints keep Instagram's native paging instead, because the token is structural to their response: [`/posts/{id}/comments`](https://herascrape.com/docs/api/posts#comments) pages on `min_id`, and [`/users/{id}/posts`](https://herascrape.com/docs/api/users#posts) pages on `max_id`. ## Parameters to delete [#parameters-to-delete] | HikerAPI | Why it is gone | | ------------------------------------------------------------------ | ----------------------------------------------- | | `safe_int` | Our ids are always strings. Always safe. | | `force` | Privacy handling is automatic. | | `amount` | Deprecated on HikerAPI too. Page with `cursor`. | | `flat`, `rank_token`, `expand_suggestion`, `can_support_threading` | Internal defaults. | ## Responses [#responses] **Read this before you touch your parsing code.** Like HikerAPI, we return Instagram's JSON as Instagram sends it. We do not rename fields, reshape objects, or trim responses. If your code already reads HikerAPI's `v1`/`v2` payloads, the fields are in the same places. The differences that will actually bite: * **List responses are wrapped.** HikerAPI returns a bare array on many endpoints; ours return `{items, next_cursor, has_more}`. Read `items`. * **HikerAPI's `v1` and `v2` variants of the same call return different shapes.** We have one endpoint per capability, so whichever variant you were on, you land on one shape. If you were using a `v1` route, check the fields — we generally serve the richer private-API source. * **No `short`/`full` modes.** You always get the full object. ## Addressing [#addressing] **Users** are addressed by numeric id. The only username path is `GET /users/username/{username}`, which returns the profile including `pk`. Where your code called a by-username sub-resource — stories, highlights, followers — resolve once, cache the id, then use the id endpoints. Resolving costs double, so caching is worth real money at volume. **Posts** take either the pk or the shortcode on `/posts/{id}`, so by-id, by-code and by-url calls all collapse into one. Pull the code out of an `instagram.com/p//` URL and pass it. ## Endpoint translation [#endpoint-translation] ### Users [#users] | HikerAPI | herascrape | | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | `GET /v2/user/by/username`, `/v1/user/by/username`, `/a2/user`, `/v2/userstream/by/username` | `GET /users/username/{username}` | | `GET /v2/user/by/id`, `/v1/user/by/id`, `/gql/user/by/id`, `/gql/user/web_profile_info`, `/v2/userstream/by/id` | `GET /users/{id}` | | `GET /v1/user/by/url` | Extract the username, then `GET /users/username/{username}` | | `GET /gql/user/about`, `/v1/user/about` | `GET /users/{id}/about` | | `GET /g2/user/followers`, `/g1/user/followers`, `/gql/user/followers/chunk`, `/v2/user/followers`, `/v1/user/followers/chunk` | `GET /users/{id}/followers` | | `GET /g2/user/following`, `/g1/user/following`, `/gql/user/following/chunk`, `/v2/user/following`, `/v1/user/following/chunk` | `GET /users/{id}/following` | | `GET /gql/user/medias`, `/g2/user/medias`, `/v1/user/medias/chunk`, `/v1/user/medias/pinned` | `GET /users/{id}/posts` — pinned posts included | | `GET /v2/user/clips`, `/gql/user/clips`, `/v1/user/clips/chunk`, `/v1/user/videos` | `GET /users/{id}/reels` | | `GET /v2/user/tag/medias`, `/v1/user/tag/medias/chunk` | `GET /users/{id}/tagged` | | `GET /gql/user/reposts` | `GET /users/{id}/reposts` | | `GET /v2/user/stories`, `/v1/user/stories`, and their `/by/username` variants | `GET /users/{id}/stories` | | `GET /v2/user/highlights`, `/v1/user/highlights`, and their `/by/username` variants | `GET /users/{id}/highlights` | ### Posts and media [#posts-and-media] | HikerAPI | herascrape | | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | `GET /v2/media/info/by/id`, `/v1/media/by/id`, `/v2/media/by/id`, `/v1/media/user` | `GET /posts/{id}` | | `GET /v2/media/info/by/code`, `/v1/media/by/code`, `/v2/media/by/code` | `GET /posts/{id}` — pass the shortcode | | `GET /v2/media/info/by/url`, `/v1/media/by/url`, `/v2/media/by/url` | `GET /posts/{id}` — pass the shortcode from the URL | | `GET /v2/media/comments`, `/v1/media/comments/chunk`, `/gql/comments`, `/gql/comments/chunk` | `GET /posts/{id}/comments?user_id=` — the post owner's id | | `GET /v2/media/likers`, `/v3/media/likers`, `/v1/media/likers`, `/gql/media/likers` | `GET /posts/{id}/likers?user_id=` — the post owner's id | | `GET /gql/media/usertags` | `GET /posts/{id}` — `usertags` is on the post | | `GET /gql/media/clips_metadata` | `GET /posts/{id}` — `clips_metadata` is on the post | | `GET /v2/media/comments/infos` | `GET /posts/{id}` — the counts are on the post | | `GET /v1/media/download/photo`, `/v1/media/download/video`, `…/by/url` | `GET /posts/{id}` — file URLs are in `image_versions2` / `video_versions` | | `GET /v1/media/oembed` | `GET /oembed?url=` | | `GET /v1/media/pk/from/code`, `/v1/media/code/from/pk` | `GET /resolve/media` — **free** | | `GET /v1/share/by/url`, `/v1/share/by/code`, `/v1/media/pk/from/url` | `GET /oembed?url=` | **Batch `?media_ids=` endpoints have no equivalent.** The slices they returned — usertags, clips metadata, comment counts — already live inside a single `GET /posts/{id}`, so we did not reproduce them. If you batched to read one slice across many posts cheaply, each post is now its own request. At $0.0004 that is usually still cheaper; check the arithmetic for your volume. ### Comments [#comments] | HikerAPI | herascrape | | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | | `GET /v2/media/comments/replies`, `/gql/comments/threaded`, `/gql/comments/threaded/chunk` | `GET /comments/{id}/replies` — `media_id` and `user_id` required | | `GET /gql/comment/likers`, `/gql/comment/likers/chunk` | `GET /comments/{id}/likers` | ### Search [#search] | HikerAPI | herascrape | | -------------------------------------------------------------------------------------------------- | ---------------------- | | `GET /v3/fbsearch/accounts`, `/v2/fbsearch/accounts`, `/v1/search/users`, `/v2/search/accounts` | `GET /search/users` | | `GET /v2/search/hashtags`, `/v1/search/hashtags`, `/v1/fbsearch/topsearch/hashtags` | `GET /search/hashtags` | | `GET /v3/fbsearch/places`, `/v2/fbsearch/places`, `/v1/fbsearch/places`, `/v2/search/places` | `GET /search/places` | | `GET /v2/search/music`, `/v1/search/music` | `GET /search/audio` | | `GET /v2/fbsearch/topsearch`, `/gql/topsearch`, `/v1/fbsearch/topsearch`, `/v3/fbsearch/topsearch` | `GET /search/top` | ### Hashtags, locations, audio, stories, highlights [#hashtags-locations-audio-stories-highlights] | HikerAPI | herascrape | | ---------------------------------------------------------------- | ------------------------------------------------------------------- | | `GET /v2/hashtag/by/name`, `/v1/hashtag/by/name` | `GET /hashtags/{name}` | | `GET /v2/hashtag/medias/top`, `/v1/hashtag/medias/top/chunk` | `GET /hashtags/{name}/top` | | `GET /v2/hashtag/medias/recent`, `/v1/hashtag/medias/recent` | `GET /hashtags/{name}/recent` | | `GET /v1/hashtag/medias/clips/chunk`, `/v2/hashtag/medias/clips` | `GET /hashtags/{name}/reels` | | `GET /v1/location/by/id` | `GET /locations/{id}` | | `GET /v1/location/medias/top`, `…/top/chunk` | `GET /locations/{id}/top` | | `GET /v1/location/medias/recent`, `…/recent/chunk` | `GET /locations/{id}/recent` | | `GET /v1/location/search` | `GET /search/places?lat=&lng=` | | `GET /v2/track/by/id` | `GET /audio/{id}` | | `GET /v2/highlight/by/id`, `/v1/highlight/by/id` | `GET /highlights/{id}` | | `GET /v1/highlight/by/url` | `GET /oembed?url=` for the `reel_id`, then `GET /highlights/{id}` | | `GET /v2/story/by/url`, `/v1/story/by/url` | `GET /oembed?url=` for the `reel_id`, then `GET /stories/{reel_id}` | | `GET /v1/story/download`, `…/by/url`, `…/by/story/url` | `GET /stories/{reel_id}` — file URLs are on each item | | `GET /sys/balance` | `GET /system/balance` — **free** | ## What we do not have [#what-we-do-not-have] Honest list, so you find out here rather than in production. **Instagram removed these, so nobody has them.** Nothing to migrate to. * `GET /v1/location/search` as a standalone geo search. The venue picker at [`/search/places`](https://herascrape.com/docs/api/search#places) covers the common case. **Returns different data than you expect.** * `GET /v2/story/by/id`, `/v1/story/by/id` — we have no fetch-one-story-by-id endpoint. `GET /stories/{reel_id}` takes the **owner's user id** and returns their whole active reel. Fetch that and match on the item's `pk`. **Not implemented.** If one of these blocks your migration, tell us — several are small, and we prioritise by what migrating customers actually need. | HikerAPI | Notes | | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `GET /v2/user/suggested/profiles`, `/v2/user/explore/businesses/by/id`, `/gql/user/related/profiles` | Related-profile suggestions. | | `GET /v1/user/guides`, `/v1/location/guides` | Guides. | | `GET /v2/media/template` | Media share template. | | `GET /v1/media/insight` | Owner-only insights; needs the account to own the post. | | `GET /v2/media/comment/offensive` | Offensive-text check. | | `GET /v2/fbsearch/reels`, `/v2/search/reels`, `/v3/fbsearch/reels` | Reels search. [`/search/top`](https://herascrape.com/docs/api/search#top) and [`/hashtags/{name}/reels`](https://herascrape.com/docs/api/hashtags) cover much of it. | | `GET /v2/track/by/canonical/id`, `/v2/track/stream/by/id` | Canonical-id lookup and stream URL. | ## Before and after [#before-and-after] ```diff - GET https://api.hikerapi.com/v2/user/followers?user_id=25025320&page_id=ABC - x-access-key: + GET https://api.herascrape.com/users/25025320/followers?cursor=ABC + x-api-key: ``` ```diff - GET https://api.hikerapi.com/v2/media/info/by/code?code=B4XpqViUFUI&safe_int=true + GET https://api.herascrape.com/posts/B4XpqViUFUI ``` ```diff - GET https://api.hikerapi.com/v2/user/by/username?username=instagram - GET https://api.hikerapi.com/v2/user/stories/by/username?username=instagram + GET https://api.herascrape.com/users/username/instagram # returns pk, cache it + GET https://api.herascrape.com/users/25025320/stories ``` ## Agent prompt [#agent-prompt] Paste this into your coding agent and point it at your repository. It fetches the docs it needs, so you do not have to attach anything. ```text You are migrating this codebase from HikerAPI (api.hikerapi.com) to herascrape (api.herascrape.com). First, read these — they are the source of truth, and this prompt alone is not enough to do the migration correctly: - https://herascrape.com/docs/llms-full.txt The complete herascrape documentation as one plain-text file: every endpoint, the pagination and error contracts, and the full HikerAPI translation tables. - https://herascrape.com/docs/guides/hikerapi-migration The migration guide on its own, if you want just the tables. - https://api.herascrape.com/openapi.json The OpenAPI (Swagger) schema, emitted by the running service. Authoritative for paths and parameters; use the "What comes back" examples in the docs for response field names. Then, across the whole repo: 1. Replace the base URL https://api.hikerapi.com with https://api.herascrape.com. 2. Rename the auth header `x-access-key` to `x-api-key`. Keep the value in its existing environment variable. 3. Rewrite every request path using the "Endpoint translation" tables. Many HikerAPI paths collapse onto one of ours — pick the matching row. 4. Users are addressed by numeric id (/users/{id}). The only username path is GET /users/username/{username}, which returns the profile including `pk`. Where the code called a by-username sub-resource (stories, highlights, followers), resolve the username once, cache the id, then call the id-based endpoint. Flag any place where caching is not straightforward. 5. Posts: replace by-id, by-code and by-url calls with GET /posts/{id}, which accepts either the media pk or the shortcode. Extract the shortcode from any instagram.com/p// URL. 6. Replace all pagination parameters (page_id, next_page_id, max_id, next_max_id, min_id, end_cursor, page_token, reels_max_id, profile_grid_items_cursor, repost_next_max_id) with a single `cursor`. Read the next token from `next_cursor` and loop while `has_more` is true. Two exceptions: /posts/{id}/comments pages on `min_id` (branch on `has_more`), and /users/{id}/posts pages on `cursor` taken from `feed.profile_grid_items_cursor`. 7. Delete these parameters: safe_int, flat, force, amount, rank_token, expand_suggestion, can_support_threading. 8. List responses are wrapped in {items, next_cursor, has_more}. Where the old code read a bare array, read `items`. Object fields are otherwise unchanged — both APIs return Instagram's JSON. 9. Respect the rate limit: 10 requests/second per key. If the code fans out concurrently, cap it. 10. Do NOT invent endpoints. If a call maps to something in the "What we do not have" section of the migration guide, leave it in place and mark it with a TODO naming the limitation. Do the same for any field you cannot confidently map. Never guess a path that is not in the translation tables. Produce a summary of every call site changed, plus the list of TODOs. ``` If your agent cannot fetch URLs, download [`llms-full.txt`](https://herascrape.com/docs/llms-full.txt) and attach it — it is the whole of these docs in one file. ## Getting started [#getting-started] Free balance to trial the migration, no card required, and [cheaper per call](https://herascrape.com/docs/getting-started/pricing) across the board. Stuck on a mapping or an endpoint you do not see here? Get in touch — we will map it, or build it. # Resolving ids URL: https://herascrape.com/docs/guides/resolving-ids Four identifier types show up in Instagram URLs, and each needs a different move to become something you can fetch. This page is the whole set. ## Username → user id [#username--user-id] Everything except the lookup itself is addressed by numeric id. ```bash GET /users/username/instagram # -> { "pk": "25025320", "username": "instagram", ... } ``` Take `pk` and use it everywhere: `/users/25025320/followers`, `/users/25025320/stories`, and so on. **Cache the id.** The lookup is billed at double because it costs two Instagram requests — a typeahead resolve, then the profile fetch. Every subsequent call by id is single-price. At any volume this is the single biggest saving available to you. Usernames are not stable — people rename accounts, and the id survives that while the handle does not. Storing the id is also the more correct choice, not just the cheaper one. If you want candidates rather than one exact account, use [`GET /search/users`](https://herascrape.com/docs/api/search#accounts) instead. The username endpoint verifies the match and returns `404` on a near-miss. ## Profile URL → user id [#profile-url--user-id] There is no URL-based profile lookup. Take the handle out of the path yourself: ```python import re def username_from_url(url: str) -> str | None: m = re.match(r"https?://(?:www\.)?instagram\.com/([A-Za-z0-9._]+)", url) return m.group(1) if m else None ``` Then resolve it as above. ## Post URL → post [#post-url--post] `/posts/{id}` accepts the shortcode directly, so a post URL needs nothing more than a substring: ```python def shortcode_from_url(url: str) -> str | None: m = re.search(r"instagram\.com/(?:p|reel|tv)/([A-Za-z0-9_-]+)", url) return m.group(1) if m else None ``` ```bash GET /posts/B4XpqViUFUI ``` `/p/`, `/reel/` and `/tv/` all carry the same kind of code. ## Shortcode ⇄ pk [#shortcode--pk] The two are interconvertible — the shortcode is base64 of the pk over Instagram's alphabet. [`GET /resolve/media`](https://herascrape.com/docs/api/utility) does it for free, with no Instagram request: ```bash GET /resolve/media?code=B4XpqViUFUI # -> { "pk": "2168384974851429640", "code": "B4XpqViUFUI" } ``` You rarely need it, since `/posts/{id}` takes either form. It earns its keep when normalising ids before storing them, so the same post does not end up in your database twice under two spellings. Some media endpoints need the **composite** `_` form. Where that applies we resolve the owner for you — pass the bare id. ## Share link → reel [#share-link--reel] A `/s/` link is Instagram's opaque share format, used for stories and highlights. Resolve it through [`GET /oembed`](https://herascrape.com/docs/api/utility#oembed-and-share-links): ```bash GET /oembed?url=https://instagram.com/s/aGlnaGxpZ2h0OjE4MDI... # -> { "reel_id": "highlight:1802...", "author_id": "25025320", "can_view": true } ``` Then fetch by the `reel_id` you got back — `highlight:` prefixed ids go to [`/highlights/{id}`](https://herascrape.com/docs/api/stories-highlights), a bare user id goes to `/stories/{id}`. `/oembed` is free. ## Quick reference [#quick-reference] | You have | You want | Do this | | ---------------- | -------------- | -------------------------------------------------- | | Username | User id | `GET /users/username/{username}` → `pk`. Cache it. | | Profile URL | User id | Regex the handle, then as above. | | Post URL | Post | Regex the shortcode → `GET /posts/{code}` | | Shortcode | pk | `GET /resolve/media?code=` — free | | pk | Shortcode | `GET /resolve/media?pk=` — free | | `/s/` share link | Reel | `GET /oembed?url=` → `reel_id` — free | | Audio id | Reels using it | `GET /audio/{id}/medias` | | Location id | Its media | `GET /locations/{id}/recent` |