Your bot token is useless for this. That surprises people, and it surprised me.
A Telegram bot can read a channel only when it is a member of it, and adding a bot to a channel you do not own requires that channel's admin. So the usual plan (get a token from BotFather, call getUpdates, read a public channel) collapses at the first step. Public here means anyone can open the channel in the app. It does not mean the Bot API will hand it to you.
The route that does work is MTProto, the protocol the Telegram apps themselves speak. Telethon and TDLib both speak it well. The cost is the part nobody mentions in the tutorial: you authenticate as a user, with a phone number, you keep a session file that is a credential in its own right, and you keep a process alive, because MTProto is a persistent connection rather than a request and a response.
For a script that runs once an hour, that is a lot of moving parts to own.
One GET instead of a session
I work on an API that puts that MTProto layer behind plain HTTP, so a channel lookup is a request with a key on it. Here is the whole thing.
import os, requests
HOST = "telegram155.p.rapidapi.com"
H = {"x-rapidapi-key": os.environ["TELEGRAM_API_KEY"], "x-rapidapi-host": HOST}
r = requests.get(f"https://{HOST}/v1/usernames/durov", headers=H, timeout=30)
r.raise_for_status()
chat = r.json()["chats"][0]
print(chat["id"], chat["title"])
No phone number, no session file, nothing to keep running between calls.
The field that will bite you
Read the handle carefully. Telegram lets an account hold several usernames, and when it does, the flat username field comes back empty while the real handles sit in an array:
{
"id": 1006503122,
"title": "Pavel Durov",
"username": "",
"usernames": [
{ "username": "durov", "active": true, "editable": true },
{ "username": "rove", "active": true, "editable": false }
]
}
A channel with a single handle fills username and leaves usernames empty, which is why this breaks in production rather than in your first test. @programmerjokes and @telegram both look ordinary; @durov does not. Read it like this:
def handle(chat):
for u in chat.get("usernames") or []:
if u.get("active") and u.get("editable"):
return u["username"]
return chat.get("username") or str(chat["id"])
The editable one is the primary handle, the others are aliases pointing at the same account.
Posting cadence, which is the number people actually want
Subscriber counts are easy to get and mostly vanity. What tells you whether a channel is alive is how often it posts and how its posts age. Both come out of one history call.
import datetime as dt
r = requests.get(f"https://{HOST}/v1/peers/1001142398/history",
headers=H, params={"limit": 5}, timeout=30)
msgs = r.json()["messages"]
for m in msgs:
when = dt.datetime.fromtimestamp(m["date"], dt.UTC)
print(m["id"], when.date().isoformat(), m.get("views"))
span = msgs[0]["date"] - msgs[-1]["date"]
print(f"{len(msgs) / (span / 86400):.2f} posts per day")
Real output for @programmerjokes, measured 2026-09-14 at 19:30 UTC:
3896 2026-09-14 4714
3895 2026-09-11 17410
3894 2026-09-09 23861
3893 2026-09-07 29896
3892 2026-09-04 38990
0.50 posts per day
Look at the view column rather than the top line. The post from that morning had 4 714 views; the one from ten days earlier had 38 990. A Telegram post keeps collecting views for days through forwards and search, so a fresh post is not a weak post, it is an unfinished one. If you are ranking channels by engagement and you divide views by subscribers on whatever the latest post happens to be, you are mostly measuring how many hours old that post is.
Take the median of posts older than a week instead, and the ranking stops jumping every time you run it.
Discovery without a crawler
The one that surprised me most: Telegram itself will tell you which channels are similar to a given one, and the API exposes it directly.
r = requests.get(f"https://{HOST}/v1/channels/recommendations",
headers=H, params={"peer_id": 1001142398}, timeout=30)
print(len(r.json()["chats"]), "similar channels")
That returned 10 channels in my run. Ten neighbours per seed, each of which has ten of its own, is a topical map you can walk instead of guessing search terms.
Retries, because quotas are real
Rate limits arrive as 429. Treat them as a signal to wait rather than an error to log and move past:
import time
def get(path, params=None, attempts=3):
for i in range(attempts):
r = requests.get(f"https://{HOST}{path}", headers=H, params=params, timeout=30)
if r.status_code != 429:
r.raise_for_status()
return r.json()
time.sleep(2 ** i)
raise RuntimeError(f"still rate limited after {attempts} attempts")
Exponential backoff, three tries, and the body of a 429 tells you which quota you hit.
When to skip all of this
If you need to send messages, join chats, react, or read anything private, you need a real MTProto client with a real account, and none of the above replaces it. Telethon is excellent and the session file is the point, not the problem.
The HTTP route earns its place when you are reading public channels from something that is not a long-running process: a cron job, a Lambda, an agent tool call, a dashboard that refreshes on load.
That is the case I had, twice, before I stopped rewriting the same Telethon wrapper.
The API used here is Telegram Public Channels API on RapidAPI. There is a free plan, up to 10 lookups a day, no card.
Written with an AI assistant; every number and code snippet was run against the live HiringIndex API by the team before publishing. <!-- ai-disclosure -->
Top comments (0)