Skip to Content
Telegram MCPComponentsHttp TelegramWebhook

src/http/telegramWebhook.ts — Telegram webhook route

Mounts POST /telegram/webhook with constant-time secret validation and a dependency-injected event handler.

Public surface

ExportShapePurpose
registerTelegramWebhook(app, deps)(FastifyInstance, TelegramWebhookDeps) => voidMounts the route
TelegramWebhookDeps{ secret, onEvent }DI seam

secret is the value passed to Telegram’s setWebhook as secret_token; Telegram echoes it back on every delivery via X-Telegram-Bot-Api-Secret-Token. onEvent is invoked once per normalized event (see normalize).

Request lifecycle

  1. Extract X-Telegram-Bot-Api-Secret-Token header.
    • Missing → log warn (reason: missing-secret) → 401.
    • Mismatch → log warn (reason: invalid-secret) → 401.
  2. SHA-256 both presented and expected secrets, compare with timingSafeEqual. Hashing makes the comparison constant-cost regardless of header length and never compares raw secret bytes against attacker-controlled bytes.
  3. Run normalize(body). If null, ack with 200 (Telegram never re-delivers a kind we won’t ever handle).
  4. Otherwise call onEvent(event). Errors are caught and logged, not re-thrown — Telegram redelivers on non-2xx and we don’t want a deterministic downstream bug to put us in a retry storm.
  5. Return {ok: true} with 200.

Why “always 200”

Telegram retries on any non-2xx. For Phase 1 the right default is “log and move on” because a deterministic bug would otherwise loop forever. When async jobs land in Phase 5, transient failures will get queued for retry through Inngest rather than relying on Telegram’s redelivery.

Tests

test/unit/webhook.test.ts:

  • missing header → 401, onEvent not called
  • mismatched secret → 401 + warn log
  • correct secret → 200 + onEvent called with the full normalized envelope
  • ignored update kind (e.g. edited_message) → 200 + onEvent not called
  • handler throws → still 200 + error log