Desarrollo de bots
Webhooks
Cómo hacer que, en lugar de usar getUpdates, el servidor envíe las novedades directamente a la dirección HTTPS de tu bot: configuración, verificación, reintentos y respuesta dentro de la propia respuesta.
getUpdates es un modo en el que el bot pregunta al servidor una y otra vez. Un webhook funciona al revés: cuando hay novedades, el servidor Pabal las envía directamente a la dirección HTTPS del bot. Si tu bot funciona en un servidor que está siempre encendido (en la nube o en la propia empresa), el webhook es más adecuado.
| getUpdates (long polling) | Webhook | |
|---|---|---|
| Quién da el primer paso | El bot pregunta al servidor | El servidor envía al bot |
| Qué necesita el bot | Solo acceso saliente a internet | Una dirección HTTPS pública |
| Adecuado para | Tu ordenador, desarrollo, detrás de un cortafuegos | Servidores siempre encendidos, serverless, muchos bots |
| Usar ambos a la vez | No se puede: si hay un webhook configurado, getUpdates devuelve 409 Conflict | |
Así funciona un webhook
Explicación del diagrama
- Dos columnas: a la izquierda, el servidor Pabal; a la derecha, la dirección de webhook que operas tú. La línea continua azul son las peticiones que envía el servidor; la gris, las respuestas normales del webhook; la discontinua roja, los fallos.
- Una respuesta 2xx significa «recibido» (②). Hasta entonces, la actualización se queda en el servidor. El cuerpo puede ir vacío.
- Si falla, se reenvía la misma actualización (④ → ⑤): una respuesta que no sea 2xx, un fallo de conexión o la falta de respuesta en 30 segundos cuentan como fallo, y el intervalo empieza en 1 segundo y se duplica hasta un máximo de 60 segundos. El motivo y la hora del fallo quedan en
getWebhookInfo. - Se respeta el orden: las actualizaciones de un bot se envían de una en una, así que la 7 espera hasta que la 6 tiene éxito. Por eso, si el webhook falla durante mucho tiempo, las actualizaciones siguientes se acumulan (
pending_update_count). - Puedes responder dentro de la respuesta (⑥): si en el cuerpo de la respuesta 200 incluyes
methody sus parámetros, el servidor ejecuta ese método en nombre del bot. Es más rápido, porque no hace falta enviar otra petición.
Requisitos de la dirección del webhook
- Tiene que ser una dirección https://. El certificado debe proceder de una autoridad de certificación pública (Let's Encrypt, por ejemplo); no se admite subir un certificado autofirmado (
certificate). - Tiene que ser una dirección pública accesible desde internet. El servidor no envía a direcciones internas como loopback (
127.0.0.1,::1), redes privadas (10.,172.16–31.,192.168.), link-local (169.254., metadatos de la nube) o CGNAT (100.64/10). También rechaza los dominios que apuntan a esas direcciones, y lo vuelve a comprobar en cada envío. - No hay restricción de puerto (no tiene por qué ser el 443). La dirección no puede incluir usuario ni contraseña (
https://user:pw@…). - Las redirecciones (3xx) no se siguen y cuentan como fallo. Indica la dirección final.
- Hay que responder en menos de 30 segundos. Si una tarea tarda, responde primero con un 200 y procésala después en segundo plano.
Configuración
- Pon en marcha el programa que recibirá el webhook. Supongamos que lanzas uno de los ejemplos de más abajo en el servidor del bot, en
127.0.0.1:8081. - Coloca HTTPS delante. Por ejemplo, con Caddy bastan estas dos líneas, y el certificado se gestiona automáticamente.
bot.example.com { reverse_proxy 127.0.0.1:8081 } - Crea un token secreto. De 1 a 256 caracteres entre letras latinas, números,
_y-. El servidor envía este valor en una cabecera con cada petición, así que puedes comprobar que la petición viene de verdad del servidor Pabal.export WEBHOOK_SECRET=$(openssl rand -hex 32) - Llama a setWebhook.
curl -s "https://pabal.me/bot$BOT_TOKEN/setWebhook" -H 'Content-Type: application/json' -d "{ \"url\": \"https://bot.example.com/pabal-webhook\", \"secret_token\": \"$WEBHOOK_SECRET\", \"allowed_updates\": [\"message\", \"callback_query\"], \"drop_pending_updates\": true }" # {"ok":true,"result":true} - Comprueba el estado. Envía un mensaje al bot desde la aplicación y consulta
getWebhookInfo.curl -s "https://pabal.me/bot$BOT_TOKEN/getWebhookInfo"{ "ok": true, "result": { "url": "https://bot.example.com/pabal-webhook", "has_custom_certificate": false, "pending_update_count": 0, "ip_address": "198.51.100.7", "max_connections": 40, "allowed_updates": ["message", "callback_query"] } }Si
pending_update_countes 0, está recibiendo bien.last_error_dateylast_error_messageson el registro del último fallo y se mantienen incluso después de recuperarse: fíjate en la hora para valorarlos.
Parámetros de setWebhook
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
url | String | Sí | Dirección del webhook. Una cadena vacía elimina el webhook (igual que deleteWebhook). |
secret_token | String | Opcional | De 1 a 256 caracteres, A-Z a-z 0-9 _ -. Se envía con cada petición en la cabecera X-Telegram-Bot-Api-Secret-Token. |
allowed_updates | Array of String | Opcional | Tipos que quieres recibir: message, edited_message, callback_query. Si lo dejas vacío, todos. Los tipos que no estén incluidos no se envían y se descartan. |
drop_pending_updates | Boolean | Opcional | Con true, se descartan todas las actualizaciones aún no entregadas antes de empezar. |
max_connections | Integer | Opcional | De 1 a 100, 40 por defecto. Se acepta y se guarda, pero Pabal envía de una en una por bot para respetar el orden. |
certificate | InputFile | No admitido | No se aceptan certificados autofirmados (400). Usa un certificado público. |
ip_address | String | Se ignora | Se acepta pero no se usa. El servidor resuelve el dominio en cada momento. |
La configuración del webhook se guarda en el servidor, así que se mantiene aunque el servidor se reinicie, y el envío se reanuda en cuanto arranca. Eso sí, las actualizaciones aún no entregadas están en memoria y se pierden al reiniciar.
La petición que envía el servidor
POST /pabal-webhook HTTP/1.1
Host: bot.example.com
Content-Type: application/json
X-Telegram-Bot-Api-Secret-Token: 3f1c…(el valor que pasaste a setWebhook)
{"update_id":12,"message":{"message_id":3,"from":{"id":100001,"is_bot":false,"first_name":"Ana"},"chat":{"id":100001,"first_name":"Ana","type":"private"},"date":1789805661,"text":"Hola"}}
- El cuerpo es un objeto Update, idéntico a un elemento del resultado de
getUpdates. - Comprueba siempre el token secreto. Si falta o no coincide, rechaza la petición con un 401. Compara con una función de tiempo constante (
hmac.compare_digest,crypto.timingSafeEqual). - La misma actualización puede llegar dos veces (si la conexión se corta antes de que tu respuesta llegue al servidor). Es seguro saltarse, por su
update_id, las que ya hayas procesado.
Responder dentro de la respuesta
Si incluyes una llamada a la Bot API en el cuerpo de la respuesta 200, el servidor la ejecuta en nombre del bot. Pon el nombre del método en method y añade el resto de los parámetros.
{"method": "sendMessage", "chat_id": 100001, "text": "¡Hola!"}
- El cuerpo puede ir en JSON, como formulario o como
multipart/form-data(aiogram lo envía como multipart). El tamaño máximo es de 1 MB. - El resultado o el error de esta llamada no se devuelven al bot (solo quedan en el log del servidor). Si necesitas el resultado, haz la petición aparte como de costumbre.
- Si no tienes nada que responder, basta con un 200 sin cuerpo.
Ejemplos
Los cuatro ejemplos comprueban el token secreto y, al recibir un mensaje de texto, lo repiten incluyendo un sendMessage en la respuesta. Se supone que hay HTTPS delante (Caddy, por ejemplo) y que reciben en 127.0.0.1:8081.
# webhook.py — solo con la biblioteca estándar
# Ejecución: WEBHOOK_SECRET='…' python3 webhook.py
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["WEBHOOK_SECRET"]
class Webhook(BaseHTTPRequestHandler):
def do_POST(self):
got = self.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
if not hmac.compare_digest(got, SECRET):
self.send_response(401)
self.end_headers()
return
update = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
message = update.get("message")
answer = b""
if message and "text" in message:
answer = json.dumps({"method": "sendMessage",
"chat_id": message["chat"]["id"],
"text": message["text"]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(answer)))
self.end_headers()
self.wfile.write(answer)
HTTPServer(("127.0.0.1", 8081), Webhook).serve_forever()// webhook.mjs — Node.js 18 o posterior, sin bibliotecas
// Ejecución: WEBHOOK_SECRET='…' node webhook.mjs
import http from 'node:http';
import { timingSafeEqual } from 'node:crypto';
const SECRET = Buffer.from(process.env.WEBHOOK_SECRET);
http.createServer(async (req, res) => {
const got = Buffer.from(req.headers['x-telegram-bot-api-secret-token'] ?? '');
if (req.method !== 'POST' || got.length !== SECRET.length || !timingSafeEqual(got, SECRET)) {
res.writeHead(401).end();
return;
}
let body = '';
for await (const chunk of req) body += chunk;
const message = JSON.parse(body).message;
if (message?.text) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ method: 'sendMessage', chat_id: message.chat.id, text: message.text }));
} else {
res.writeHead(200).end();
}
}).listen(8081, '127.0.0.1');# webhook_aiogram.py — pip install aiogram
# Este programa también configura el webhook (setWebhook)
import os
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiohttp import web
URL = "https://bot.example.com/pabal-webhook"
SECRET = os.environ["WEBHOOK_SECRET"]
dp = Dispatcher()
@dp.message()
async def echo(message):
if message.text:
return message.answer(message.text) # return: se envía dentro de la respuesta del webhook
async def on_startup(bot: Bot):
await bot.set_webhook(URL, secret_token=SECRET)
def main():
session = AiohttpSession(api=TelegramAPIServer.from_base("https://pabal.me"))
bot = Bot(os.environ["BOT_TOKEN"], session=session)
dp.startup.register(on_startup)
app = web.Application()
SimpleRequestHandler(dispatcher=dp, bot=bot, secret_token=SECRET).register(app, path="/pabal-webhook")
setup_application(app, dp, bot=bot)
web.run_app(app, host="127.0.0.1", port=8081)
main()# webhook_ptb.py — pip install "python-telegram-bot[webhooks]"
# Este programa también configura el webhook (setWebhook)
import os
from telegram import Update
from telegram.ext import Application, ContextTypes, MessageHandler, filters
SERVER = "https://pabal.me"
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(update.message.text)
app = (Application.builder().token(os.environ["BOT_TOKEN"])
.base_url(f"{SERVER}/bot").base_file_url(f"{SERVER}/file/bot").build())
app.add_handler(MessageHandler(filters.TEXT, echo))
app.run_webhook(listen="127.0.0.1", port=8081, url_path="pabal-webhook",
webhook_url="https://bot.example.com/pabal-webhook",
secret_token=os.environ["WEBHOOK_SECRET"])Ver el estado: getWebhookInfo
| Campo | Significado |
|---|---|
url | La dirección configurada. Si no hay webhook, una cadena vacía |
pending_update_count | Número de actualizaciones que esperan a ser entregadas |
ip_address | La IP de la última dirección a la que se envió |
last_error_date, last_error_message | La hora (segundos Unix) y el motivo del último fallo. No aparecen si nunca ha fallado. Se mantienen después de recuperarse |
max_connections, allowed_updates | Los valores que pasaste a setWebhook |
has_custom_certificate | Siempre false |
Mensajes que aparecen en last_error_message
| Mensaje | Causa |
|---|---|
Connection refused | Nadie escucha en esa dirección y puerto. El programa del webhook o el proxy están apagados |
Connection timed out | Un cortafuegos lo bloquea o la dirección no es alcanzable |
Read timeout expired | No respondió en 30 segundos |
Failed to resolve host: Name or service not known | No se encuentra el nombre de dominio (DNS) |
SSL error {…} | Problema con el certificado: caducado, autofirmado o con un nombre que no coincide |
Wrong response from the webhook: 502 Bad Gateway | Una respuesta que no es 2xx. El número es el código de estado que devolvió el webhook |
IP address 10.0.0.5 is reserved | El dominio apunta a una dirección interna (no se envía) |
Volver a getUpdates
curl -s "https://pabal.me/bot$BOT_TOKEN/deleteWebhook"
# Para descartar también las actualizaciones pendientes: deleteWebhook?drop_pending_updates=true
Las actualizaciones que el webhook ya recibió (respondiendo con 2xx) no vuelven a llegar por getUpdates. Por getUpdates solo sigues recibiendo las que aún no se habían entregado.
Probar durante el desarrollo
- Túnel: con una herramienta que da una dirección HTTPS pública a
127.0.0.1:8081de tu ordenador (cloudflared, ngrok, etc.) también puedes probar contra el servidor Pabal de producción. - Tu propio servidor Pabal: si has levantado tu propio servidor Pabal de desarrollo, actívalo con
TELEGRAM_WEBHOOK_ALLOW_LOCAL=true. Ese servidor también enviará a direcciones locales comohttp://127.0.0.1:8081/…. No lo actives nunca en un servidor de producción: cualquier bot podría enviar peticiones a la red interna del servidor (base de datos, metadatos de la nube).
Lista de comprobación para producción
- Has definido un token secreto y lo compruebas en todas las peticiones.
- Respondes en menos de 30 segundos: las tareas largas pasan a una cola y respondes enseguida con un 200.
- Filtras los duplicados por
update_id. - Si el webhook se detiene mucho tiempo, las actualizaciones siguientes se acumulan y se pierden al reiniciar el servidor: vigila el programa del webhook (
pending_update_countylast_error_datedegetWebhookInfo). - Si el token se filtra, usa
/revokey vuelve a llamar asetWebhookcon el token nuevo. Cambia también el token secreto.