Bot API reference
The same update can arrive twice
Telegram delivers updates at least once. A webhook that is slow, times out or returns a non-200 will receive the same update again, and there is no ordering guarantee between separate updates. Every `Update` carries a monotonically increasing `update_id`, which is the only reliable deduplication key.
At-least-once delivery: the exact figures
- Delivery guarantee
- At least once, unordered across distinct updates
- Delivery
- At least once — duplicates are expected, not exceptional
- Ordering
- Not guaranteed between distinct updates
- Deduplication key
- update_id, monotonically increasing
- Where to store it
- Durably. In-memory sets are empty after a deploy
- Allocation writes
- Conditional UPDATE, never read-then-write
As of 2025-10-01, Telegram Bot API 13.4
What it means in practice
At-least-once is a property of every reliable messaging system and it is not a defect, but it is routinely designed around rather than designed for. The failure it produces is specific and expensive: a payment confirmation processed twice credits an account twice, an allocation handler run twice hands out two slots from a capped pool, and a welcome message sent twice merely looks careless. Which of those you get depends entirely on what the handler does, not on how often the duplicate arrives.
`update_id` is the deduplication key and it must be persisted, not held in memory. A `Set` in a worker process is empty after the next deploy, and the redelivery that matters most is the one that arrives during a restart. The correct shape is an insert with a uniqueness constraint on `update_id`: if the insert conflicts, the update has already been handled and the handler returns without doing the work again.
The ordering caveat is separate and catches people who solved the duplication problem first. Two distinct updates may be processed out of order, particularly under concurrency where several workers pull from the same webhook. Reading a balance and then writing it back in two operations is not atomic, so a bot that reads a remaining allocation, decides it is sufficient, and then decrements it can over-allocate under concurrent load. The fix is a conditional write in the database — decrement where remaining is greater than zero — rather than a check followed by a write.
Naive retries make both problems worse, and this is where a well-configured HTTP client causes harm. A timeout on `sendMessage` does not tell you whether the message was delivered; the connection dropped, and the send may well have succeeded. Retrying without a key is how subscribers receive a broadcast twice. The queue should record the attempt before the call and reconcile after, rather than relying on the outcome of a request that may never report one.
The practical rule is to make handlers idempotent by construction and stop reasoning about how often a duplicate will occur. Assume every handler runs at least twice, assume any two of them may run in either order, and design so that both are harmless. That posture costs very little at build time and removes the entire class of bug that only appears under the load nobody tested.
Handling it in code
// Deduplicate on update_id in the database, not in process memory.
async function handleOnce(update: { update_id: number }, env: Env): Promise<void> {
const claimed = await env.DB
.prepare('INSERT OR IGNORE INTO seen_updates (update_id, at) VALUES (?, ?)')
.bind(update.update_id, Date.now())
.run()
// No row inserted means this update_id was already handled. A restart between the
// insert and the work still redelivers, which is why the work must also be idempotent.
if (claimed.meta.changes === 0) return
await doWork(update)
}
// Allocation under concurrency: one conditional write, never read-then-write.
async function claimSlot(env: Env, poolId: string): Promise<boolean> {
const result = await env.DB
.prepare('UPDATE pools SET remaining = remaining - 1 WHERE id = ? AND remaining > 0')
.bind(poolId)
.run()
return result.meta.changes === 1
}Questions this raises
How often does a duplicate actually arrive?
Rarely, which is exactly what makes it dangerous. It clusters around the moments a system is already under stress — a slow handler, a deploy, a timeout — so it is absent from every test and present during the incident. Designing for it is cheap; diagnosing it after a double charge is not.
Is update_id unique forever, or does it reset?
It increases monotonically for the bot and is suitable as a deduplication key over any window you care about. Retaining a bounded history — days rather than forever — is normally sufficient, since redelivery happens within the retry window rather than weeks later.
Does responding 200 quickly stop duplicates?
It removes the most common cause, because Telegram retries what looks unhealthy. It does not make the handler idempotent, and a deploy that restarts mid-processing will still produce a redelivery of work that was partly done. Fast acknowledgement and idempotency are complementary rather than alternatives.
Can I rely on updates arriving in the order events happened?
No, and this is the half of the problem that survives deduplication. Two messages sent in quick succession may be processed in either order, which matters whenever the second depends on the first. Where sequence genuinely matters, it has to be reconstructed from the message contents rather than assumed from arrival order.
Related limits
What Telegram will and will not tell you about a message after sending is covered in the absence of delivery receipts.
The one failure Telegram does report explicitly, and how to handle it, is in blocked-by-user errors.
The same guarantee in third-party systems, and how to reconcile across both, is discussed in the webhooks integration page.