A guide to wiring a website chat widget to Telegram, so a solo operator gets a push notification when a visitor asks for a human, and can answer from their phone with the reply appearing in the visitor's browser.
Written after implementing this on a Next.js 14 + Supabase + Vercel stack. The architecture is stack-agnostic — anything that can receive an HTTPS POST and store a row will do. Code samples are TypeScript/Next.js; translate freely.
Status of this guide: the Telegram side is verified working (bot posts to the group, webhook receives updates and returns 200). The full end-to-end path on the original implementation was still being debugged when this was written, for environment-configuration reasons documented in Troubleshooting. Nothing here is theoretical, but treat the integration checklist as advice from someone mid-climb rather than from the summit.
Visitor types in widget
│
▼
Your chat backend ──► bot decides it needs a human
│
▼
createForumTopic ──────────────► Telegram supergroup
└─ new topic "Dana · he"
└─ push notification to your phone
▲ │
│ │ you reply in the topic
│ ▼
Your webhook ◄────────────────────── Telegram sends update
│ (message_thread_id identifies chat)
▼
Write as agent message ──► visitor's browser polls it up
Two moving parts: outbound (your server → Telegram) and inbound (Telegram → your server). They fail independently and for different reasons, so build and verify them separately.
A bot has exactly one DM thread with you. If you route every conversation through that DM, two visitors at once become interleaved text and you cannot tell which reply belongs to whom.
Telegram forum topics solve this. A supergroup with Topics enabled shows a list of conversations, each with its own unread badge, each opening like a normal chat. One topic per escalated conversation.
The decisive property: every message you send inside a topic arrives at your
webhook carrying message.message_thread_id. Store that ID against the chat
row and the reply→conversation mapping is exact. No quoting discipline, no
"which chat did you mean" fallback, no possibility of a reply landing on the
wrong customer.
The alternative, if topics are unavailable: send each escalation as a
message to the bot DM, store its message_id, and require yourself to
swipe-reply. The inbound update then carries
reply_to_message.message_id. It works, it is a smaller change, and it breaks
the first time you forget to swipe and just type. Only choose this if topics
genuinely will not work for you.
Note: older documentation says forum topics require 100 or 200+ group members. That limit no longer applies — verified working on a two-member group.
All of this is manual, done once, no code. Roughly ten minutes.
Message @BotFather (verify the blue check):
/newbot
Give it a display name and a globally-unique username ending in bot. You get
a token shaped like 8123456789:AAF.... Treat it as a password.
If you ever paste this token into a screenshot, chat, or issue tracker, revoke it immediately: BotFather →
/revoke→ pick the bot. You get a new token instantly and nothing else needs redoing. This is easy to do by accident while troubleshooting.
/setprivacy → pick your bot → Disable
The wording is inverted and trips everyone: DISABLED is what you want.
Enabled means the bot only sees messages starting with / or mentioning it by
username — so your normal typed replies would be invisible to it.
Admin bots are supposed to receive everything regardless of this setting, but set it explicitly; it removes a variable.
Note: privacy changes are documented as applying to groups the bot joins after the change. If the bot was already in your group, consider removing and re-adding it.
- New Group → add your bot (Telegram requires at least one member) → name it.
- Modern Telegram clients create supergroups directly. Confirm by opening group info: if you see Permissions, Administrators, and Recent Actions, it is already a supergroup. If not, set a public username under Group Type and then remove it — it stays converted.
- Group info → Topics → enable. Existing messages move into a "General" topic.
- Group Type → Private. This group will contain customer emails and transcripts.
- Chat History For New Members → Hidden. Enabling topics may flip this to Visible. Anyone who joins later would otherwise be able to read every past support conversation.
Group info → Administrators → Add Admin → your bot. Grant at minimum:
- Manage Topics ← required for
createForumTopic - Send messages
- Manage messages
Verify the admin count incremented. A bot that is a member but not an admin cannot create topics, and the failure is a runtime 400 rather than anything visible in the UI.
Post any message in the group, then open in a browser:
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates
From the JSON:
message.chat.id→ your group ID. Negative, starting-100.message.from.id→ your admin user ID.
Three things that go wrong here:
{"ok":true,"result":[]}— the bot was not eligible to receive those messages. Fix privacy mode and admin status, then post a fresh message.getUpdatesonly replays what the bot was allowed to see at the time; earlier messages are gone permanently.- Do this before registering a webhook.
getUpdatesandsetWebhookare mutually exclusive. Once a webhook exists,getUpdatesreturns nothing. Use/deleteWebhookto go back. - Capture the group ID after enabling topics. Converting a group can change
its ID, and a stale one produces
chat not foundlong after you have stopped suspecting it.
openssl rand -hex 32Telegram only accepts A-Z a-z 0-9 _ - here, so hex is safe.
TELEGRAM_BOT_TOKEN from BotFather
TELEGRAM_GROUP_ID negative, -100… ← the minus sign is part of it
TELEGRAM_ADMIN_ID your numeric user ID
TELEGRAM_WEBHOOK_SECRET the openssl output
SUPPORT_AGENT_ID your internal admin user ID (see below)
SUPPORT_AGENT_EMAIL the address shown to visitors as the agent
SUPPORT_AGENT_ID should be your real admin account ID, not a synthetic
one. It gets written to every conversation you answer from Telegram, which is
what lets you start a conversation on your phone and finish it in your admin
console without the record looking orphaned.
Read section 8.1 before you deploy. On most platforms, environment variables are baked in at build time. Adding a variable does not affect a running deployment until you rebuild. This detail consumed several hours of debugging on the original implementation.
One nullable column on your conversations table:
alter table support_chats
add column if not exists telegram_topic_id bigint,
add column if not exists telegram_notified_at timestamptz;
create index if not exists support_chats_tg_topic_idx
on support_chats(telegram_topic_id)
where telegram_topic_id is not null;The index matters: every inbound Telegram message does a lookup by
telegram_topic_id, and it is on the latency path of you sending a reply.
Because the column is new, existing conversations are correctly NULL — there is no backfill trap where old rows appear already-notified.
// lib/telegram.ts
const API = "https://api.telegram.org";
export function enabled() {
return Boolean(process.env.TELEGRAM_BOT_TOKEN);
}
async function call<T>(method: string, body: Record<string, unknown>): Promise<T | null> {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) return null;
try {
const res = await fetch(`${API}/bot${token}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
if (!json.ok) {
// LOG THIS. See section 8.2 — silent catches are why debugging took hours.
console.error(`[telegram] ${method} failed:`, json.description, body);
return null;
}
return json.result as T;
} catch (err) {
console.error(`[telegram] ${method} threw:`, err);
return null;
}
}
export async function openTopic(name: string): Promise<number | null> {
const r = await call<{ message_thread_id: number }>("createForumTopic", {
chat_id: process.env.TELEGRAM_GROUP_ID,
name: name.slice(0, 128),
});
return r?.message_thread_id ?? null;
}
export async function send(topicId: number, text: string): Promise<boolean> {
const r = await call("sendMessage", {
chat_id: process.env.TELEGRAM_GROUP_ID,
message_thread_id: topicId,
text,
// NO parse_mode — see below
});
return r !== null;
}
export async function closeTopic(topicId: number) {
await call("closeForumTopic", {
chat_id: process.env.TELEGRAM_GROUP_ID,
message_thread_id: topicId,
});
}Send with no parse_mode. If you set Markdown or HTML, a visitor message
containing a stray _, *, or [ returns a 400 and the notification is
silently lost. Losing exactly the messages that contain code snippets or file
paths is the worst possible failure distribution — those are the ones where the
visitor most needs a human.
Every call is wrapped so Telegram failures never break the visitor's request. This is correct behaviour and a diagnostic disaster; section 8.2 covers how to have both.
Find the point in your code that decides "a human is needed." Insert the Telegram attempt before any existing agent-availability check.
async function telegramEscalate(chat: Chat): Promise<boolean> {
if (!tg.enabled()) return false;
let topicId = chat.telegram_topic_id;
if (!topicId) {
topicId = await tg.openTopic(`${chat.name ?? chat.email ?? "Visitor"} · ${chat.lang}`);
if (!topicId) return false;
await updateChat(chat.id, {
telegram_topic_id: topicId,
telegram_notified_at: new Date().toISOString(),
});
}
const transcript = (await messagesFor(chat.id))
.map((m) => `${m.role}: ${m.body}`)
.join("\n");
return tg.send(topicId, `${chat.email ?? "no email"}\n${chat.path}\n\n${transcript}`);
}Then at the escalation site:
if (await telegramEscalate(chat)) {
await addMessage(chat.id, "system", "Connecting you to a person…");
return; // ← the critical early return
}
// existing fallback: check for online agents, otherwise email the transcriptWhy the early return matters. If your existing code checks a presence table to decide whether anyone is available, that table knows nothing about you holding a phone. Without this return, the visitor is told nobody is available and the transcript emails itself — while a notification sits on your lock screen. Treating a successful Telegram delivery as proof of an available agent is the whole point.
Only create topics on escalation, never on conversation start. Otherwise every bot-handled conversation spawns a topic and the list becomes unusable within a week.
Escalation only sends the opening transcript. Every visitor message after that needs forwarding too, or you see the question and none of the follow-ups.
Find the branch in your message handler that fires when a conversation is already with a human:
if (chat.status === "agent" || chat.status === "waiting") {
if (chat.telegram_topic_id && tg.enabled()) {
await tg.send(chat.telegram_topic_id, `${chat.name ?? "Visitor"}: ${text}`);
}
return currentState(chat);
}Forward visitor messages only. If you forward agent messages too, your own Telegram reply gets written as a message, which forwards it back to Telegram, which writes it again. Guard on role explicitly.
// app/api/support/telegram/route.ts
export async function POST(req: Request) {
// Always return 200, even when rejecting. A non-200 makes Telegram retry the
// same update indefinitely and your queue never drains.
const ok = () => new Response("ok");
if (req.headers.get("x-telegram-bot-api-secret-token") !== process.env.TELEGRAM_WEBHOOK_SECRET) {
return ok();
}
const update = await req.json();
const msg = update.message;
if (!msg?.text) return ok();
if (String(msg.from?.id) !== process.env.TELEGRAM_ADMIN_ID) return ok();
if (String(msg.chat?.id) !== process.env.TELEGRAM_GROUP_ID) return ok();
const topicId = msg.message_thread_id;
if (!topicId) return ok(); // General topic — ignore
const chat = await chatByTopicId(topicId);
if (!chat) return ok();
if (msg.text.trim() === "/close") {
await closeChat(chat, { role: "agent", email: process.env.SUPPORT_AGENT_EMAIL });
await tg.closeTopic(topicId);
return ok();
}
await updateChat(chat.id, {
status: "agent",
agent_id: process.env.SUPPORT_AGENT_ID,
agent_email: process.env.SUPPORT_AGENT_EMAIL,
});
await addMessage(chat.id, "agent", msg.text, "Support");
return ok();
}All three checks are load-bearing. The secret header proves the request came from Telegram. The sender check means a compromised group cannot inject messages. The group check means the bot being added elsewhere cannot reach your customers. Without them, anyone who discovers your bot can send messages that render to customers as official support.
After the route is deployed and live:
curl -s "https://api.telegram.org/bot<TOKEN>/setWebhook" \
-d "url=https://www.example.com/api/support/telegram" \
-d "secret_token=<SECRET>" \
-d 'allowed_updates=["message"]'Verify:
curl -s "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"You want pending_update_count: 0 and no last_error_message.
Use your canonical host. If
example.com308-redirects towww.example.com, registering the apex fails withWrong response from the webhook: 308 Permanent Redirect— Telegram does not follow redirects. Check withcurl -sI https://example.com/api/... | grep -i locationand register whatever it points to. This costs a debugging round on almost every deployment where www and apex both resolve.
A climbing pending_update_count means Telegram is delivering and your route
is not returning 200.
Every failure mode in this integration produces the same symptom: nothing happens. Missing env var, wrong token, wrong group ID, insufficient permissions, unwritten code — identical silence.
The original implementation spent six debugging rounds guessing between these. Build the instrument first and every question becomes a page load.
// app/api/support/telegram/diag/route.ts
export const dynamic = "force-dynamic";
const peek = (v?: string) =>
!v ? { set: false } : { set: true, len: v.length, head: v.slice(0, 4), tail: v.slice(-4) };
async function probe(method: string, body: Record<string, unknown> = {}) {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) return { skipped: "no token" };
try {
const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
return { http: res.status, ok: json.ok, description: json.description };
} catch (e) {
return { threw: String(e) };
}
}
export async function GET() {
const session = await requireAdmin(); // ← never leave this unauthenticated
if (!session) return Response.json({ error: "forbidden" }, { status: 403 });
const group = process.env.TELEGRAM_GROUP_ID;
return Response.json({
commit: process.env.VERCEL_GIT_COMMIT_SHA ?? null,
env: {
TELEGRAM_BOT_TOKEN: peek(process.env.TELEGRAM_BOT_TOKEN),
TELEGRAM_GROUP_ID: peek(group),
TELEGRAM_ADMIN_ID: peek(process.env.TELEGRAM_ADMIN_ID),
TELEGRAM_WEBHOOK_SECRET: peek(process.env.TELEGRAM_WEBHOOK_SECRET),
SUPPORT_AGENT_ID: peek(process.env.SUPPORT_AGENT_ID),
SUPPORT_AGENT_EMAIL: peek(process.env.SUPPORT_AGENT_EMAIL),
},
getMe: await probe("getMe"),
getChat: await probe("getChat", { chat_id: group }),
send: await probe("sendMessage", { chat_id: group, text: "diag ping" }),
webhook: await probe("getWebhookInfo"),
});
}Reading it:
| Result | Meaning |
|---|---|
TELEGRAM_BOT_TOKEN.set: false |
Variable not in this build. Everything else is noise. Redeploy. |
getMe.ok: false |
Bad token. Often a pre-revoke copy. |
getMe ok, getChat fails chat not found |
Wrong group ID — missing -, or a stale pre-topics ID. |
send fails not enough rights |
Bot is not an admin, or lacks Manage Topics. |
| All green, feature still silent | Env and Telegram are fine. Now suspect your code. |
The head/tail/len fields let you compare against known-good values
without exposing secrets, and catch invisible problems — a trailing newline
shows up as an off-by-one length.
Delete or permanently gate this route once you are done.
Section 6.1 wraps every Telegram call so failures never break the visitor experience. That is right. But a silent catch means a bad group ID, a missing variable, and a permissions error are indistinguishable from outside.
Log inside the catch — specifically Telegram's description field, which names
the problem in plain English. You want the visitor protected and yourself
informed. These are not in tension; they just need one console.error.
On Vercel and most similar platforms, adding or editing an environment variable does not affect the running deployment. The value is captured when the build runs.
Symptoms: settings show the variable present and correct, and the code behaves as though it does not exist.
Check the "Updated Xm ago" timestamp on the variable against the build time of the live deployment. If the variable is newer, redeploy — from the dashboard, without the build cache.
Also confirm the variable is assigned to the Production environment specifically, not only Preview or Development.
If your platform logs outgoing HTTP calls per invocation (Vercel's "External APIs" table does this), that single view answers the central question:
api.telegram.orgabsent — your code never attempted a send. Either the branch was not written, or anenabled()guard returned false. Not a Telegram problem at all.- Present, non-200 — the send fired and was rejected. The status names the cause.
- Present, 200, but nothing arrived — wrong chat or topic ID.
Make sure you are reading the right invocation. A polling widget generates
constant GETs; escalation happens on the POST.
Before suspecting your application, verify the bot and IDs in isolation:
curl -s "https://api.telegram.org/bot<TOKEN>/sendMessage" \
-d "chat_id=<GROUP_ID>" -d "text=ping"If this lands in the group, the bot, token, group ID, and permissions are all
fine and the problem is entirely in your application or its environment. If it
fails, the description field names the cause. This one command eliminates
half the possible causes in ten seconds.
Rate limits. All topics share one group, and bot sends to a group are capped around 20/minute. Irrelevant for a solo operator; if support volume ever justifies it, the fix is sharding across multiple groups.
Deleted topics. If you delete a topic manually, subsequent sends fail with
a thread-not-found error. Catch it, recreate the topic, update the row —
otherwise that customer's messages vanish into a 400. Use closeForumTopic
rather than deleteForumTopic when finishing a conversation; closing greys it
out but preserves the transcript.
Topic colours. editForumTopic accepts one of six icon colours. Mapping
them to conversation status gives you state at a glance from the topic list.
Message length. Telegram caps messages at 4096 characters. A long transcript needs chunking, or it fails as a unit.
Privacy. This moves customer emails and conversation content onto Telegram's servers. For most small operators that is an acceptable trade, but it should be a deliberate decision rather than a default — and depending on jurisdiction and what customers disclose in support conversations, it may need to appear in your privacy policy. Keep the group private, keep history hidden from new members, and revoke the invite link if it is ever exposed.
Secrets discipline. During setup you will handle a bot token and an invite
link, and both are easy to leak into a screenshot while troubleshooting. Both
are revocable in seconds — BotFather /revoke for the token, Manage Links for
the invite. Do it immediately rather than deciding it probably does not matter.
- Visitor asks for a human → topic appears, push notification arrives
- Reply in the topic → appears in the visitor's browser
- Two conversations escalated at once → two topics, replies land correctly
- Follow-up visitor messages appear in the correct topic
- Conversation opened in the admin console mid-flow shows Telegram replies attributed to the right agent
-
/closein a topic closes the conversation and greys the topic -
TELEGRAM_BOT_TOKENunset → previous fallback behaviour is unchanged - Forged webhook POST without the secret header → 200, nothing written
- Visitor message containing
_,*, and[delivers intact - Diagnostic route requires authentication
If the friend just wants the shape without the reasoning:
- BotFather →
/newbot,/setprivacy→ Disable - Private supergroup, Topics on, bot as admin with Manage Topics
- Post a message, read
chat.idandfrom.idfromgetUpdates - Six env vars — redeploy after setting them
- Column
telegram_topic_idon the conversations table - On escalation:
createForumTopic, store the ID, send the transcript, return early - On later visitor messages:
sendMessagewithmessage_thread_id - Webhook: verify secret header + sender + group, look up by
message_thread_id, write as agent, always return 200 setWebhookagainst the canonical host (www vs apex matters)- Build the diagnostic route before you need it