Webhooks
Recibe las novedades de tu bot en tu servidor, firmadas con Huwi-Signature. Cómo verificar la firma, qué reintentos hay y cuándo se pausa el bot.
Hay dos formas de recibir las novedades de tu bot:
bot.updates | Webhook | |
|---|---|---|
| Quién pide | Tu programa le pregunta a Huwi. | Huwi llama a tu servidor. |
| Ideal para | Scripts, pruebas, tu computadora. | Servidores y funciones que siempre están encendidos. |
| Espera | Hasta 25 s por pedido. | Inmediato. |
No se usan las dos a la vez. Con un webhook puesto, bot.updates responde BAD_REQUEST con webhookActive. Quita el webhook para volver a pedir las novedades.
Poner el webhook
# Poner el webhook. Devuelve {"secret":"whsec_..."}: se ve una sola vez, guárdalo.
curl -s "https://huwi.app/api/trpc/bot.setWebhook" \
-H "x-api-key: $HUWI_KEY" -H "content-type: application/json" \
-d '{"json":{"url":"https://mi-bot.example.com/huwi"}}'
# Ver si está puesto y desde cuándo falla.
curl -s "https://huwi.app/api/trpc/bot.webhookInfo" -H "x-api-key: $HUWI_KEY"
# Quitarlo y volver a bot.updates.
curl -s -X POST "https://huwi.app/api/trpc/bot.deleteWebhook" -H "x-api-key: $HUWI_KEY"- La dirección tiene que ser https (si no,
BAD_REQUEST url) y de menos de 500 caracteres. bot.setWebhookdevuelve{ "secret": "whsec_..." }. Se ve una sola vez y cambia cada vez que lo llamas. Guárdalo.- También lo puedes poner desde @gepeto, con el botón Webhook del menú del bot.
- Pasar
url: null(obot.deleteWebhook) lo quita.
Para probar en tu computadora
Huwi no manda nada a localhost ni a dominios reservados (.test, .example, .invalid, .localhost): los da por
entregados sin llamar. Usa un túnel (por ejemplo cloudflared tunnel o ngrok) con una dirección https real.
Qué recibes
Un POST con content-type: application/json y este cuerpo, una novedad por pedido y en orden:
{
"id": "107652223732637696",
"type": "message",
"chatId": "107652219945181184",
"payload": { "text": "/start", "userId": "362387869790208", "command": "start", "messageId": "107652223229321216" },
"createdAt": "2026-10-25T01:31:30.218Z"
}Los tipos de novedad (type) son:
type | Cuándo | payload |
|---|---|---|
message | Alguien le escribe al bot (o, en un grupo, le habla). | userId, text, messageId y command si empieza con /. |
action | Alguien toca un botón. | userId, action, token, messageId, data. |
stopped | La persona detuvo el bot. | userId. |
Verifica la firma
Cada pedido trae el encabezado Huwi-Signature, como los webhooks de Stripe:
Huwi-Signature: t=1761356000,v1=5b0f…c9tson los segundos (hora Unix) en que Huwi firmó.v1es el HMAC-SHA256 en hexadecimal, con tuwhsec_...como clave, de este texto:"<t>.<cuerpo>".- El
<cuerpo>es el texto exacto que llegó, sin volver a serializar el JSON. - Rechaza firmas con más de 5 minutos de diferencia, para que nadie repita un pedido viejo.
- Compara con una función de tiempo constante (
timingSafeEqual,hmac.compare_digest).
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
// Receptor de webhooks de Huwi. WEBHOOK_SECRET es el `whsec_...` que devolvió bot.setWebhook.
const SECRET = process.env.WEBHOOK_SECRET!;
// Huwi-Signature: t=<segundos>,v1=<HMAC-SHA256 en hex de "<t>.<cuerpo tal cual llegó>">
function isValid(header: string | undefined, rawBody: string): boolean {
const parts = Object.fromEntries((header ?? "").split(",").map((part) => part.split("=") as [string, string]));
const t = Number(parts.t);
if (!parts.v1 || !Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > 5 * 60) return false; // rechaza firmas de más de 5 minutos
const expected = createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const rawBody = Buffer.concat(chunks).toString("utf8"); // firma el cuerpo EXACTO: no lo vuelvas a serializar
if (!isValid(req.headers["huwi-signature"] as string | undefined, rawBody)) {
res.writeHead(401).end();
return;
}
const update = JSON.parse(rawBody); // { id, type, chatId, payload, createdAt }
console.log("novedad", update.id, update.type, update.payload.text ?? update.payload.action ?? "");
// Contesta rápido (menos de 10 s) con un 2xx: así Huwi la da por entregada.
res.writeHead(200).end("ok");
// El trabajo largo va después de contestar (cola, base de datos, un modelo).
}).listen(Number(process.env.PORT ?? 8080));Estos dos receptores se probaron con firmas hechas por el mismo código que usa Huwi: aceptan una firma buena y rechazan un cuerpo cambiado, un secreto distinto, una firma de hace 6 minutos y un pedido sin firma.
Contesta rápido
- Contesta con un 2xx en menos de 10 segundos. Cualquier otra cosa (un 500, un redirect, un corte) cuenta como fallo; Huwi no sigue redirecciones.
- Haz el trabajo largo después de contestar (una cola, un modelo, una base de datos).
- Tu servidor puede recibir la misma novedad dos veces: usa el
idpara no repetir.
Reintentos y pausa
Una novedad que tu servidor no recibe queda pendiente, y las siguientes esperan detrás para mantener el orden. El plan de reintentos es este:
| Intento | Espera |
|---|---|
| 1 | 1 minuto |
| 2 | 5 minutos |
| 3 | 30 minutos |
| 4 | 2 horas |
| 5 y siguientes | 6 horas |
Si el webhook falla un día seguido, el bot debe quedar en pausa y @gepeto avisarle a su dueño. bot.webhookInfo te dice la dirección puesta y desde cuándo falla (failingSince).
Estado de los reintentos y de la pausa
Hoy Huwi intenta entregar cada novedad cuando llega y guarda failingSince, pero los reintentos automáticos y la pausa
por un día de fallas todavía no están activos. Si tu servidor estuvo caído, quita el webhook (bot.deleteWebhook) y pide
lo pendiente con bot.updates: las novedades sin entregar siguen ahí hasta por un día. Después vuelve a poner el webhook.
Las novedades que no se entregan ni se confirman en un día se borran.