Lesson 4 · Telegram Bots on Serverless

One Tap, End to End: The Life of a Webhook Request

You know the entities (lesson 3) and where their state lives (lesson 2). Now follow a single ▶ tap through every hop it makes — because two facts about that journey (replies travel separately, and failures get retried) quietly dictate half the defensive code in this repo, and will dictate it in every bot you build.

Warm-up (the question left open in chat)

Dev bot and prod bot share one Firestore project. Both would write telegramLinks/{yourUserId} — same key, since private chat ids equal your user id across all bots. When is that sharing fine, and when does it bite?

The journey, hop by hop

You tap ▶ on the teach card. Here is everything that happens, in order:

  1. Your phone → Telegram. The tap goes to Telegram's servers, which create a callback query, wrap it in an update, and put it on your bot's queue.
  2. Telegram → your URL. Because production registered a webhook, Telegram immediately makes an HTTPS POST to …/api/telegram/webhook/<secret> with that one update as the JSON body (Marvin's guide).
  3. Vercel finds a body to run. A warm instance if one exists — module scope intact, handler already built — otherwise a cold start: new instance, module code runs from scratch, handler is null again, firebase-admin re-initializes. Cold adds latency; it changes nothing else (lesson 2: everything authoritative is in Firestore precisely so this moment is boring).
  4. The transport gate. Nitro routes to the webhook file; the unguessable path segment is checked first — wrong secret, 404, nobody home.
  5. grammY opens the envelope. webhookCallback parses the update, sees a callback query with data teach:next, and routes to your bot.callbackQuery(TEACH_NEXT, …) handler.
  6. Your handler works. Firestore: read the telegramLink (who is this chat?), read sessions/teach-{messageId} (which card, what cursor?), write the moved cursor back.
  7. Outbound calls — separate connections. editMessageText and answerCallbackQuery are their own HTTPS requests from your function to api.telegram.org, happening mid-handler. They are not part of the webhook exchange at all.
  8. The receipt. Your handler finishes; the route returns 200 OK with an empty body. That 200 means exactly one thing to Telegram: “delivered — take it off the queue.”
phone ──tap──► Telegram servers ──(update queued)──┐ │ ① inbound: one POST ▼ Vercel ── cold? build handler ── secret gate ── grammY ── your handler │ │ │ ② outbound: separate HTTPS calls │ ▼ │ api.telegram.org │ (editMessageText, │ answerCallbackQuery) ▼ ③ 200 OK (empty) ──► Telegram: "delivered, dequeue it"
The fact people get wrong: the webhook's 200 response carries no reply text. ctx.reply(…) does not “answer the request” — it opens a brand-new HTTPS call to Telegram's API while the inbound request is still being handled. Inbound and outbound are two independent channels that happen to be active at the same moment. (grammY's docs on the two deployment types draw the same picture.)

What happens when you're slow — or you crash

Telegram treats the webhook like any unreliable postal address: if the POST times out or comes back non-200, the update is not lost — it's redelivered, with growing pauses, until your bot finally 200s it (or it expires after a long while). You can see the evidence at any time via getWebhookInfo: pending_update_count and last_error_message are Telegram telling you "I'm holding mail you haven't accepted."

Sounds friendly — nothing is dropped! But look at it from your handler's side. A crash after step 6's Firestore write but before step 8's 200 means Telegram sends the same tap again — and your handler runs a second time against state it already mutated. Redelivery turns every webhook handler into code that must tolerate running twice.

Idempotency: this repo's answer, everywhere you look

That's the "why" behind defensive lines you may have already skimmed past. Three examples, all real:

server/telegram/practice.ts
// …anything that doesn't match the authoritative state — a
// stale message, a replayed tap, an out-of-turn gap — is a safe no-op.

A redelivered ans:2:1:0 finds gap 1 already filled in the PracticeDay and does nothing. The tap is validated against stored truth, not trusted on arrival — lesson 2's authoritative state doing double duty as a replay shield.

server/domain/pipeline.ts
// The delete precondition makes a double-fired completion
// (duplicate webhook delivery) fail outright instead of
// writing Results twice.

The nastiest case — the day-completing tap delivered twice — is answered at the database level: the completion batch requires the PracticeDay doc to still exist to delete it, so the second run fails cleanly instead of double-writing your Results. And the 🔁 retry button re-runs the whole tick safely for the same reason: the tick's gate (one per UTC day, must-be-completed) makes a duplicate attempt a polite refusal, not a double pipeline.

The design rule to carry into your next bot: never make a webhook handler's correctness depend on running exactly once. Validate every incoming event against authoritative state, and guard "exactly-once" writes with a database-level precondition. If a handler is safe to run twice, Telegram's redelivery becomes a free reliability feature instead of a bug source.

Why speed matters (and this repo's one slow spot)

Two clocks are ticking while your handler runs: Telegram's patience with the POST, and your platform's function timeout (generous on Vercel — minutes — so Telegram's is the one that binds). The practical rule from Telegram's webhook guide: answer promptly; do heavy work elsewhere. Most of this bot's handlers are a few Firestore reads and one edit — comfortably fast. The exception is instructive: the 🔁 retry button runs the entire tick inside a callback handler — several LLM calls, easily many seconds. If that ever exceeds Telegram's patience, the update gets redelivered mid-tick… and the answer to "isn't that a disaster?" is the same gate again: the second delivery hits already-ticked-today (or the in-flight day's lock conditions) and no-ops. The architecture doesn't try to be fast there — it arranges to be safe while slow.

Check the journey

1 · Your handler calls ctx.reply('✓ added Hund'). Where does that text physically travel?

2 · A handler writes "gap 1 = katze" to Firestore, then crashes before returning 200. What does Telegram do, and what must your code guarantee?

3 · Production suddenly answers every webhook POST with 404 (say, the secret env var was wiped on a redeploy). What do users experience?

Want to go deeper on any hop — what getWebhookInfo shows for YOUR bot right now, how grammY decides when the handler is "done", or what Vercel's timeout budget really is? Ask your teacher (me) directly, any time.