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.
On this page
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 | Nonetype 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.pyOpen 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
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.
| 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):
{
"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 qualityThe 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 10A 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:
| 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.
"""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.targetsudo systemctl daemon-reload
sudo systemctl enable --now reels-bot
journalctl -u reels-bot -fThe .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.
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.
