Guía 08

APIs y webhooks para bots: recibir, verificar y procesar eventos con seguridad

Un webhook es una petición HTTP de un desconocido que dice ser una plataforma. Verifícala, confírmala y procésala exactamente una vez.

Guía3 min de lecturaActualizado

Tres formas de recibir eventos

Modelo Cómo Ventajas Inconvenientes
Polling Llamas a GET /events?since=... periódicamente Simple, sin endpoint público Latencia, peticiones desperdiciadas
Webhooks El proveedor hace POST a tu URL Tiempo real, apto para serverless Requiere endpoint HTTPS público y verificación
WebSocket Conexión persistente Mínima latencia, bidireccional Proceso de larga duración, lógica de reconexión

Anatomía de un buen receptor

  1. Lee el cuerpo crudo como bytes antes de interpretar el JSON.
  2. Verifica la firma o la cabecera secreta. Rechaza con 401 si falla.
  3. Comprueba la frescura: si el proveedor envía una marca de tiempo, rechaza lo que tenga más de unos cinco minutos.
  4. Deduplica por el ID del evento.
  5. Encola el evento y devuelve 2xx de inmediato.
  6. Procesa en un worker con reintentos y luego llama a la API de la plataforma para responder.

Verificar firmas

La mayoría de proveedores usa HMAC-SHA256 con un secreto compartido. El detalle crítico es firmar los bytes exactos recibidos: si tu framework interpreta y vuelve a serializar el JSON, cambian los espacios y el orden de las claves y la firma falla.

import express from "express";
import crypto from "node:crypto";

const app = express();
app.post("/webhooks/github", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
  const sig = req.get("X-Hub-Signature-256") || "";
  const mac = "sha256=" + crypto.createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET).update(req.body).digest("hex");
  const a = Buffer.from(sig), b = Buffer.from(mac);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString("utf8"));
  queue.add({ id: req.get("X-GitHub-Delivery"), type: req.get("X-GitHub-Event"), event });
  res.sendStatus(202);
});

Usa una comparación en tiempo constante (timingSafeEqual, hmac.compare_digest) para que nadie pueda adivinar la firma byte a byte midiendo tiempos. Algunas plataformas usan otros esquemas:

  • Slack: HMAC de v0:{timestamp}:{body}.
  • Standard Webhooks: HMAC de {id}.{timestamp}.{body}, en base64 en webhook-signature: v1,....
  • Discord: firma de clave pública Ed25519 sobre timestamp + body.
  • Telegram: un secret_token fijo que eliges tú, enviado en una cabecera.

Depura las discrepancias con el probador de webhooks.

Confirma rápido, trabaja después

Los proveedores tienen tiempos de espera cortos (Slack y Discord: 3 segundos; otros de 5 a 30) y reintentan si fallas. Si tu manejador llama a un modelo de IA o a una API lenta antes de responder, superarás el tiempo, el proveedor reintentará y procesarás el mismo evento varias veces. Pon el evento en una cola (Cloudflare Queues, SQS, Redis, una tabla) y devuelve 200 o 202. En plataformas serverless que lo permiten, waitUntil deja que el trabajo continúe tras la respuesta.

Idempotencia

Asume entrega al menos una vez. Guarda los IDs procesados con una caducidad mayor que la ventana de reintentos del proveedor y omite los duplicados. En llamadas salientes que crean cosas (pagos, tickets), envía una clave de idempotencia si la API la admite, para que tus propios reintentos no dupliquen nada.

Reintentos con backoff y jitter

Cuando llames a otras APIs, reintenta solo ante fallos transitorios (errores de red, 429, 5xx), nunca ante 400 o 401. Espera cada vez exponencialmente más y añade aleatoriedad para que muchos clientes no reintenten a la vez:

import random, time, httpx

def call_with_retry(fn, attempts=5, base=0.5, cap=30):
    for i in range(attempts):
        try:
            r = fn()
            if r.status_code == 429:
                time.sleep(float(r.headers.get("Retry-After", base * 2 ** i)))
                continue
            if r.status_code < 500:
                return r
        except httpx.TransportError:
            pass
        time.sleep(random.uniform(0, min(cap, base * 2 ** i)))   # jitter completo
    raise RuntimeError("abandonado tras varios reintentos")

Higiene de los payloads

  • Limita el tamaño del cuerpo (1 MB es generoso para eventos de chat).
  • Valida la forma con un esquema; ignora campos desconocidos en lugar de fallar.
  • Nunca uses IDs del payload para autorizar sin contrastarlos con tus propios registros.
  • Registra IDs y tipos de evento, no cuerpos completos con datos personales.

Pruebas en local

Expón tu servidor local con un túnel (cloudflared tunnel --url http://localhost:3000, ngrok o similar), registra la URL temporal como webhook y acuérdate de volver a la de producción. Guarda payloads reales (sin datos personales) como fixtures y reprodúcelos en pruebas unitarias. La mayoría de proveedores tiene además un botón de "reenviar" en su panel.

Llamar bien a las APIs

  • Envía un User-Agent descriptivo y pon las credenciales en cabeceras, no en la query string.
  • Fija tiempos de espera en cada petición (conexión y lectura).
  • Pagina con cursores y detente cuando la API lo indique.
  • Respeta Retry-After y los límites publicados; consulta fiabilidad y límites de tasa.

Comprueba lo aprendido

1. ¿Sobre qué debe calcularse el HMAC?
2. ¿Qué respuestas merece la pena reintentar?