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:

  1. Call answerCallbackQuery with that query's id — 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?"
  2. Then apply the decision — record the approval/deny against the request id you decoded from callback_data, and edit the original message (editMessageReplyMarkup, or editMessageText to 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:

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.