Telegram Approval Bot
Impri can send pending-action notifications to a Telegram chat with Approve and Reject buttons that let your team decide without ever opening a browser. The decision is recorded in Impri with the same integrity guarantees as a web-inbox decision.
This is an extension of the existing telegram notification channel
(see Notification channels). When approval_mode
is false (the default), the channel behaves exactly as before —
a plain HTML notification with a "Review in Impri" link. Setting
approval_mode: true enables the inline-keyboard approval flow
described here.
How it works
- An action lands in Impri as
pending. - Impri sends a Telegram message to the configured chat or group with
two inline buttons: ✅ Approve and ❌ Reject (plus an optional
🔗 View in inbox link if
BASE_URLis set). - An authorized team member taps a button. Telegram delivers a
callback_queryto Impri's webhook endpoint. - Impri verifies the webhook secret, the HMAC signature on the button payload, and the tapper's Telegram user ID against the allow-list.
- If all checks pass, Impri records the decision (same transaction as
POST /v1/actions/:id/decision), fires anycallback_urlwebhook, and edits the Telegram message to show the outcome and remove the buttons.
Digest batches (multiple actions coalesced into one message) fall back to the plain notification with a "View inbox" link — inline buttons are only meaningful for a single action.
Setup (one-time, ~10 minutes)
Step 1 — Create a Telegram bot
Open Telegram, start a chat with @BotFather, send /newbot, and
follow the prompts. BotFather returns a bot token in the form
1234567890:AAFxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx. Save it — it is not
shown again.
Step 2 — Get the chat ID
Group chat: Add the bot to the group as a member. Post any message in the group. Call:
GET https://api.telegram.org/bot{token}/getUpdatesLook for message.chat.id in the response. Group IDs are negative
integers (e.g. -1001234567890).
Direct message: Send /start to the bot. Call getUpdates and look
for message.chat.id (a positive integer — your personal ID).
Channel: Add the bot as an admin, post anything, and look for
channel_post.chat.id in getUpdates.
Step 3 — Collect authorized Telegram user IDs
Each team member who should be able to tap the buttons needs their
numeric Telegram user ID added to allowed_approver_user_ids.
The easiest method: each person messages @userinfobot and it replies
with their Id: (a positive integer like 123456789).
Alternatively, after any message is sent in the group, getUpdates
returns message.from.id for each sender — that is the same ID.
Collect all IDs you want to authorize.
Step 4 — Create the approval channel via the Impri API
curl -X POST https://api.impri.dev/v1/notification-channels \
-H "Authorization: Bearer im_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Ops approvals (Telegram)",
"type": "telegram",
"config": {
"bot_token": "1234567890:AAFxxx",
"chat_id": "-1001234567890",
"approval_mode": true,
"allowed_approver_user_ids": [123456789, 987654321]
},
"enabled": true
}'Omit hmac_secret — Impri generates a 64-character hex secret
automatically and stores it. The response shows it masked (****xxxx).
You never need to know its value unless you want to supply your own
(16–256 characters).
Step 5 — Webhook registration
Automatic (public HTTPS server): If BASE_URL is set to a public
https:// URL, Impri calls Telegram's setWebhook automatically on
channel creation. Look for a log line:
[telegram-approval] setWebhook ok {"channelId":"chan_...","url":"https://..."}No further action is needed.
Local dev / self-host behind NAT: When BASE_URL is absent,
localhost, or an RFC 1918 address, setWebhook is skipped. To
register manually:
- Expose your local server with a tunnel:bash
ngrok http 8484 # or: cloudflared tunnel --url http://localhost:8484 - Set
BASE_URL=https://<tunnel-hostname>in your.envand restart the server. - Trigger webhook registration:bashResponse on success:
curl -X POST http://localhost:8484/v1/notification-channels/{channelId}/setup-webhook \ -H "Authorization: Bearer im_..."json{ "ok": true, "url": "https://..." }
You can also skip the tunnel for local testing and use the
/test endpoint to verify the notification format (the test buttons are
harmless — they reference a synthetic action ID that does not exist in
the DB, so the webhook handler returns "Action not found" gracefully).
Step 6 — Verify
# Send a test message to the chat
curl -X POST http://localhost:8484/v1/notification-channels/{channelId}/test \
-H "Authorization: Bearer im_..."The bot posts a message with Approve / Reject buttons to the chat. You
can tap them — the response "Action not found" is correct and expected.
For a real end-to-end test, create a pending action via your agent and
tap the button; check GET /v1/actions/{actionId} to confirm the
decision landed.
Configuration reference
All fields live inside the config object of a telegram channel. The
three new fields are optional and default to off so existing channels
are unaffected.
| Field | Type | Default | Description |
|---|---|---|---|
bot_token |
string | required | Telegram Bot API token (\d+:[A-Za-z0-9_-]+). |
chat_id |
string | required | Destination chat, group, or channel ID. |
approval_mode |
boolean | false |
When true, single-action sends use inline Approve/Reject buttons. Digest batches always use plain text. |
allowed_approver_user_ids |
integer[] | [] |
Telegram numeric user IDs permitted to tap the buttons. Max 50. An empty list means no one can approve via Telegram (a validation warning is raised if approval_mode is true and the list is empty). |
hmac_secret |
string | auto-generated | 16–256 character secret used to sign button payloads and derive the Telegram webhook token. Auto-generated if omitted at creation time. Masked (****{last4}) in all API responses. Rotate via PATCH config.hmac_secret. |
Masking in API responses:
| Field | Behavior |
|---|---|
bot_token |
****{last4} |
hmac_secret |
****{last4} (when present) |
chat_id |
returned as-is |
approval_mode |
returned as-is |
allowed_approver_user_ids |
returned as-is |
Managing the channel
Add or remove approvers without re-creating the channel:
curl -X PATCH .../v1/notification-channels/{channelId} \
-H "Authorization: Bearer im_..." \
-H "Content-Type: application/json" \
-d '{"config": {"allowed_approver_user_ids": [123456789]}}'Config fields are shallow-merged — you only need to send what changes.
Disable the approval flow (revert to plain notifications):
curl -X PATCH .../v1/notification-channels/{channelId} \
-d '{"config": {"approval_mode": false}}'Impri calls deleteWebhook for the bot automatically when
approval_mode transitions to false or when enabled is set to
false.
Rotate hmac_secret: Send a new value in a PATCH. Impri
re-derives the webhook secret, calls setWebhook with the updated
secret_token, and saves the new value. Old inline buttons in chat
history return "Invalid approval link" — safe and harmless.
Delete the channel: DELETE /v1/notification-channels/{channelId}
calls deleteWebhook first, then removes the row.
Webhook endpoint
Telegram posts callback_query updates to:
POST /v1/integrations/telegram/webhook/:channelIdThis endpoint is public (no Bearer token). Authentication happens
entirely via the X-Telegram-Bot-Api-Secret-Token header verified
against the derived webhook secret before the request body is parsed.
Operators do not call this endpoint directly — Telegram does.
Rate limit: 100 requests per minute per channelId (fixed window,
keyed on channelId + IP). Legitimate Telegram traffic peaks at a
handful of requests per second; this limit stops scanner abuse.
Security model
Four independent defenses
An attacker must bypass all four simultaneously to forge an approval.
1. Webhook secret header
Every callback_query from Telegram carries
X-Telegram-Bot-Api-Secret-Token. The value Impri registered at
setWebhook time is derived as:
HMAC-SHA256(hmac_secret, "tg-webhook-secret").slice(0, 32).hex()Impri re-derives and compares it with crypto.timingSafeEqual() before
parsing the request body. A missing or wrong header gets 403 immediately.
Without hmac_secret, no scanner or forger can produce a valid header.
2. Button payload HMAC
Each Approve / Reject button carries a signature in its callback_data:
{v}:{actionId}:{sig}sig is the first 6 bytes (48 bits) of
HMAC-SHA256(hmac_secret, "tg:" + v + ":" + actionId) encoded as
base64url (8 chars). Total payload: 37 bytes, well within Telegram's
64-byte callback_data limit.
Without hmac_secret, an attacker in the Telegram chat cannot fabricate
valid callback_data for any action ID. A 48-bit HMAC requires 2^47
guesses on average — each requiring a real Telegram account to press a
real button, observable in chat, rate-limited by Telegram itself.
Comparison uses timingSafeEqual to prevent timing attacks.
3. Authorized-user check
Telegram sets callback_query.from.id in its own infrastructure — other
Telegram users cannot spoof this integer. Impri checks it against
allowed_approver_user_ids before recording any decision. If the user
ID is not in the list, the handler answers "Not authorized" with
show_alert: true and returns without reading the action from the DB —
no information about the action is revealed to unauthorized users.
Authorized taps are recorded in audit_log with
decided_by = "tg:{telegram_user_id}" and channel = "telegram".
First names (cq.from.first_name) are used only in the ephemeral
in-chat confirmation message and are never stored.
4. Idempotency via UNIQUE constraint
decisions(action_id) has a DB UNIQUE constraint. The first writer wins.
A concurrent second tap (two team members pressing simultaneously) is
caught by the constraint violation, answered with "Already decided," and
returns 200 to Telegram. Replaying a pressed button is a safe no-op.
Project isolation
The action is loaded with WHERE id = ? AND project_id = channel.project_id.
An action from project A cannot be approved via a channel belonging to
project B, even if an attacker reconstructs a valid HMAC for a foreign
action ID — the lookup returns not-found.
Bot token protection
bot_token appears only in outbound Telegram API URLs assembled
server-side. It is masked in all API responses (****{last4}), stripped
from error messages by sanitizeError() in notify.ts, never stored in
logs or audit_log, and never included in webhook request or response
bodies. All outbound Telegram calls go through fetchGuarded() with the
api.telegram.org domain hardcoded — not user-supplied.
Threat model summary
| Attack | Blocked by |
|---|---|
| Forge a button press without being in the chat | Webhook secret header (Layer 1) |
Craft valid callback_data without hmac_secret |
Button payload HMAC (Layer 2) |
Press buttons but not in allowed_approver_user_ids |
Authorized-user check (Layer 3) |
| Press the same button twice / race two team members | UNIQUE constraint (Layer 4) |
| Approve an action from a different project | project_id binding in SQL query |
Extract bot_token from logs or API |
maskConfig() + sanitizeError() |
SSRF via api.telegram.org URL |
Hardcoded domain in fetchGuarded() |
Environment variables
| Variable | Effect |
|---|---|
BASE_URL |
Must be a publicly reachable URL for setWebhook to be called automatically. Absent or localhost = webhook registration skipped. |
Troubleshooting
The bot sends a message but buttons do nothing.
The webhook is not registered or is pointing to an unreachable URL.
Check the server logs for a setWebhook call on channel creation.
If BASE_URL was a tunnel URL that has since expired, update .env
with the new tunnel URL, restart, and call
POST /v1/notification-channels/{channelId}/setup-webhook.
"Not authorized to approve in this project" when tapping a button.
The Telegram user ID of the person tapping is not in
allowed_approver_user_ids. Find their ID via @userinfobot or
getUpdates and add it with a PATCH.
"Invalid or expired approval link" when tapping a button.
The hmac_secret was rotated since the message was sent, or the
callback_data was tampered with. Old buttons become invalid after
rotation — this is intentional and safe. The action is still decidable
from the web inbox or CLI.
"Action not found".
The action expired, was already decided via a different path, or the
button belongs to a test message. Check
GET /v1/actions/{actionId} to see current status.
"Already decided".
Another approver (or a concurrent tap from the same person) beat this one to it. The decision is idempotent — nothing went wrong.
Buttons still appear after a decision.
The editMessageReplyMarkup call to Telegram failed (network error or
the message was too old). The decision was still recorded in Impri.
The buttons are cosmetically stale; any additional tap is answered with
"Already decided" and is a no-op.
setup-webhook returns an error from Telegram.
Telegram requires the webhook URL to be reachable from the internet over
HTTPS. Verify that BASE_URL resolves publicly and serves a valid TLS
certificate. A self-signed cert is rejected by Telegram unless it is
uploaded as a certificate parameter to setWebhook (not currently
automated — use a CA-signed cert or a tunnel service).
The channel was auto-disabled (fail_count reached 5).
Fix the underlying issue (e.g. incorrect bot_token or chat_id), then:
curl -X PATCH .../v1/notification-channels/{channelId} \
-d '{"enabled": true}'This resets fail_count to 0 and re-enables delivery.