Webhooks salientes
Recibe eventos en tus sistemas, y verifica que vienen de nosotros.
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.createdEl cuerpo tiene esta forma:
{
"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:
- Lee
tyv1de la cabeceraX-WelcomeAI-Signature. - Rechaza cualquier cosa donde
ttenga 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. - Calcula HMAC-SHA256 sobre
<t>.<cuerpo tal cual>con tu secreto de firma. - Compara con
v1en 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
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
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WELCOMEAI_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $piece) {
[$k, $v] = array_pad(explode('=', trim($piece), 2), 2, '');
$parts[$k] = $v;
}
$t = isset($parts['t']) ? (int) $parts['t'] : 0;
$v1 = $parts['v1'] ?? '';
if ($t === 0 || abs(time() - $t) > 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
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 "", 200Las 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.