Lesson 5 · Telegram Bots on Serverless

Owning the Handshake: setWebhook, Secrets, and Dev/Prod Side by Side

You've seen the registration call in passing since lesson 1. This lesson makes you the operator of it: what setWebhook really stores, which secret carries the trust, how to inspect and repair a live registration, and why dev and prod can run at the same time without stealing each other's mail. It ends with you reading your own bot's registration, live.

Warm-up (the question left open in chat)

Which leaked secret would let a stranger impersonate you to your own bot — forge an update saying "chat 421337842 says: delete everything" — and what's the fix?

What Telegram actually stores

setWebhook(url) writes exactly one record on Telegram's side, keyed by bot token: "deliver this bot's updates by POSTing to this URL." Three properties of that record drive everything operational:

  1. One per token. Registering again replaces the previous URL silently — last writer wins. (The preview-hijack hazard from lesson 1 is just this property plus Vercel's throwaway preview URLs.)
  2. Idempotent. Same URL again = harmless no-op. That's why the repo can re-register on every production cold start without ceremony.
  3. Exclusive with polling. While a webhook is registered, calling getUpdates fails with 409 Conflict — Telegram refuses to serve the same queue two ways. Delivery mode is a switch, not a mixture.
That 409 is the missing "why" from lesson 1: the dev bot isn't a separate token by convention — it's forced. If dev long-polled the prod token, it would either 409 (webhook registered) or, after a deleteWebhook, silently drag production's real updates onto your laptop. A second bot account is the only clean way to have both delivery modes alive at once.

The two registrars in this repo

Same call, two triggers — you saw both files in chat; here's the operational logic:

server/plugins/register-webhook.ts
export default defineNitroPlugin(async () => {
  if (env.vercelEnv !== 'production') return   // preview deploys: hands off
  // …
  const url = `${env.publicBaseUrl}/api/telegram/webhook/${env.telegramWebhookSecret}`
  await bot.api.setWebhook(url)
})

Automatic: a Nitro plugin runs at server boot — on Vercel, that means each cold start. The VERCEL_ENV gate is the whole safety story: without it, every preview deploy's first request would boot the server, self-register its throwaway URL, and steal production's queue (property 1 above).

Manual (npm run set-webhook): the identical call from your machine. It exists because of a genuine chicken-and-egg: the plugin only runs on a cold start, a cold start only happens when traffic arrives, and no traffic ever arrives until Telegram knows the URL. After the very first production deploy, someone has to make the first move — that's this script.

One deliberate omission worth knowing about: setWebhook also accepts a secret_token parameter — Telegram would then echo it back in an X-Telegram-Bot-Api-Secret-Token header on every POST, and you'd verify the header instead of hiding the path. It's the "official" way to authenticate inbound webhooks; this repo skips it and relies on the unguessable path alone. Both are legitimate; the header survives your URL appearing in logs, the path needs zero extra code. Your next bot can choose either — just choose one on purpose.

Reading a live registration

getWebhookInfo is the diagnostic view of the stored record — callable from a browser, since bot API methods are plain HTTPS GETs:

https://api.telegram.org/bot<TOKEN>/getWebhookInfo

The fields that matter, and what they tell you:

  1. url — what's actually registered right now. Empty string = no webhook = polling mode possible. Wrong host = something hijacked or a stale registration.
  2. pending_update_count — mail Telegram is holding that you haven't 200-ed. Nonzero and growing = your endpoint is failing (lesson 4's silent-outage scenario).
  3. last_error_message / last_error_date — Telegram's own note about the most recent failed delivery ("connection refused", "wrong response…"). The first place to look when the bot "just stopped answering".
Hands-on · do this now

Open two browser tabs with getWebhookInfo — one with your prod token, one with your dev token (both are in .env). Predict before you look: what should url be in each? You should see your production URL + secret in one, and an empty url in the other — the two delivery modes of lessons 1 and 5, live. If prod's pending_update_count is nonzero, tell your teacher — that's a real finding. (Treat these URLs as secrets: they contain the token — don't paste them anywhere public.)

The operator's playbook

  1. First production deploy: deploy, then npm run set-webhook once by hand. Verify with getWebhookInfo.
  2. Secret rotation (leak, or hygiene): change TELEGRAM_WEBHOOK_SECRET in the env, redeploy (or run the script). One move does both halves: the route now 404s the old path, and Telegram now POSTs the new one.
  3. Bot "stopped answering": getWebhookInfo first — last_error_message and pending_update_count usually name the culprit before you open a single log.
  4. Taking a bot back to polling (e.g. moving off serverless): deleteWebhook, optionally with drop_pending_updates: true if the held backlog would replay confusingly. Only then does getUpdates stop 409-ing.

Check the handshake

1 · A teammate opens a preview deploy of a bot that lacks the VERCEL_ENV gate. Production goes quiet. What exactly happened, and where's the proof?

2 · You accidentally start npm run bot:dev with the PROD token in TELEGRAM_DEV_BOT_TOKEN while production's webhook is registered. What happens?

3 · Fresh project, first-ever production deploy just finished. Why can't you simply wait for the auto-register plugin to do its job?

Questions the lesson provokes — should your next bot use secret_token instead of the path? what does drop_pending_updates lose exactly? — ask your teacher (me) directly, any time.