Telegram bots — the official Bot API
Telegram holds the chat. Your database holds everything you will act on later.
Every serious bot defect is an update that was processed twice, or one that was
never delivered because nobody asked for it.
The Bot API is not where bots fail. They fail at the seam: the update redelivered
after a crash, the member event that never arrives because of a default nobody
read, the payment confirmed from the wrong signal, the broadcast that trips 429
at user 31 and silently stops.
Read against the Bot API on 2026-08-25, at version 10.3 (released
2026-08-24). Re-check core.telegram.org/bots/api-changelog
before quoting a
version: this API ships roughly monthly. Re-checked 2026-10-19: 10.3 is still
the latest. For a machine to read, Telegram serves the docs as Markdown at the
same path plus : core.telegram.org/bots/api.md
, one file per method
(/bots/api/methods/getupdates.md
) and a types index ().
That is official and parseable, but it is Markdown, not a JSON schema.
Deep material, loaded on demand:
| Read | When |
|---|
references/updates-and-delivery.md
| wiring polling or a webhook — the delivery contract, ordering, dedup, and the migration between them |
references/payments-stars.md
| taking money — Stars, invoices, the pre-checkout window, refunds, subscriptions |
references/limits-and-files.md
| sending at volume, or moving files — every published limit and what to do at each |
| choosing or auditing a library — aiogram, grammY, Telegraf, python-telegram-bot, and what each hides |
| the bot has real traffic, or it stops answering everyone when Telegram is slow — a queue per chat, a ceiling, payments outside it, shutdown, alarms |
Runnable, and shipped beside this file:
fixtures/update_delivery.py
— nine invariants a
correct handler holds, each with the mutant that makes it fail.
python3 fixtures/update_delivery.py --self-test
watches a redelivered update processed
twice, an update lost to a crash at the offset seam, an acked update lost to a
dead worker, a redelivery answered "duplicate" about work that never happened, a
reply doubled by a crash before the done mark, a redelivered outbox row sent
twice, one charge granted twice across two updates, a reply dropped on 429, and
one payment granted twice. Standard library only.
— nine rules that keep one
slow handler from holding every chat (the poll does not wait for handlers,
per-chat order, a ceiling, a payment lane, flood measured from arrival, no
supersession at queue time, waiting callbacks answered, dedup, a per-chat cap),
each removed in turn by
and watched failing.
Which API you are on, decided once
| You need | Use | Why |
|---|
| A bot users add, in chats it is in | Bot API — this skill | HTTP, a token, no phone number, no ban risk |
| To read history a bot cannot see, or act as a person | MTProto — | a user account, with everything that costs |
| A web interface inside Telegram | Mini App — | a page plus you must verify |
A bot cannot read messages in a group without privacy mode off or a mention,
see a chat it was never added to, read history from before it joined, act on
behalf of a user, or download a file over 20 MB. Wanting any of those is the
signal to read
— and to read its refusal first, because a
user account is a liability a bot token is not.
The update is the event, and it arrives at least once
python
# aiogram 3.x — the shape, not the framework
async def webhook(update: Update, db) -> web.Response:
await db.inbox_put(update.update_id, raw_body) # INSERT on a primary key,
return web.Response(status=200) # durable BEFORE the ack
# off the request path: a worker takes a pending row, does the work, marks it
# done — and a crashed attempt leaves the row pending for the next sweep
- is the only idempotency key you get. It is stable across
redeliveries and it identifies an update — but it is IDENTITY, not a forever
guarantee of monotonicity: after ~a week idle the counter can restart from a
new random base, so never compare update_ids across a long gap to decide
"newer". Nothing else in an update identifies it: two identical messages a
second apart are two events, and the same event delivered twice is one.
- Inbox before ack — a claim is a receipt, not completion. The on a
primary key (never a then an : under a webhook up to
, default 40, deliveries of one update can be in flight at
once) writes a durable row the 200 is only then allowed to answer for. Work
runs off the request under a worker lease: a worker that dies leaves the row
pending and the sweep retries it. Claim-then-work inside the request is the
quiet data-loss shape — the crash leaves the claim standing, Telegram's
redelivery reads "duplicate", and the update is gone while every fixture
stays green.
- Updates are kept for 24 hours and no longer. A bot that is down for a day
has lost them, and nothing will say so — reconcile from your own state, never
from the assumption that the queue drained.
- Measured across eight live Telegram bots on this machine on 2026-08-25:
zero of eight deduplicate on . Long polling hides it until the
first crash between "work done" and "offset confirmed".
drops three types by default
python
await bot.set_webhook(
url=f"{origin}/tg/{SECRET_PATH}",
secret_token=WEBHOOK_SECRET, # 1-256 chars
allowed_updates=["message", "callback_query", "chat_member",
"pre_checkout_query", "my_chat_member"],
drop_pending_updates=False,
)
The default — an empty list, and the value you get by not passing the parameter —
means
"all update types except , and
". So a bot that tracks joins and leaves receives
nothing, the request returns
, and the logs are clean.
Name every
type you handle, explicitly, and re-run
when you add one:
is set at subscription time, not at handler time.
Webhook or polling — and never both
will not work while a webhook is set. That is the whole
migration hazard: a local
run against a production token silently
takes over, or fails, depending on which side moved last. One token, one
consumer.
- The webhook is the callback, so it needs the same defences as a payment
webhook: verify
X-Telegram-Bot-Api-Secret-Token
against the value you set
with , in constant time, and answer 401 with no detail when it
fails. The URL is not a secret; the header is.
- Answer fast, work later. Telegram retries on non-2xx and on a slow reply,
and a retry is a second delivery of the same . Acknowledge, then
process — the claim above is what makes that safe.
drop_pending_updates=True
is a decision, not a cleanup. It discards
everything queued, including payments already made.
- Long polling is correct for development and for low-volume bots; a webhook is
correct when you have a public HTTPS endpoint and care about latency. Both are
in
references/updates-and-delivery.md
.
Limits are a design constraint, not an error path
| Limit | Value | What it means for the design |
|---|
| One chat | ~1 message/second | bursts are tolerated, then refused |
| One group | 20 messages/minute | a chatty group bot needs a queue |
| Bulk, all users | ~30 messages/second | a broadcast to 100k users is hours, not a loop |
| Paid broadcasts | up to 1000/second | 0.1 Stars per message past the free 30/s |
On 429 the response carries
in seconds.
Sleep exactly
that long and retry the same call — a fixed backoff either wastes the window or
trips the next one. A broadcast is a job with a rate limiter and a resume point,
not a
loop; if it cannot resume, a crash at user 60 000 restarts at zero
and every earlier user is messaged twice.
Money: the successful payment is the payment
Digital goods and services are sold
exclusively in Telegram Stars, currency
code
, and
is left empty for them.
sendInvoice / createInvoiceLink → pre_checkout_query → successful_payment
answer within 10s deliver here
- has a ten-second window. Miss it and the
transaction is cancelled — so that handler validates against your own state and
nothing slow. Anything that can take a second belongs after
.
- Deliver on , never on the pre-checkout. Pre-checkout is
a question; the payment has not happened yet.
- exists and a refund is not a deletion. Claw back what
you granted, keyed on the charge id, exactly the way a card refund is handled —
the arithmetic and the ordering are the same problem covers.
Full flow, subscriptions and the transaction ledger:
references/payments-stars.md
.
Files have a ceiling, and it is lower than you think
- Download via : 20 MB. Above that the Bot API refuses, and no
framework works around it.
- Upload: 50 MB through the cloud Bot API.
- A local Bot API server removes both ceilings and changes the file paths
your code receives. That is an infrastructure decision, not a flag —
references/limits-and-files.md
.
- is not a URL and not stable across bots. It is valid for your bot
only; store it to resend cheaply, never as an archival reference.
What belongs in your database
| Fact | Where it lives | Why |
|---|
| seen | yours, unique | the only idempotency key |
| , | yours | Telegram will not list your users for you |
| the message you sent | yours, with | editing later needs both ids |
| entitlement bought with Stars | yours, keyed on the charge id | a refund must find it |
| yours, as a cache | cheap resend, not an archive |
Telegram is not a database and offers no way to enumerate the people who have
started your bot. If you did not write it down when it happened, it is gone.
A second channel, when the audience can lose Telegram. A bot is a channel
you do not own. If your users live where access to Telegram is restricted, or
could become so, plan for the day it stops reaching them: key users on your own
id rather than on
alone, ask for a second contact (email, a web
account) at a moment it is useful to the user, and keep the product reachable
outside Telegram. A block then costs a channel, not the user list.
Before you ship
- Every handler is idempotent on (§ The update is the event).
- The webhook verifies the secret header and answers 401 without detail
(§ Webhook or polling).
- names every type you handle — the default is not "all"
(§ drops three types).
- 429 sleeps for , and a broadcast can resume (§ Limits).
- Goods are delivered on , not on pre-checkout
(§ Money).
- The poll does not wait for handlers: a queue per chat, a ceiling across
chats, payments outside it ().
Triggers
More phrases that route here: "grammy", "telegraf", "python-telegram-bot", "getUpdates",
"pre_checkout_query", "high load bot", "бот тормозит", "бот апи", "Telegram-бот", "Telegram-бота", "телеграм-бота".