IdiomaENES
Destinos

Webhooks salientes

Recibe eventos en tus sistemas, y verifica que vienen de nosotros.

Actualizado 2026-09-11Ver .md

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 <t>.<cuerpo tal cual> 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
<?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

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.