src/http/telegramWebhook.ts — Telegram webhook route
Mounts POST /telegram/webhook with constant-time secret validation and a
dependency-injected event handler.
Public surface
| Export | Shape | Purpose |
|---|---|---|
registerTelegramWebhook(app, deps) | (FastifyInstance, TelegramWebhookDeps) => void | Mounts 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
- Extract
X-Telegram-Bot-Api-Secret-Tokenheader.- Missing → log warn (
reason: missing-secret) → 401. - Mismatch → log warn (
reason: invalid-secret) → 401.
- Missing → log warn (
- 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. - Run
normalize(body). Ifnull, ack with 200 (Telegram never re-delivers a kind we won’t ever handle). - 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. - 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
- missing header → 401,
onEventnot called - mismatched secret → 401 + warn log
- correct secret → 200 +
onEventcalled with the full normalized envelope - ignored update kind (e.g. edited_message) → 200 +
onEventnot called - handler throws → still 200 + error log