# Download Instagram Stories with Python (No Login)

> Download Instagram stories with Python and no login: a short requests script on the StoriesFly API that skips saved files, with a cron line.

- URL: https://storiesfly.com/blog/download-instagram-stories-python
- Published: 2026-10-21
- Topic: Downloading
- Publisher: StoriesFly (https://storiesfly.com)

## How do you download Instagram stories with Python without logging in?

Call a story API with `requests`, read the `stories` list from the JSON answer, and save each `media_url` to a file. With the StoriesFly API this takes one `GET` request to `/v1/user/stories` and an API key in a header. You do not need an Instagram account, a password or cookies. The script below is about 60 lines and uses only `requests` plus the standard library.

StoriesFly is our API, so we use it here. The same approach works with any service that returns story media links. This article covers public accounts only.

## What you need

- Python 3.9 or newer
- The `requests` package
- A StoriesFly API key

Install the package:

```bash
pip install requests
```

To get a key, sign in with Telegram at [/developers/dashboard](/developers/dashboard). A new account gets 100 free requests, once per Telegram account, which is enough to try everything here. Keys look like `sf_live_...`. The API docs are at [/developers/docs](/developers/docs) and the story endpoint is described at [/developers/docs/user-stories](/developers/docs/user-stories).

Keep the key out of your code. The script reads it from the `STORIESFLY_KEY` environment variable.

## The full script

Save this as `download_stories.py`:

```python
"""Download every story an Instagram account has up right now.

Usage:  STORIESFLY_KEY=sf_live_... python download_stories.py natgeo
"""
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import requests

API = "https://storiesfly.com/api/v1"
KEY = os.environ["STORIESFLY_KEY"]


def get_stories(username: str) -> list[dict]:
    r = requests.get(
        f"{API}/user/stories",
        params={"username": username},
        headers={"X-Api-Key": KEY},
        timeout=60,
    )
    if r.status_code != 200:
        # Every error is JSON: {"error": "...", "code": "..."}
        err = r.json()
        raise SystemExit(f"{r.status_code} {err['code']}: {err['error']}")
    print("Requests charged:", r.headers.get("X-Units-Charged"))
    return r.json()["stories"]


def download(url: str, path: Path) -> None:
    with requests.get(url, stream=True, timeout=120) as r:
        r.raise_for_status()
        with open(path, "wb") as f:
            for chunk in r.iter_content(chunk_size=1 << 16):
                f.write(chunk)


def main() -> None:
    username = sys.argv[1].lstrip("@")
    stories = get_stories(username)
    if not stories:
        print(f"@{username} has no stories right now.")
        return

    folder = Path("stories") / username
    folder.mkdir(parents=True, exist_ok=True)

    for story in stories:
        ext = "mp4" if story["media_type"] == "video" else "jpg"
        when = datetime.fromtimestamp(story["taken_at"], tz=timezone.utc)
        path = folder / f"{when:%Y-%m-%d_%H-%M-%S}_{story['id']}.{ext}"
        if path.exists():
            continue  # already saved on an earlier run
        download(story["media_url"], path)
        print("saved", path)


if __name__ == "__main__":
    main()
```

## Run it

Set the key and pass a username, with or without the `@`:

```bash
export STORIESFLY_KEY=sf_live_your_key_here
python download_stories.py natgeo
```

Output looks like this (sample values):

```text
Requests charged: 1
saved stories/natgeo/2026-10-21_08-14-52_3412345678901234567.mp4
saved stories/natgeo/2026-10-21_09-02-10_3412345678901234999.jpg
```

Files land in `stories/<username>/`. If the account has no active stories, the script says so and exits.

## What the API returns

`GET /v1/user/stories?username=natgeo` answers with JSON that has a `stories` array. Each item has these fields:

| Field | Meaning |
|---|---|
| `id` | Story id |
| `media_type` | `video` or `photo` |
| `media_url` | Link to the file |
| `taken_at` | Unix timestamp of when the story was posted |
| `video_duration` | Length in seconds, for videos |

A sample item, with made-up values:

```json
{
  "id": "3412345678901234567",
  "media_type": "video",
  "media_url": "https://...",
  "taken_at": 1792570492,
  "video_duration": 12.4
}
```

Check the [endpoint page](/developers/docs/user-stories) or the OpenAPI spec at `https://storiesfly.com/openapi.json` for the full schema.

## How the script handles files

**Video or photo.** `media_type` decides the extension: `mp4` for `video`, `jpg` for everything else.

**File names.** Each file is named `<date>_<time>_<id>.<ext>`, for example `2026-10-21_08-14-52_3412345678901234567.mp4`. The time comes from `taken_at` in UTC, so the files sort in the order the stories were posted. The id keeps two stories posted in the same second apart.

**Reruns skip saved files.** Because the name is built from data that never changes for a story, the same story always gets the same path. If the path exists, the script moves on without downloading. You can run it as often as you like and only new stories are fetched.

**Download right away.** `media_url` points to the Instagram CDN, and those links expire after a while. The script downloads each file in the same run that fetched the list. Do not store the links for later.

**Errors.** Every error answer is JSON like `{"error": "...", "code": "..."}`. The script prints the status and `code` and stops. Common codes are `not_found` (no such user), `private_account`, `unauthorized` (bad key), `credits_exhausted` and `rate_limited` (wait for the `Retry-After` header and try again).

## Run it every few hours

Stories disappear 24 hours after they are posted. To keep an archive, run the script on a schedule. On Linux or macOS, `crontab -e` and add:

```bash
0 */4 * * * cd /home/you/story-archive && STORIESFLY_KEY=sf_live_your_key_here /usr/bin/python3 download_stories.py natgeo >> run.log 2>&1
```

This runs at minute 0 of every fourth hour. Each run costs one request per account, and the file downloads from the CDN are not counted, so a four-hour schedule for one account is six requests a day. With several accounts, loop over the usernames in a shell script or wrap `main()` in a loop. If you do, add a short pause between accounts to stay under the rate limit: 1 request per second before your first top-up, 3 per second at the Start level.

## Highlights

Highlights stay on a profile and do not expire. To save them, call `/v1/user/highlights?username=...` to list an account's highlights, then `/v1/highlight` with a highlight id to get its items. The items are downloaded the same way as stories. Details are in the docs for [user highlights](/developers/docs/user-highlights) and [highlight](/developers/docs/highlight).

## Why not Instaloader or a logged-in script?

Tools that log in to Instagram, such as Instaloader, work by using a real account's session. That has trade-offs. A logged-in account that makes automated requests can be flagged, put through a checkpoint or blocked, and you need to keep the session and cookies alive. If the account matters to you, that is a risk. With a story API none of that is your concern, so your script is a plain HTTP call with a key. The cost is that it is a paid service and depends on the provider. Pick whichever trade-off suits your project.

## Private accounts

Stories of private accounts cannot be downloaded this way. The API answers `private_account` (HTTP 403), and no private content is returned. A private account only shows its stories to approved followers, and a no-login tool has no way around that.

## Pricing

Most calls count as one request. You pay as you go from a USD balance, and you can top it up with crypto, including TON and USDT. A `not_found` or `private_account` answer is charged like data. Server-side failures (`upstream_unavailable`, `internal_error`) are refunded. Current prices are on [/developers/pricing](/developers/pricing).

## Use it responsibly

Use this for public data and follow Instagram's terms and the law where you live. Respect the people who post the stories: do not republish their content without permission.

## FAQ

### Can I download Instagram stories with Python without an Instagram account?

Yes. The script calls the StoriesFly API with an API key. No Instagram or Facebook account, login or cookies are involved.

### Does the script download a story twice if I run it again?

No. Files are named by posting time and story id, and the script skips any path that already exists. Reruns only fetch stories that are new.

### How many requests does one run use?

One request to `/v1/user/stories` per account per run. The file downloads come from the Instagram CDN and are not counted. The `X-Units-Charged` header shows what a call cost.

### Can I download stories from a private account?

No. The API returns `private_account` and no content. Only public accounts work.

### Why do I need to run it on a schedule?

Stories are removed after 24 hours. A run every few hours saves each story before it expires.

### Does it work for photo stories as well as video?

Yes. `media_type` is `video` or `photo`, and the script saves videos as `.mp4` and photos as `.jpg`.
