InstagramAnswer

Instagram API get stories: any public account, not yours

Meta's API reads stories only on accounts you manage. Ours lists any public account's live stories by username for 1 credit a call, plus highlights.

6 min read

Short answer

  • Meta's Instagram API reads stories only on professional accounts you manage, for 24 hours, and documents no stories call for other accounts.
  • ToolzerHub's /v2/instagram/user/stories lists any public account's live stories by username, with image and video URLs, at 1 credit a call; two more routes list highlights and their stories.
  • NASA's 2 live stories and 89 highlight items, plus Nike's 48 highlight items, came back in 11 calls, and all 139 media files downloaded.
On this page

Meta's Instagram API reads stories only on professional accounts you manage, and only while they are live: 24 hours. Its docs show no call for another account's stories. ToolzerHub's /v2/instagram/user/stories lists any public account's live stories by username, with download URLs, and two more routes return its highlights.

What Meta's API does with stories

Meta's IG User Stories endpoint, GET /{ig-user-id}/stories, lists the live stories of an account whose token you hold. Its reference says "Stories are only available for 24 hours", and it leaves out live video stories and reshares. Business Discovery, Meta's route to other accounts, documents a media edge and no stories edge. Use Meta's API for your own account: it also returns story metrics and publishes stories, which ours can't.

Instagram stories with ToolzerHub

We make ToolzerHub. Our Instagram API reads stories by username, with one API key and no Meta app. Three /v2 routes, 1 credit a call each:

  • GET /v2/instagram/user/stories?username_or_id= returns the live stories. An account with none returns reel: null.
  • GET /v2/instagram/user/highlights?username_or_id= returns the highlight covers, each with an id and title.
  • GET /v2/instagram/highlight/stories?highlight_id= returns the items saved in one highlight.

Each item has taken_at, expiring_at, video_duration, link stickers, and image_versions2 or video_versions with media URLs. The routes return no viewer lists or metrics.

This script writes each account's live stories, and optionally its highlights, to CSV, and downloads the media if you give it a folder.

python
import csv
import os
import sys
import time
from datetime import datetime, timezone

import requests

BASE = "https://api.toolzerhub.com/v2/instagram"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
FIELDS = ["account", "source", "story_id", "posted_at", "expires_at", "type", "seconds",
          "link", "file"]
calls = 0


def cell(value):
    # Spreadsheets run text starting with = + - or @ as a formula; an apostrophe keeps it text.
    if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t"):
        return "'" + value
    return value


def get(path, params, tries=4):
    # 429 and 503 are retried; the wait comes from the error body or the Retry-After header.
    global calls
    for attempt in range(tries):
        r = requests.get(BASE + path, headers=HEADERS, params=params, timeout=90)
        calls += 1
        if r.status_code == 400:  # malformed username: skip this account
            print("bad request:", str(r.json()["error"].get("message")).split("\n")[0])
            return None
        if r.status_code not in (429, 503) or attempt == tries - 1:
            if r.status_code >= 500:  # any 5xx, or a 503 past the retries: skip
                print(f"HTTP {r.status_code}, skipping:", r.text[:120].replace("\n", " "))
                return None
            r.raise_for_status()
            return r.json().get("data")
        try:
            wait = r.json()["error"]["details"]["retryAfter"]
        except (ValueError, KeyError, TypeError):
            wait = None
        try:
            wait = int(wait or r.headers.get("Retry-After") or 5 * (attempt + 1))
        except ValueError:
            wait = 5 * (attempt + 1)
        time.sleep(min(wait, 60))


def when(ts):
    return datetime.fromtimestamp(ts, timezone.utc).isoformat() if ts else ""


def save(item, folder, video):
    # Media URLs carry a signed expiry, so download in the same run.
    path = os.path.join(folder, f"{item['pk']}.{'mp4' if video else 'jpg'}")
    try:
        url = (item["video_versions"][0] if video else item["image_versions2"]["candidates"][0])["url"]
        with requests.get(url, timeout=120, stream=True) as r:
            r.raise_for_status()
            with open(path, "wb") as f:
                for chunk in r.iter_content(1 << 16):
                    f.write(chunk)
        return path
    except (KeyError, IndexError, TypeError, OSError, requests.RequestException) as e:
        print("  download failed:", item.get("pk"), type(e).__name__)
        if os.path.exists(path):
            os.remove(path)  # no half-written files
        return ""


