requests installed, the sqlite3 module (ships with Python), a bot token from @BotFather, a test channel where that bot is an administrator with the "Post messages" permission, curl, and the Telegram Bot API documentation open in a browser tab (Bot API limits change — check the current numbers there before you set production values).
bash, or Windows 10/11 with WSL2. All commands below are bash.python3 --version.python3 -m venv .venv && source .venv/bin/activate.requests package: pip install requests.sqlite3 CLI for manual inspection (optional but used in verification below). On Debian/Ubuntu: sudo apt install sqlite3.-100…) and its public username if it has one.The three protection layers you are about to build look like this:
publisher ──► [1] idempotency key ──► [2] rate limiter ──► [3] Bot API sendMessage
│ │ │
SQLite outbox token bucket message_id
(UNIQUE key) (per-minute cap) │
└──────────── skip on duplicate ◄─────────────┘
│
https://t.me/<username>/<id>
| Layer | What it stops | Where it lives |
|---|---|---|
| Idempotency key | The same text being posted twice after a retry, restart, or double-clicked admin button | SQLite table `outbox`, `UNIQUE` primary key |
| Rate limiter | `429 Too Many Requests` during broadcast loops | In-process token bucket, one worker |
| `t.me` permalink check | Silent misconfiguration: wrong channel, deleted post, wrong username | `https://t.me/<username>/<message_id>` |
Example: a bot with token <token-from-BotFather> posting to -1001234567890 with username my_test_channel produces the permalink https://t.me/my_test_channel/42.
For the bot-creation and deployment part itself, follow An AI-powered Telegram bot in Python: from scratch to deploy — this guide starts where that one ends.
What to do: create the project skeleton and export the environment variables.
mkdir -p ~/tg-dedup && cd ~/tg-dedup
python3 -m venv .venv && source .venv/bin/activate
pip install requests
export TG_BOT_TOKEN="<token-from-BotFather>"
export TG_CHANNEL="-1001234567890"
export TG_CHANNEL_USER="my_test_channel"
Why: the key must be derived from the message content and the target channel, so the script needs both values at runtime; keeping the token in the environment prevents it from leaking into git.
How to check: echo "${TG_BOT_TOKEN:0:10}" prints the first ten characters of the token, and python3 -c "import requests; print(requests.__version__)" prints a version number.
An idempotency key is a fingerprint of an operation: run the same operation twice, get the same fingerprint, and the second run is discarded instead of executed. Here the fingerprint is a SHA-256 hash of the channel id plus the normalized text.
What to do: create dedup.py with the key function and the SQLite schema.
# dedup.py
import hashlib, sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS outbox (
idem_key TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed
message_id INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
sent_at TEXT
);
"""
def idempotency_key(channel_id: str, text: str) -> str:
normalized = " ".join(text.split()).strip().lower()
return hashlib.sha256(f"{channel_id}:{normalized}".encode()).hexdigest()[:32]
def init_db(path="outbox.db"):
con = sqlite3.connect(path)
con.executescript(SCHEMA)
con.commit()
con.close()
if __name__ == "__main__":
init_db()
TEXT PRIMARY KEY on idem_key makes the database itself the arbiter: the second insert of the same key fails or is ignored, so two processes cannot both send.
Why: normalization (collapsing whitespace, lowercasing) means "Hello World" and "hello world" map to one key — this is what kills the classic duplicate caused by a re-rendered template.
How to check:
python3 dedup.py
python3 -c "
from dedup import idempotency_key
print(idempotency_key('-1001234567890', 'Hello World'))
print(idempotency_key('-1001234567890', 'hello world'))
"
Expected output: two identical 32-character hex lines. If they differ, your normalization is broken — fix it before continuing. ls -l outbox.db should show the database file.
Example: sqlite3 outbox.db ".tables" prints outbox.

The Bot API has no idempotency parameter for sendMessage — the deduplication must happen on your side (confirm the current parameter list in the official docs). The rate limiter is a token bucket: it records timestamps of recent sends and sleeps when the per-minute cap is reached. The Bot API documentation states a global cap of roughly 30 messages per second and about 20 messages per minute to one group or channel; re-check those numbers in the docs, because Telegram adjusts them.
What to do: create publisher.py.
# publisher.py
import os, sqlite3, time, sys, requests
from dedup import idempotency_key, init_db
BOT_TOKEN = os.environ["TG_BOT_TOKEN"]
CHANNEL = os.environ["TG_CHANNEL"]
USERNAME = os.environ["TG_CHANNEL_USER"]
API = f"https://api.telegram.org/bot{BOT_TOKEN}"
DB = "outbox.db"
class RateLimiter:
def __init__(self, per_minute=20):
self.per_minute, self.stamps = per_minute, []
def wait(self):
now = time.monotonic()
self.stamps = [t for t in self.stamps if now - t < 60]
if len(self.stamps) >= self.per_minute:
time.sleep(60 - (now - self.stamps[0]) + 0.5)
self.stamps.append(time.monotonic())
def claim(key, text):
con = sqlite3.connect(DB)
cur = con.execute(
"INSERT INTO outbox (idem_key, channel_id, text) VALUES (?,?,?) "
"ON CONFLICT(idem_key) DO NOTHING", (key, CHANNEL, text))
con.commit()
claimed = cur.rowcount == 1
con.close()
return claimed
def publish(text, limiter):
key = idempotency_key(CHANNEL, text)
if not claim(key, text):
print(f"skip: duplicate idem_key={key}")
return
for _ in range(3):
limiter.wait()
r = requests.post(f"{API}/sendMessage",
json={"chat_id": CHANNEL, "text": text,
"disable_web_page_preview": True}, timeout=15)
if r.status_code != 429:
break
wait = r.json().get("parameters", {}).get("retry_after", 30)
print(f"429: sleeping {wait}s")
time.sleep(wait)
body = r.json()
if not body.get("ok"):
print(f"failed: {body.get('description')}")
return
message_id = body["result"]["message_id"]
con = sqlite3.connect(DB)
con.execute("UPDATE outbox SET status='sent', message_id=?, sent_at=datetime('now') "
"WHERE idem_key=?", (message_id, key))
con.commit(); con.close()
print(f"sent: message_id={message_id} https://t.me/{USERNAME}/{message_id}")
if __name__ == "__main__":
init_db()
publish(" ".join(sys.argv[1:]), RateLimiter(per_minute=20))
Why: the row is inserted *before* the network call. If the process crashes between insert and send, the row stays pending and is visible in the database — you never get an invisible duplicate, only an explicit retry decision. Honoring retry_after instead of a fixed sleep is what prevents a second 429 immediately after the first.
How to check: run the script once. Expected output:
sent: message_id=42 https://t.me/my_test_channel/42
Open the permalink — the post must be there. Then sqlite3 outbox.db "SELECT status, message_id FROM outbox;" shows sent|42.
Example: for a broadcast of 50 texts with per_minute=20, the limiter inserts three sleep windows and the script finishes in roughly 2 minutes instead of being throttled by the API.

What to do: run the same publish command twice and inspect all three layers.
python3 publisher.py "Idempotency smoke test"
python3 publisher.py "Idempotency smoke test"
Expected output — one send, one skip:
sent: message_id=42 https://t.me/my_test_channel/42
skip: duplicate idem_key=<32-hex-string>
Then verify the store and the public permalink:
sqlite3 outbox.db "SELECT status, COUNT(*) FROM outbox GROUP BY status;"
curl -s -o /dev/null -w "%{http_code}\n" https://t.me/my_test_channel/42
Expected: sent|1 from the database and 200 from curl. A 404 means the username or the message id is wrong, not that the post is missing from the channel.
Why: this proves the three layers agree — the database has exactly one accepted row, the channel has exactly one message, and the message is reachable at its canonical URL.
How to check (pre-flight list before switching to production):
sent: and one skip: lineSELECT COUNT(*) FROM outbox GROUP BY idem_key HAVING COUNT(*)>1; returns no rows200429 appears at most once in the log and is followed by the correct sleepExample: ps aux | grep publisher.py | grep -v grep must show exactly one line. Two processes bypass the in-process limiter and double the send rate.
Problem. The broadcast dies partway with a rate-limit error
{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 35","parameters":{"retry_after":35}}per_minute below the documented cap, honor retry_after from the response body, and keep a single publisher process. In the code above time.sleep(wait) does this; verify with a log line 429: sleeping 35s. Wrap the worker in flock -n /tmp/tg.lock python3 publisher.py "text" so cron cannot overlap.Problem. Two identical posts appear in the channel
sqlite3.IntegrityError: UNIQUE constraint failed: outbox.idem_keyclaim(), or someone ran a manual INSERT and then a sendMessage anyway. The exception is the database correctly refusing the duplicate — the bug is that the sender ignored it and posted regardless.claim() and use ON CONFLICT(idem_key) DO NOTHING so the conflict becomes a skip: line, not a crash. Detect historical damage with sqlite3 outbox.db "SELECT idem_key, COUNT(*) c FROM outbox GROUP BY idem_key HAVING c>1;" — this must return no rows.Problem. Editing a post repeatedly returns an error
400 Bad Request: message is not modifiededitMessageText was called with text identical to the current post — typical for a polling loop that re-renders a counter or a timestamp.python3 -c "import hashlib;print(hashlib.sha256('same'.encode()).hexdigest()[:8])" gives you the comparison value; store it next to message_id and compare before calling the API.You now have a publisher where the database, not the network, decides whether a message is new, and where every send leaves a permalink you can verify in one curl. To extend it into a scheduled content pipeline with retries, queues and monitoring, continue with AI agents for task automation: a guide.
Does the Telegram Bot API have a built-in idempotency key for sendMessage?
No idempotency parameter is documented for sendMessage; deduplication must be implemented on the client side, as shown in Step 1. Check the current parameter list in the official Bot API docs before relying on any field.
Why check t.me if the database already says sent?
The database proves what you intended to send, while the permalink proves the message exists in the channel you think it does. A wrong TG_CHANNEL id produces a clean database and an empty channel.
Can I use the t.me check for a private channel?
Public previews exist only for channels with a public username. For a private channel, verify with your own message_id record, or mirror the post into a private test channel that has a username.
What rate limit should I set in production?
Start at per_minute=20 for a single channel and always honor retry_after from a 429 response. The exact global and per-chat limits are published in the Bot API documentation — confirm them there, as they change.