Cómo añadir a tu web un chat que te llega a Telegram
October 07, 2026 · 11 min read · Angel LM

En mi portfolio, el botón Let's talk abre un chat. Primero pide nombre y email, ofrece unas opciones guiadas y, si la persona quiere contratarme o tiene una propuesta, le envía un enlace para confirmar su email. Al confirmarlo, la conversación me llega a Telegram, contesto desde el móvil y mi respuesta aparece en el chat de la web, solo para esa persona.
En este post te cuento cómo está construido, con el código esencial, los servicios que usa y los errores que me encontré por el camino.
Qué vamos a construir
El flujo completo es este:
- El visitante abre el chat y escribe su nombre y su email.
- Elige una opción. Las informativas (tarifas, proyectos) se responden ahí mismo, sin molestarte.
- Si elige una que requiere hablar contigo, recibe un correo con un enlace de un solo uso.
- Al abrir el enlace vuelve a la web, el chat confirma el email y solo entonces se abre la conversación: te llega a Telegram una ficha con su nombre, email y motivo.
- Cada mensaje que escribe te llega a Telegram. Tú respondes (reply) a su mensaje y la respuesta aparece en su chat en segundos.
Piezas:
| Pieza | Para qué |
|---|---|
| Widget de chat (client component) | La interfaz, el flujo guiado y el polling de respuestas |
| API routes de Next.js | Pedir la verificación, verificar, enviar y leer mensajes |
| Bot de Telegram | Te reenvía los mensajes y recibe tus respuestas por webhook |
| Upstash Redis | Guarda sesiones, mensajes y qué mensaje de Telegram es de quién |
| Resend | Envía el correo de verificación |
La idea clave: cada mensaje de Telegram sabe de quién es
En Vercel las funciones no comparten memoria, así que todo el estado vive en Redis. El truco que hace que las respuestas lleguen a la persona correcta es simple:
- Cada conversación tiene un
sidaleatorio (crypto.randomUUID()) que solo conoce el navegador del visitante. - Cada vez que el servidor te envía algo a Telegram, guarda
message_id → sid. - Cuando respondes a un mensaje, Telegram te dice a qué
message_idrespondiste (reply_to_message). Con eso se recupera elsidy la respuesta va solo a esa conversación.
No hace falta un bot por visitante ni chats de grupo: un solo chat privado contigo y los "reply" hacen de enrutador.
Prepara los servicios
1. El bot. Habla con @BotFather, usa /newbot y guarda el token.
2. Tu chat id. Abre tu bot y pulsa Start (importante, lo explico al final). Luego visita https://api.telegram.org/bot<TOKEN>/getUpdates y copia message.chat.id.
3. Redis. En Vercel: Storage → Upstash Redis → conectar al proyecto. Crea las variables KV_REST_API_URL y KV_REST_API_TOKEN automáticamente.
4. Resend. Añade tu dominio, crea los registros DNS que te indique (si usas Cloudflare, en modo "DNS only") y genera una API key.
TELEGRAM_BOT_TOKEN=123456:ABC...
TELEGRAM_CHAT_ID=123456789
TELEGRAM_WEBHOOK_SECRET=una-cadena-larga-y-aleatoria
KV_REST_API_URL=https://xxx.upstash.io
KV_REST_API_TOKEN=...
RESEND_API_KEY=re_...Para el secreto del webhook basta con openssl rand -hex 32. Solo admite letras, números, _ y -.
Enviar a Telegram y recordar de quién es
pnpm add @upstash/redis server-onlyToda la lógica de servidor vive en un único módulo. Redis.fromEnv() lee tanto UPSTASH_REDIS_REST_URL como KV_REST_API_URL:
import 'server-only'
import { Redis } from '@upstash/redis'
const redis = Redis.fromEnv()
const TTL = 60 * 60 * 24 * 30 // 30 días
/** Texto plano (sin parse_mode): no hay nada que escapar. Devuelve el message_id. */
export async function sendTelegram(text: string): Promise<number> {
const res = await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: process.env.TELEGRAM_CHAT_ID, text }),
})
const data = await res.json()
if (!data.ok) throw new Error(`Telegram: ${data.description}`)
return data.result.message_id
}
const linkTelegram = (messageId: number, sid: string) => redis.set(`chat:tg:${messageId}`, sid, { ex: TTL })
export async function createSession(data: { name: string; email: string; topic: string }) {
const sid = crypto.randomUUID()
await redis.set(`chat:${sid}`, { ...data, createdAt: Date.now() }, { ex: TTL })
const messageId = await sendTelegram(
`🆕 New chat #${sid.slice(0, 6)}\n${data.topic}\n\n👤 ${data.name}\n✉️ ${data.email}\n\n↩️ Reply to this message to answer.`
)
await linkTelegram(messageId, sid)
return sid
}
export async function addUserMessage(sid: string, name: string, text: string) {
await redis.rpush(`chat:${sid}:msgs`, { from: 'user', text, at: Date.now() })
const messageId = await sendTelegram(`💬 #${sid.slice(0, 6)} · ${name}\n\n${text}`)
await linkTelegram(messageId, sid)
}Los mensajes de cada conversación son una lista en Redis (chat:<sid>:msgs). El widget pide los nuevos con LRANGE a partir del último índice que ya vio.
Verificar el email antes de abrir el chat
Sin verificación, cualquiera podría escribirte haciéndose pasar por otra persona. La solución es un enlace mágico:
const sha256 = async (v: string) =>
Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(v))).toString('hex')
// Dominio fijo: nunca el Host de la petición.
const SITE = process.env.SITE_URL ?? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
export async function requestVerification(data: { name: string; email: string; topic: string }) {
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
await redis.set(`chat:verify:${await sha256(token)}`, data, { ex: 60 * 30 }) // 30 min
await sendEmail(data.email, `${SITE}/?chat_token=${token}`)
}
export async function verifyAndCreateSession(token: string) {
// GETDEL: el token solo sirve una vez
const data = await redis.getdel<{ name: string; email: string; topic: string }>(`chat:verify:${await sha256(token)}`)
if (!data) return null
return { sid: await createSession(data), ...data }
}Tres detalles que importan:
- Solo se guarda el hash del token. Si alguien lee tu Redis, no puede usar los enlaces pendientes.
GETDELlo consume de forma atómica, así que el enlace no se puede reutilizar.- El dominio del enlace es fijo. Si lo construyeras con el
Hostde la petición, un atacante podría pedir la verificación del email de otra persona con unHostfalso, y la víctima recibiría un enlace a otro dominio con un token válido.
Enviar el correo con Resend es una sola llamada fetch, sin SDK:
async function sendEmail(to: string, link: string) {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'Tu Nombre <chat@tudominio.com>',
to,
subject: 'Confirm your email to chat with me',
text: `Confirm your email to start the chat:\n${link}\n\nThe link expires in 30 minutes.`,
}),
})
if (!res.ok) throw new Error(`Resend: ${res.status} ${await res.text()}`)
}El sid solo se crea al verificar. Como el cliente no tiene forma de obtener un sid sin pasar por el enlace, la verificación no se puede saltar desde el navegador.
El webhook: tus respuestas vuelven solo a ese visitante
import { NextResponse } from 'next/server'
import { addOwnerReply, sendTelegram } from '@/lib/chat'
export async function POST(req: Request) {
// Telegram envía el secreto que registraste en setWebhook
if (req.headers.get('x-telegram-bot-api-secret-token') !== process.env.TELEGRAM_WEBHOOK_SECRET) {
return NextResponse.json({ ok: false }, { status: 401 })
}
const { message } = await req.json()
// Solo mensajes de tu chat privado con el bot
if (!message?.text || String(message.chat.id) !== process.env.TELEGRAM_CHAT_ID) {
return NextResponse.json({ ok: true })
}
const target = message.reply_to_message?.message_id
const delivered = target ? await addOwnerReply(target, message.message_id, message.text) : false
if (!delivered) await sendTelegram('⚠️ Not delivered. Reply to a visitor message so I know who it is for.')
return NextResponse.json({ ok: true }) // 200 siempre: si no, Telegram reintenta
}Y addOwnerReply busca el sid y deja la respuesta en esa conversación:
export async function addOwnerReply(replyTo: number, ownMessageId: number, text: string) {
const sid = await redis.get<string>(`chat:tg:${replyTo}`)
if (!sid) return false
await redis.rpush(`chat:${sid}:msgs`, { from: 'angel', text, at: Date.now() })
await linkTelegram(ownMessageId, sid) // también puedes responder a tus propios mensajes del hilo
return true
}Tras desplegar, registra el webhook una sola vez:
curl "https://api.telegram.org/bot<TOKEN>/setWebhook" -d "url=https://tudominio.com/api/telegram/webhook" -d "secret_token=<TELEGRAM_WEBHOOK_SECRET>" -d 'allowed_updates=["message"]'Puedes comprobarlo con getWebhookInfo: debe mostrar tu URL, pending_update_count: 0 y ningún error.
El widget
El chat es un client component montado en el layout raíz. Algunas decisiones que simplifican mucho:
- Se abre con un evento. El botón del navbar llama a
window.dispatchEvent(new Event('open-chat')), así que no hace falta un contexto global. - La conversación vive en
localStorage: sobrevive a recargas y, con el eventostorage, se sincroniza entre pestañas. Por ejemplo, cuando el enlace del email se abre en otra pestaña. - Polling, no WebSockets. Cada 4 segundos con el chat abierto, cada 20 con el chat cerrado y nada si la pestaña está oculta. Para un chat de contacto es más que suficiente y funciona en serverless sin infraestructura extra.
- La vuelta del enlace se detecta al montar: si la URL trae
?chat_token=, se quita de la URL conhistory.replaceState(es de un solo uso), se llama a/api/chat/verifyy se abre el chat ya conectado.
useEffect(() => {
if (!sid) return
const poll = async () => {
if (document.hidden) return
const res = await fetch(`/api/chat/messages?sid=${sid}&after=${serverCount}`, { cache: 'no-store' })
if (!res.ok) return
const { messages } = await res.json()
// añade las respuestas nuevas y avanza el índice
}
const id = setInterval(poll, open ? 4000 : 20000)
poll()
return () => clearInterval(id)
}, [sid, serverCount, open])Un detalle que me mordió: si cargas el estado desde localStorage en un useEffect y lo guardas en otro, el primer guardado puede pisar lo guardado con el estado inicial. Solución: no guardar mientras el estado sea la referencia exacta del estado inicial (if (state === INITIAL) return).
Seguridad y límites
- El
sides un UUID aleatorio: es el "secreto" de cada conversación y nunca se muestra. - El webhook rechaza peticiones sin el
secret_tokeny mensajes que no sean de tu chat. - Rate limiting con
INCR+EXPIREen Redis: 5 solicitudes por hora por IP, 3 por email, 40 mensajes por conversación y 20 verificaciones por IP. - Validación en servidor de longitudes y formato de email.
- Mensajes a Telegram en texto plano, sin
parse_mode: el texto del visitante no puede inyectar formato. - Todo caduca a los 30 días en Redis.
- Si falta configuración, el chat no se rompe: muestra un aviso "offline" con tu email.
Errores que me encontré (para que no te pasen)
Bad Request: chat not found. Un bot no puede escribirte primero. Si nunca le diste a Start en tu bot, cualquier sendMessage a tu chat id falla aunque el id sea correcto.
Las respuestas no vuelven a la web. El webhook solo funciona con una URL pública: Telegram no puede llamar a tu localhost. Si tu entorno local y producción comparten la misma base de Redis, tus respuestas desde Telegram (que entran por producción) aparecen también en el chat local.
getaddrinfo ENOTFOUND xxx.upstash.io". Fíjate en la comilla al final del host: la variable tenía una " de más en el .env. Revisa que los valores no lleven comillas sueltas.
Configuré las variables y sigue diciendo que faltan. En Vercel las variables se aplican al desplegar: hay que hacer redeploy. Y comprueba el entorno: los previews no ven las variables que solo marcaste para Production.
El indicador de diagnóstico. Para depurar esto, la API responde 503 con { "missing": ["TELEGRAM_BOT_TOKEN"] }: solo los nombres de las variables que faltan, nunca los valores. Me ahorró adivinar.
Conclusiones
Con Telegram, Upstash y Resend, cada uno con su plan gratuito, tienes un chat de contacto que te llega al móvil, verifica a quien te escribe y no necesita ni base de datos propia ni servidor de WebSockets. La pieza que lo hace funcionar es pequeña: guardar message_id → sid y usar los reply de Telegram como enrutador.
Pruébalo en el botón Let's talk de esta web.
Sin escribir todo esto: Holagram
Empaqueté todo este flujo en una librería, Holagram, y el chat de esta web ya funciona con ella. Creas el bot, pones las variables de entorno y añades dos piezas:
pnpm add @angelitolm/holagramimport { createHolagram } from '@angelitolm/holagram/server'
export const { GET, POST } = createHolagram({ ownerName: 'Ana' })import { Holagram } from '@angelitolm/holagram'
// dentro de <body>, después de {children}
<Holagram owner={{ name: 'Ana', status: 'Suelo responder en unas horas' }} />Trae la verificación por email, el enrutado por reply, los límites de uso, textos en español y una CLI para el bot (npx holagram chat-id, npx holagram webhook https://tu-dominio.com). Puedes probarla sin instalar nada en la demo en vivo, y el código está en GitHub.
Imagen: Shyam Mishra en Unsplash.
Written by
Angel LM
Full-stack developer passionate about creating beautiful and functional web applications. Sharing knowledge about React, Next.js, and modern web development.
Share