def write(writer, account, source, items, folder, seen):
    for item in items:
        if not item.get("pk") or item["pk"] in seen:
            continue
        seen.add(item["pk"])
        video = bool(item.get("video_versions"))
        links = [(s.get("story_link") or {}).get("url") for s in item.get("story_link_stickers") or []]
        writer.writerow({
            "account": account, "source": cell(source), "story_id": item["pk"],
            "posted_at": when(item.get("taken_at")), "expires_at": when(item.get("expiring_at")),
            "type": "video" if video else "image", "seconds": round(item.get("video_duration") or 0) or "",
            "link": cell(" ".join(u for u in links if u)),
            "file": save(item, folder, video) if folder else "",
        })


def export_account(writer, account, folder, highlights):
    seen = set()
    data = get("/user/stories", {"username_or_id": account})
    stories = ((data or {}).get("reel") or {}).get("items") or []  # no live story: reel is null
    write(writer, account, "story", stories, folder, seen)
    tray = []
    if highlights and data is not None:  # skip highlights for unknown or failed accounts
        tray = ((get("/user/highlights", {"username_or_id": account}) or {}).get("tray")) or []
        for h in tray:
            items = (get("/highlight/stories", {"highlight_id": h.get("id")}) or {}).get("items") or []
            write(writer, account, f"highlight: {h.get('title') or h.get('id')}", items, folder, seen)
            time.sleep(0.3)
    print(f"{account}: {len(stories)} live stories, {len(tray)} highlights, {len(seen)} rows")
    return len(seen)


def main(accounts, highlights="no", folder="", out="stories.csv"):
    if folder:
        os.makedirs(folder, exist_ok=True)
    total = 0
    with open(out, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        for account in [a.strip().lstrip("@") for a in accounts.split(",") if a.strip()]:
            total += export_account(writer, account, folder, highlights == "yes")
    print(f"wrote {total} rows to {out} in {calls} calls")


if __name__ == "__main__":
    if len(sys.argv) < 2 or (len(sys.argv) > 2 and sys.argv[2] not in ("yes", "no")):
        sys.exit('usage: python instagram_stories.py nasa,nba [yes|no highlights] [media_folder or ""] [file.csv]')
    if not HEADERS["x-api-key"]:
        sys.exit("set TOOLZERHUB_API_KEY first")
    main(*sys.argv[1:])

Media URLs expire, so the script downloads them in the same run; pass "" as the folder to skip downloads. A story that sits in two highlights appears once.

Run it

Pass usernames, yes for highlights, and a folder for the media:

bash
export TOOLZERHUB_API_KEY=your_key
python instagram_stories.py nasa,nike yes media stories.csv

The output:

text
nasa: 2 live stories, 5 highlights, 91 rows
nike: 0 live stories, 1 highlights, 48 rows
wrote 139 rows to stories.csv in 11 calls

It took 110 seconds and 11 calls. All 139 files downloaded: 84 images and 55 videos, 124 MB in all. The highlights held story media up to a year old.

What the live stories showed

We asked for the live stories of 10 brand and celebrity accounts:

Account Live stories Highlights
BBC News 13 not checked
NatGeo 6 0
Netflix 6 0
NBA 4 not checked
NASA 2 5
MrBeast 1 not checked
Nike 0 1
SpaceX 0 0
Instagram 0 not checked
Cristiano 0 not checked
  • Every live story expired 24 hours after it was posted, so run the script at least once a day, or read the highlights.
  • Of 32 live stories, 23 were images and 9 were videos of 6 to 60 seconds. BBC News put a link sticker on all 13.
  • 4 of the 10 accounts had no live story at the time.

What it costs

Live stories are one call per account, so 1,000 accounts cost 1,000 credits. Highlights add one call per account plus one per highlight: NASA's 5 highlights and 89 items took 6 calls. The 100 free credits a month cover 100 accounts' live stories; $19 buys 10,000 credits that never expire, and plans for higher volume cover more accounts. Set your key and run the script on your first account.

For an account's posts and reels, see getting a user's posts and the Instagram reels API.

We are not affiliated with Meta Platforms, Inc. or Instagram.

Get this data from your own code

The Instagram API returns it as JSON with one x-api-key header. 100 free credits monthly.