StoriesFly logo

Telegram11 min read

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.

StoriesFly TeamPublished
On this page
  1. How do you build a Telegram bot that downloads Instagram Reels and TikTok?
  2. What you need
  3. How the bot works
  4. The full script
  5. Deploying it
  6. Costs
  7. Adding stories
  8. Notes on use
  9. FAQ

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 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 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.

Install and run.

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

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

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

GET /v1/download takes one url and works for both platforms. It returns files only, no metrics. The fields: For more details, see our guide on download Reels in HD.

FieldMeaning
platforminstagram or tiktok
idpost or video id
urlthe post link
authorthe author's username
captionTikTok only; null for Instagram
media_typevideo, photo or carousel
thumbnail_urlpreview image
medialist of files: type (video or image), url, quality (hd, sd or null), thumbnail_url
audio_urlTikTok only

Output looks like this (sample values, trimmed):

{
  "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:

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

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

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:

CodeHTTPMeaningCharged
not_found404post missing or deletedyes
private_account403account is private; the API returns no private contentyes
bad_request400not an Instagram or TikTok post linkno
rate_limited429too many requests; wait Retry-Afterno
credits_exhausted402balance is emptyno
upstream_unavailable503failed 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.

"""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.

# /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
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.

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=.... 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. 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.

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