HuwiDevelopers

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.updatesWebhook
Quién pideTu programa le pregunta a Huwi.Huwi llama a tu servidor.
Ideal paraScripts, pruebas, tu computadora.Servidores y funciones que siempre están encendidos.
EsperaHasta 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.setWebhook devuelve { "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 (o bot.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:

typeCuándopayload
messageAlguien le escribe al bot (o, en un grupo, le habla).userId, text, messageId y command si empieza con /.
actionAlguien toca un botón.userId, action, token, messageId, data.
stoppedLa 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…c9
  • t son los segundos (hora Unix) en que Huwi firmó.
  • v1 es el HMAC-SHA256 en hexadecimal, con tu whsec_... 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 id para 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:

IntentoEspera
11 minuto
25 minutos
330 minutos
42 horas
5 y siguientes6 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.

En esta página