# Telegram bot to download Reels and TikTok in Python

> Build a Telegram bot in Python with aiogram 3 that takes an Instagram Reel or TikTok link and sends the video back, TikTok without watermark. Full code, deploy.

- URL: https://storiesfly.com/blog/telegram-bot-download-instagram-reels-python
- Published: 2026-10-21
- Topic: Telegram
- Publisher: StoriesFly (https://storiesfly.com)

## How do you build a Telegram bot that downloads Instagram Reels and TikTok?

Write a Python bot with aiogram 3. It finds an Instagram or TikTok link in the message, asks the API's download endpoint for the media files, downloads them and uploads them to the chat. The script below is about 120 lines and uses one API request per link. StoriesFly is our API, so we use it here; no Instagram or TikTok account, login or cookies are needed.

The bot works with public Instagram reels, posts and IGTV links, and with TikTok videos (`www`, `m`, `vm` and `vt` short links). TikTok videos arrive without the watermark. A single video or photo is sent as is, and a carousel as an album of up to 10 items.

## What you need

- Python 3.10 or newer (the code uses `bytes | None` type syntax).
- A Telegram bot token.
- A StoriesFly API key.

**Bot token.** Open [@BotFather](https://t.me/BotFather) in Telegram, send `/newbot`, pick a name and a username ending in `bot`. BotFather replies with a token like `123456:ABC...`. Keep it secret.

**API key.** Sign in with Telegram at [/developers/dashboard](/developers/dashboard) and create a key. It looks like `sf_live_...`. A new account gets 100 free requests, once per Telegram account, which is enough to try the bot. The API documentation is at [/developers/docs](/developers/docs).

**Install and run.**

```bash
pip install "aiogram>=3,<4" aiohttp
export BOT_TOKEN="123456:ABC..."
export STORIESFLY_KEY="sf_live_..."
python reels_bot.py
```

Open your bot in Telegram, send `/start`, then paste a link such as `https://www.instagram.com/reel/XXXXXXXXXXX/` or `https://www.tiktok.com/@user/video/123...`.

## How the bot works

### Finding the link

```python
LINK_RE = re.compile(
    r"https?://(?:www\.)?instagram\.com/(?:p|reels?|tv)/[\w-]+"
    r"|https?://(?:www\.|m\.|vm\.|vt\.)?tiktok\.com/[^\s]+"
)
```

The first half accepts `/p/` (posts), `/reel/` and `/reels/` (reels) and `/tv/` (IGTV). It stops at the shortcode, so a link with tracking parameters such as `?igsh=...` still matches, and the parameters are dropped. The second half accepts TikTok links on `www`, `m`, `vm` and `vt` hosts and takes everything up to the next space. The handler uses `F.text.regexp(LINK_RE)`, so any message that contains a link is handled, even if the user adds words around it. Anything else goes to the last handler, which asks for a link.

### Calling /v1/download

```python
async with session.get(
    f"{API}/download", params={"url": url}, headers={"X-Api-Key": KEY}
) as r:
```

[`GET /v1/download`](/developers/docs/download) takes one `url` and works for both platforms. It returns files only, no metrics. The fields:

| Field | Meaning |
|---|---|
| `platform` | `instagram` or `tiktok` |
| `id` | post or video id |
| `url` | the post link |
| `author` | the author's username |
| `caption` | TikTok only; `null` for Instagram |
| `media_type` | `video`, `photo` or `carousel` |
| `thumbnail_url` | preview image |
| `media` | list of files: `type` (`video` or `image`), `url`, `quality` (`hd`, `sd` or `null`), `thumbnail_url` |
| `audio_url` | TikTok only |

Output looks like this (sample values, trimmed):

```json
{
  "platform": "tiktok",
  "id": "7300000000000000000",
  "url": "https://www.tiktok.com/@user/video/7300000000000000000",
  "author": "sample_user",
  "caption": "Sample caption",
  "media_type": "video",
  "thumbnail_url": "https://...",
  "media": [
    { "type": "video", "url": "https://...", "quality": "hd", "thumbnail_url": "https://..." }
  ],
  "audio_url": "https://..."
}
```

For a TikTok video the first entry in `media` is the best quality, without the watermark. That is why the bot keeps only the first entry when `media_type` is `video`:

```python
files = post["media"]
if post["media_type"] == "video":
    files = files[:1]  # the first entry is the best quality
```

The caption is read with `(post.get("caption") or "")[:1000] or None`, so an Instagram post, where it is `null`, is sent without a caption.

Errors come as JSON: `{"error": "...", "code": "not_found"}`. `fetch_download` raises `LookupError(code)` for any non-200 status, and the handler turns the code into a message.

### Why the bot downloads the file and uploads it

The media links in the answer expire after a while, so they must be used right away. The simplest way to send them would be to pass the URL to `answer_video`, and Telegram would fetch the file itself. That works for small files only: the limit for files a bot sends by URL is lower than the 50 MB limit for files a bot uploads (the Bot API documentation gives 5 MB for photos and 20 MB for other files by URL). Videos often exceed it.

So the bot downloads the bytes with `fetch_file` and sends them as a `BufferedInputFile`. It checks `Content-Length` first. If the file is larger than 50 MB, it skips the upload and sends the link instead.

One limit of the code to know: if a server does not send `Content-Length`, the size check passes and the whole file is read into memory. Reels and TikTok videos are usually well below the limit, so this is fine for a small bot.

### Carousels as albums

```python
for i, m in enumerate(files[:10]):  # an album holds up to 10
```

A Telegram album holds up to 10 items, so the bot cuts the list at 10. Each item becomes an `InputMediaVideo` or `InputMediaPhoto`, chosen by `m["type"]`. The caption is attached to the first item only, because Telegram shows an album caption from the first element. Captions are cut at 1000 characters, below Telegram's limit for media captions. If only one item is left, the bot sends it with `answer_video` or `answer_photo` instead of an album.

### Errors as friendly messages

```python
ERRORS = {
    "not_found": "This post does not exist or was deleted.",
    "private_account": "The account is private, so its posts are not available.",
    "bad_request": "That does not look like an Instagram or TikTok post link.",
    ...
    "upstream_unavailable": "Could not get this one right now. Try again in a minute.",
}
```

The API error `code` is mapped to a sentence the user can read. Unknown codes fall back to "Something went wrong, try later." The codes you will meet most often:

| Code | HTTP | Meaning | Charged |
|---|---|---|---|
| `not_found` | 404 | post missing or deleted | yes |
| `private_account` | 403 | account is private; the API returns no private content | yes |
| `bad_request` | 400 | not an Instagram or TikTok post link | no |
| `rate_limited` | 429 | too many requests; wait `Retry-After` | no |
| `credits_exhausted` | 402 | balance is empty | no |
| `upstream_unavailable` | 503 | failed on our side; the bot answers "try again in a minute" | refunded |

The bot does not retry on 429. It tells the user to try again. If many people use it at once, add a small queue or a delay.

## The full script

Save this as `reels_bot.py`.

```python
"""Telegram bot: send it an Instagram reel/post or a TikTok link, get the video back.

Usage:
    pip install "aiogram>=3,<4" aiohttp
    BOT_TOKEN=123:abc STORIESFLY_KEY=sf_live_... python reels_bot.py
"""
import asyncio
import os
import re

import aiohttp
from aiogram import Bot, Dispatcher, F
from aiogram.filters import CommandStart
from aiogram.types import (
    BufferedInputFile,
    InputMediaPhoto,
    InputMediaVideo,
    Message,
)

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

# A bot may upload files up to 50 MB; bigger videos are sent as a link.
MAX_UPLOAD = 50 * 1024 * 1024
LINK_RE = re.compile(
    r"https?://(?:www\.)?instagram\.com/(?:p|reels?|tv)/[\w-]+"
    r"|https?://(?:www\.|m\.|vm\.|vt\.)?tiktok\.com/[^\s]+"
)

ERRORS = {
    "not_found": "This post does not exist or was deleted.",
    "private_account": "The account is private, so its posts are not available.",
    "bad_request": "That does not look like an Instagram or TikTok post link.",
    "rate_limited": "Too many requests at once. Try again in a few seconds.",
    "credits_exhausted": "The bot is out of API requests for now.",
    "upstream_unavailable": "Could not get this one right now. Try again in a minute.",
}

dp = Dispatcher()


async def fetch_download(session: aiohttp.ClientSession, url: str) -> dict:
    async with session.get(
        f"{API}/download", params={"url": url}, headers={"X-Api-Key": KEY}
    ) as r:
        data = await r.json()
        if r.status != 200:
            raise LookupError(data.get("code", "error"))
        return data


async def fetch_file(session: aiohttp.ClientSession, url: str) -> bytes | None:
    async with session.get(url) as r:
        r.raise_for_status()
        size = int(r.headers.get("Content-Length", 0))
        if size > MAX_UPLOAD:
            return None
        return await r.read()


@dp.message(CommandStart())
async def start(message: Message) -> None:
    await message.answer("Send me a link to an Instagram reel or post, or a TikTok video.")


@dp.message(F.text.regexp(LINK_RE))
async def on_link(message: Message, session: aiohttp.ClientSession) -> None:
    link = LINK_RE.search(message.text).group(0)
    await message.bot.send_chat_action(message.chat.id, "upload_video")

    try:
        post = await fetch_download(session, link)
    except LookupError as e:
        await message.answer(ERRORS.get(str(e), "Something went wrong, try later."))
        return

    files = post["media"]
    if post["media_type"] == "video":
        files = files[:1]  # the first entry is the best quality
    caption = (post.get("caption") or "")[:1000] or None

    items = []
    for i, m in enumerate(files[:10]):  # an album holds up to 10
        data = await fetch_file(session, m["url"])
        if data is None:  # over 50 MB: give the link instead
            await message.answer(f"The video is too big to upload, here is the link:\n{m['url']}")
            continue
        is_video = m["type"] == "video"
        name = f"{post['id'] or 'post'}_{i}.{'mp4' if is_video else 'jpg'}"
        file = BufferedInputFile(data, filename=name)
        cap = caption if not items else None
        items.append(
            InputMediaVideo(media=file, caption=cap)
            if is_video
            else InputMediaPhoto(media=file, caption=cap)
        )

    if len(items) == 1:
        one = items[0]
        if isinstance(one, InputMediaVideo):
            await message.answer_video(one.media, caption=one.caption)
        else:
            await message.answer_photo(one.media, caption=one.caption)
    elif items:
        await message.answer_media_group(items)


@dp.message()
async def other(message: Message) -> None:
    await message.answer("I need a link like https://www.instagram.com/reel/... or https://www.tiktok.com/@user/video/...")


async def main() -> None:
    bot = Bot(BOT_TOKEN)
    async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120)) as session:
        await dp.start_polling(bot, session=session)


if __name__ == "__main__":
    asyncio.run(main())
```

## Deploying it

Polling mode needs no public address, so any small VPS works. Create a user and a virtualenv, put the script in `/opt/reels-bot`, and keep the secrets in an env file with `chmod 600`.

```ini
# /etc/systemd/system/reels-bot.service
[Unit]
Description=Reels and TikTok Telegram bot
After=network-online.target

[Service]
WorkingDirectory=/opt/reels-bot
EnvironmentFile=/opt/reels-bot/.env
ExecStart=/opt/reels-bot/venv/bin/python reels_bot.py
Restart=always
RestartSec=5
User=reelsbot

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now reels-bot
journalctl -u reels-bot -f
```

The `.env` file holds the two lines `BOT_TOKEN=...` and `STORIESFLY_KEY=...` without quotes. With Docker, use a `python:3.12-slim` image, `pip install` the two packages, pass the same variables with `--env-file`, and set `--restart unless-stopped`.

## Costs

The bot makes one API request per link. Downloading the file itself is not an API request. `download` has its own fixed price per download, lower than a regular data request, and a TikTok download costs less than an Instagram one. While you have free requests left, one download uses one of the 100. Not-found and private-account answers are charged like data, so a user who sends a dead link still costs one download. Current prices are on [/developers/pricing](/developers/pricing).

If you also need views, likes or other numbers, call `/v1/post` for Instagram or `/v1/tiktok/video` for TikTok. Those are regular data requests.

The rate limit is 1 request per second before the first top-up. Every answer carries `X-Balance-Remaining` and `X-RateLimit-Remaining` headers, which you can log to see how many requests are left.

## Adding stories

The same pattern works for stories. Call [`GET /v1/user/stories?username=...`](/developers/docs/user-stories). It returns `stories[]` with `media_type` (`video` or `photo`), `media_url`, `taken_at` and `video_duration`. You would add a `/stories username` command, download each `media_url` the same way and send them as an album. The step-by-step version is in [How to download Instagram stories with Python](/blog/download-instagram-stories-python). Stories of private accounts return `private_account`.

## Notes on use

This only works with public data. Follow the terms of Instagram and TikTok and your local law, and send people content only if they have the right to save it, for example their own or with the author's permission.

## FAQ

### Can the bot download TikTok videos without the watermark?

Yes. For a TikTok video the first entry in `media` is the best quality without the watermark, and the bot sends that one.

### Can the bot download Reels from private accounts?

No. The API returns `private_account` and no content, and the bot shows a message that the account is private.

### Why does the bot upload the video instead of sending the URL?

Telegram limits files that a bot sends by URL to a lower size than files it uploads (50 MB). Videos often exceed the URL limit. Downloading and uploading also avoids sending an expired link.

### Do I need an Instagram or TikTok account or cookies?

No. The bot talks only to the StoriesFly API with an `X-Api-Key` header. There is no login, no cookies and no Instagram, Facebook or TikTok account.

### Can I use webhooks instead of polling?

Yes. aiogram supports webhooks. The code here uses polling because it needs no domain or HTTPS certificate.

### How many requests does one link use?

One. The `/v1/download` call returns every item of a carousel in one answer. Downloading the files from the media links does not count.
