Approving agent actions from Telegram: patterns and pitfalls
Telegram is one of the fastest channels to stand up for human-in-the-loop approvals — a bot token and a webhook, no OAuth app review, buttons that render natively in the chat. It's also easy to get subtly wrong in ways that only show up once two people are watching the same chat, or the network hiccups at exactly the wrong moment.
On this page
Bot setup and the webhook
Talk to @BotFather on Telegram, /newbot, and you get back a bot
token. Point Telegram at your receiving URL with setWebhook, including a secret of
your own choosing:
curl "https://api.telegram.org/bot$TOKEN/setWebhook" \
-d "url=https://api.example.dev/telegram/webhook" \
-d "secret_token=$WEBHOOK_SECRET"
Telegram echoes that secret back on every update it POSTs, as the
X-Telegram-Bot-Api-Secret-Token header — verify it on every request, in
constant time, before touching the body. This is the entire authentication story for
inbound Telegram traffic: without checking it, anyone who discovers your webhook URL can POST a
forged Update that looks exactly like a real button tap.
Linking a chat to a workspace
Unlike an email address or a webhook URL, there's no "address" to hand a Telegram channel at
creation time — a chat has to /start your bot before you know it exists. The pattern
that works: issue a short-lived, single-use link code from your own backend, and have the user
send it to the bot as a command argument:
# 1. your backend issues a code tied to a workspace
POST /v1/channels/telegram/link-code → { "code": "7f3ac1" }
# 2. the human sends this to your bot in Telegram
/start 7f3ac1
# 3. your webhook handler reads the code out of the /start command,
# looks up the workspace it belongs to, and stores this chat_id against it
Keep the code short-lived (minutes, not days) and single-use — it's a bearer credential for
"deliver approvals to this chat" for as long as it's valid, so treat it the way you'd treat a
password-reset link, not a permanent secret. Cache the bot's own username (one API call,
getMe) rather than re-fetching it per link generated; it doesn't change.
Inline keyboard buttons
Approve/deny renders as an inline_keyboard on the message, each button carrying a
callback_data string that comes back to you unchanged when it's tapped:
{
"chat_id": 123456789,
"text": "Deploy release v1.2.3 to prod?",
"reply_markup": {
"inline_keyboard": [[
{ "text": "Approve", "callback_data": "approve:4521" },
{ "text": "Deny", "callback_data": "deny:4521" }
]]
}
}
callback_data has a hard 64-byte limit — encode a short internal
id (4521), never the full request payload. If you need more context than an id fits,
look the rest up server-side from that id rather than trying to cram it into the button.
Handling the callback_query
A tap arrives as an Update containing a callback_query:
{ id, data, message, from }. Two things have to happen, and the order matters:
- Call
answerCallbackQuerywith that query'sid— Telegram's own docs call this "necessary … even if no notification … is needed." Skip it and the tapping client shows a loading spinner on the button until it eventually times out, which reads to the operator as "did that even register?" - Then apply the decision — record the approval/deny against the request id
you decoded from
callback_data, and edit the original message (editMessageReplyMarkup, oreditMessageTextto change the text too) to remove the buttons so a second tap has nothing left to press.
curl "https://api.telegram.org/bot$TOKEN/answerCallbackQuery" \
-d "callback_query_id=$QUERY_ID" -d "text=Recorded: approved"
curl "https://api.telegram.org/bot$TOKEN/editMessageReplyMarkup" \
-d "chat_id=$CHAT_ID" -d "message_id=$MESSAGE_ID" -d "reply_markup={}"
Idempotency: the part that bites
Two things Telegram does that a naive handler doesn't expect:
- Telegram retries webhook delivery if your endpoint is slow or returns a
non-2xx status — the same
Update, including the samecallback_query, can arrive more than once. A handler that unconditionally setsstatus = "approved"and fires a delivery side effect (deploy, refund, whatever the approval gates) on every call will fire it twice for one tap. - Two people can tap different buttons on the same message near-simultaneously — an operator taps Approve while a teammate, looking at a stale copy of the chat, taps Deny a moment later. Without a guard, whichever request's write lands last wins silently, and nobody finds out the first decision was overwritten until later.
Both are the same underlying problem — "resolve this request exactly once" — and the same fix covers both: make the resolving write conditional on the request still being pending, atomically, at the database level, not by checking status in application code first and writing second:
UPDATE requests
SET status = 'approved', answered_at = now()
WHERE id = ? AND status = 'pending';
-- check the affected-row count: 0 means someone else already resolved it first
If the update affects zero rows, the request was already resolved — reply to whichever retry or late tap lost the race with "already handled" rather than pretending the second decision took effect. This is the same idempotency shape as any at-least-once delivery system; Telegram's retry behavior just makes it show up sooner than you might otherwise notice it needs handling.
Groups vs. DMs
Delivering to a group chat instead of a single operator's DM changes the failure modes above
from theoretical to routine — a group is precisely the case where two people plausibly tap at
once. It's a legitimate choice (an on-call channel where whoever's awake handles it), but pair it
deliberately with the idempotent-update pattern above rather than assuming a DM's simpler
single-viewer behavior will hold. Also worth deciding explicitly: whether answerCallbackQuery's
brief toast should say who resolved it (from.first_name off the callback query) — in
a group, "approved by Priya" answers a question a bare "approved" leaves hanging for everyone else
still looking at the chat.