# Webhooks salientes > Recibe eventos en tus sistemas, y verifica que vienen de nosotros. Source: https://welcomeai.dev/es/docs/webhooks Category: Destinos Updated: 2026-09-11 --- ## Los eventos - `lead.created` — un visitante dejó sus datos. - `conversation.completed` — alguien marcó una conversación como cerrada o escalada. - `document.indexed` — un documento terminó de indexarse y ya sirve para responder. - `document.failed` — un documento no se pudo indexar. - `sync.completed` — una fuente conectada terminó una sincronización. ## Añadir un endpoint En **Destinos → Webhooks**, añade una URL https pública y marca los eventos que quieras. El secreto de firma se muestra una sola vez, al crear el endpoint y cada vez que lo rotas. Guárdalo entonces; después solo conservamos su hash. Usa **Enviar una prueba** para lanzar un envío real con datos de ejemplo: misma firma, mismos reintentos, mismo registro. ## El sobre Enviamos JSON por POST con dos cabeceras que importan: ``` X-WelcomeAI-Signature: t=1788639323,v1=6f3a... X-WelcomeAI-Event: lead.created ``` El cuerpo tiene esta forma: ```json { "id": "evt_2b91Ha0Kd3fQ", "type": "lead.created", "created_at": "2026-09-05T20:15:23.201Z", "org_id": "org_UWzIf5jv8lW2", "data": { "lead_id": "ld_GSTPfNF6SEkE", "email": "grace@example.com" } } ``` ## Verificar un envío Cuatro cosas, en este orden: 1. Lee `t` y `v1` de la cabecera `X-WelcomeAI-Signature`. 2. Rechaza cualquier cosa donde `t` tenga más de **cinco minutos**. Cada reintento se vuelve a firmar, así que una marca de tiempo vieja es una repetición, no un envío lento. 3. Calcula HMAC-SHA256 sobre `.` con tu secreto de firma. 4. Compara con `v1` en **tiempo constante**, y solo después parsea el JSON. **Usa el cuerpo tal cual llegó, no un objeto reserializado.** Cualquier diferencia de espacios o de orden de claves cambia el digest, y la mayoría de los frameworks te dan un objeto ya parseado salvo que pidas los bytes. Cada receta de abajo muestra cómo obtenerlos. ### Node ```js const crypto = require("node:crypto"); const express = require("express"); const app = express(); // express.raw, no express.json: el digest es sobre los bytes que llegaron. app.post("/welcomeai", express.raw({ type: "application/json" }), (req, res) => { const header = req.get("X-WelcomeAI-Signature") ?? ""; const parts = Object.fromEntries(header.split(",").map((p) => p.split("=").map((s) => s.trim()))); const t = Number(parts.t); const v1 = parts.v1 ?? ""; const raw = req.body.toString("utf8"); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400); const expected = crypto.createHmac("sha256", process.env.WELCOMEAI_WEBHOOK_SECRET).update(`${t}.${raw}`).digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(v1, "utf8"); // timingSafeEqual lanza si las longitudes difieren, así que eso se comprueba antes. if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401); const event = JSON.parse(raw); // Haz tu trabajo usando event.id como clave, para que el mismo envío dos veces no haga nada. res.sendStatus(200); }); ``` ### PHP ```php 300) { http_response_code(400); exit; } $expected = hash_hmac('sha256', $t . '.' . $raw, getenv('WELCOMEAI_WEBHOOK_SECRET')); if (!hash_equals($expected, $v1)) { http_response_code(401); exit; } $event = json_decode($raw, true); // Haz tu trabajo usando $event['id'] como clave. http_response_code(200); ``` ### Python ```python import hashlib, hmac, os, time from flask import Flask, request app = Flask(__name__) SECRET = os.environ["WELCOMEAI_WEBHOOK_SECRET"].encode() @app.post("/welcomeai") def welcomeai(): raw = request.get_data() # bytes, antes de que nada los parsee header = request.headers.get("X-WelcomeAI-Signature", "") parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p) try: t = int(parts.get("t", "")) except ValueError: return "", 400 if abs(time.time() - t) > 300: return "", 400 expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, parts.get("v1", "")): return "", 401 event = request.get_json(force=True) # Haz tu trabajo usando event["id"] como clave. return "", 200 ``` Las tres se ejecutaron contra una firma real de este producto, sobre un cuerpo con acentos y un objeto anidado, y las tres coinciden: un envío genuino verifica, un cuerpo modificado falla, el secreto equivocado falla, un reintento un minuto después verifica, y una repetición seis minutos después se rechaza. ## Reintentos Lo intentamos seis veces: a los 30 segundos, 2 minutos, 10 minutos, 1 hora, 6 horas y 24 horas. Cada intento se firma de nuevo, así que un reintento nunca llega con una marca de tiempo caducada. Cualquier código 2xx cuenta como entregado. `410 Gone` significa deja de enviar y no reintentamos. Todo lo demás se reintenta, incluidos los 4xx, porque un receptor con un despliegue roto no debería perder eventos. Un endpoint que falla veinte veces seguidas se apaga, y puedes volver a encenderlo cuando el receptor este arreglado. ## Duplicados El mismo id de evento puede llegar más de una vez, por ejemplo si se perdió tu confirmación. Usa `id` como clave para deduplicar.