El webhook de sincronización
Un endpoint firmado por base de conocimiento. Qué es, cuándo usarlo, y todos sus campos, cabeceras y errores.
Qué es un webhook
Un webhook es una URL que le das a otro sistema para que te llame a ti cuando pasa algo. La alternativa es preguntar cada pocos minutos si hay novedades, que es más lento y casi siempre inútil. Un webhook es una petición, en el momento en que la cosa cambió de verdad.
Qué hace este
Cada base de conocimiento puede tener un endpoint firmado. Envías un documento y el documento se añade, se actualiza o se elimina. Ese es todo el contrato.
Existe para contenido que vive en algún sitio para el que no tenemos conector: un CMS interno, una base de Notion pasando por Zapier, una hoja de cálculo en Make, un script que se ejecuta después de tu build. Si ya usas WordPress o Webflow, usa esos conectores: saben qué cambió y gestionan los borrados por ti.
Cuándo usarlo y cuándo no
La diferencia con la API REST es solo cómo demuestras quién eres. La API REST usa una clave en una cabecera, que es lo correcto cuando controlas el código. Este usa una firma, que es lo correcto cuando quien llama es una herramienta sin código: la URL lleva un token público, así que la herramienta no necesita guardar credenciales, y la firma demuestra que el cuerpo no se manipuló.
Activarlo
Integraciones, luego Webhook de sincronización. Elige una base de conocimiento y pulsa Activar. Obtienes dos cosas:
- El endpoint,
https://app.welcomeai.dev/api/v1/kb/{kbId}/sync?t=whsec_…. El token de la URL identifica qué fuente se alimenta. Por sí solo no es un secreto: nombra, no autoriza. - El secreto de firma, que se muestra una sola vez. Guárdalo donde tu automatización guarde credenciales.
Rotar el secreto invalida el anterior al instante. No hay ventana de solape, así que cámbialo antes en tu automatización o asume unas peticiones fallidas.
Autenticación
Firma el cuerpo en crudo con HMAC-SHA256 y envía el resumen en hexadecimal.
X-WelcomeAI-Timestamp: 1757155200
X-WelcomeAI-Signature: sha256=<hex de "1757155200.<cuerpo en crudo>">Lo que se firma es el timestamp, un punto literal y el cuerpo exactamente como viaja. Firma los bytes que envías: volver a serializar el JSON cambia los espacios y la firma dejará de coincidir.
Un timestamp de más de cinco minutos de antigüedad se rechaza, para que una petición capturada no pueda reproducirse después. Sirven segundos o milisegundos, lo que ahorra una conversión en Zapier y Make: ambos emiten milisegundos. Si tu herramienta no puede añadir cabeceras, omite el timestamp y firma solo el cuerpo; funciona igual y es lo que hace la mayoría de configuraciones de Zapier, pero renuncias a la protección contra reenvíos.
Cómo calcularla
BODY='{"external_id":"faq-reembolsos","title":"Reembolsos","content_md":"# Reembolsos"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST "https://app.welcomeai.dev/api/v1/kb/$KB_ID/sync?t=$TOKEN" \
-H 'Content-Type: application/json' \
-H "X-WelcomeAI-Timestamp: $TS" \
-H "X-WelcomeAI-Signature: sha256=$SIG" \
--data-binary "$BODY"import { createHmac } from "node:crypto";
const body = JSON.stringify({ external_id: "faq-reembolsos", content_md: "# Reembolsos" });
const ts = Math.floor(Date.now() / 1000);
const sig = createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
await fetch(`https://app.welcomeai.dev/api/v1/kb/${kbId}/sync?t=${token}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-WelcomeAI-Timestamp": String(ts),
"X-WelcomeAI-Signature": `sha256=${sig}`,
},
body, // la cadena que se firmó, no un objeto vuelto a serializar
});import hashlib, hmac, time, requests
body = '{"external_id":"faq-reembolsos","content_md":"# Reembolsos"}'
ts = str(int(time.time()))
sig = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
requests.post(
f"https://app.welcomeai.dev/api/v1/kb/{kb_id}/sync?t={token}",
headers={
"Content-Type": "application/json",
"X-WelcomeAI-Timestamp": ts,
"X-WelcomeAI-Signature": f"sha256={sig}",
},
data=body, # `data=`, no `json=`: `json=` vuelve a serializar y la firma deja de coincidir
)En los tres el cuerpo se construye una sola vez, como cadena, y se firma y se envía sin tocarlo. Construirlo dos veces es lo que produce casi todos los 401.
Lo que el endpoint perdona
Nada de esto hace falta, y nada de esto falla:
- El prefijo
sha256=es opcional: un hexadecimal pelado se acepta. ?token=funciona igual que?t=.- Quitar el parámetro por completo recae en la fuente de empuje de la propia base de conocimiento y en su secreto. Cómodo desde tu backend; en una herramienta sin código compartida el token explícito es más claro, porque es lo que te dice después qué sistema envió qué.
La petición
POST, Content-Type: application/json. Un documento, o un lote:
{
"external_id": "faq-reembolsos",
"title": "Reembolsos",
"url": "https://acme.com/ayuda/reembolsos",
"content_md": "# Reembolsos\n\nDentro de 14 días, escribe a billing@acme.com.",
"metadata": { "categoria": "facturacion" }
}{
"items": [
{ "external_id": "faq-reembolsos", "title": "Reembolsos", "content_md": "…" },
{ "external_id": "faq-envios", "title": "Envíos", "content_md": "…" },
{ "external_id": "faq-viejo", "action": "delete" }
]
}Campos
Un lote son como mucho 25 elementos y el cuerpo entero como mucho 4 MB.
Pasarse de cualquiera de esos límites es un 400 invalid_input cuyo mensaje nombra qué corregir, y en un lote en qué elemento corregirlo: items.4.title: Too big: expected string to have <=300 characters. Dos campos de la API REST, kb_id y source, se aceptan aquí y se ignoran: este endpoint toma la base de conocimiento de la URL.
Si tu herramienta solo te da una ruta y no una dirección completa, abre la fuente en Fuentes conectadas -> Ajustes y pon la Dirección del sitio. Una url relativa se resuelve entonces bajo ella. Sin ella no guardamos ninguna URL en vez de adivinar un origen: el documento sigue respondiendo, solo que su cita va sin enlace, porque un enlace a la página equivocada es peor que ningún enlace.
Borrar
Envía {"external_id": "…", "action": "delete"}. El documento, sus fragmentos y sus embeddings desaparecen. Borrar algo que nunca existió no es un error: devuelve missing y sigue, así que un borrado repetido es seguro.
La respuesta
200, siempre con el detalle por elemento. Un fallo parcial es un 200 con ok: false, no un 500: un elemento malo en un lote de veinticinco no debe obligarte a reenviar los otros veinticuatro.
{
"ok": true,
"accepted": 2,
"failed": 0,
"results": [
{ "external_id": "faq-reembolsos", "status": "created" },
{ "external_id": "faq-envios", "status": "unchanged" }
]
}status es created, updated, unchanged, deleted, missing o failed. Un elemento failed lleva un error que dice por qué.
`unchanged` es el que conviene mirar. Significa que el hash coincidía con lo que ya teníamos y no se reindexó nada. En una sincronización nocturna completa casi todo debería decir unchanged; si todo dice updated, algo en tu proceso está reescribiendo el contenido en cada pasada -- normalmente una marca de tiempo regenerada dentro del cuerpo -- y estás pagando por volver a incrustar el mismo texto.
Errores
El 401 es el que sale de verdad. Por orden de probabilidad: se volvió a serializar el cuerpo antes de firmar; el timestamp se puso en la cabecera pero no en lo firmado, o al revés; se rotó el secreto; el reloj va desviado más de cinco minutos.
Reintentos
Nosotros no reintentamos: el que llama eres tú. Reintenta un 5xx o un fallo de red con espera creciente, y no reintentes un 4xx: nada de esa petición va a funcionar a la segunda.
Reintentar es seguro. external_id hace que cada escritura sea idempotente, así que enviar dos veces el mismo documento produce un documento, y la segunda llamada devuelve unchanged.
Límites
Hoy no hay límite de peticiones por endpoint. La ingesta la acotan los límites de documentos y almacenamiento de tu plan, y un lote está limitado a 25 elementos y 4 MB. Sé razonable en una primera importación: unas pocas peticiones por segundo, no unos cientos.
Mantenerlo seguro
- Trata el secreto como una contraseña. Es lo único que separa tu base de conocimiento de cualquiera que tenga la URL, y la URL viaja por la herramienta donde la pegaste.
- Rótalo cuando alguien se va, o cuando se retira una automatización.
- Una base, un webhook. Si dos sistemas alimentan la misma base, dales bases separadas o prefijos de
external_iddistintos, para poder saber después cuál envió qué. - No envíes nada que un visitante no deba leer. Todo lo que publiques es contenido que el asistente puede citarle a quien pregunte.
- Cada petición queda en el Registro de sincronización con qué encontró y qué cambió, que es donde mirar cuando el contenido no es el que esperabas.