Tema
Webhooks
Endpoints para que otros sistemas avisen a ur de que hay documentos nuevos o cambiados, en vez de esperar al siguiente escaneo programado.
No usan el access token
Los webhooks quedan fuera de la autenticación normal de /api/*: quien llama es un sistema externo que no puede llevar tu token. Cada uno tiene su propio secreto, generado junto con la fuente de ficheros.
Tampoco aparecen en /api/openapi.json.
Comparten un límite de 20 peticiones por minuto y por IP.
Avisar de que una URL ha cambiado (fuente web)
POST /api/webhooks/web/<id-de-la-file-source>Si tienes una fuente de ficheros de tipo web, esto es lo que engancha tu CMS con ur: cada vez que publicas, editas o borras una página, se lo dices y se procesa en el acto.
Viene desactivado
Hay que activarlo por fuente, en el panel. Mientras esté apagado, el endpoint responde 403 con reason: "webhook-disabled" sin mirar siquiera la firma.
json
{ "urls": ["https://tusitio.com/blog/mi-articulo"] }Mandamos un GET a cada URL que nos das y actuamos según lo que responda tu servidor. No hay endpoint de alta ni de baja: los tres casos salen del mismo aviso.
| Lo que responde tu sitio | Lo que hacemos |
|---|---|
200 y la URL no estaba indexada | La damos de alta: entra en el inventario y se indexa en todos los RAGs que usen esa fuente |
200 y el contenido ha cambiado | Borramos sus vectores anteriores y reindexamos el nuevo contenido |
200 y el contenido es el mismo | Nada. Avisar de más sale barato |
404 o 410 | La damos de baja: sale del inventario y sus vectores se destruyen en ese mismo ciclo, en todos los RAGs |
403, 429, 5xx… | Lo dejamos como está y se reintenta. Un bloqueo temporal no es un borrado |
Solo se miran las URLs que mandas
No se siguen sus enlaces ni se rastrea el resto del sitio. Descubrir páginas que nadie ha anunciado sigue siendo trabajo del rastreo completo, que corre con su frecuencia aparte.
Las URLs tienen que ser del mismo dominio que la fuente o de uno de los que tenga en Otros dominios del sitio (p. ej. el CMS que sirve los PDFs).
Tope de 100 URLs por petición.
json
{ "ok": true, "accepted": 1, "rejected": 0, "rejectedUrls": [], "pending": 1, "queued": true }| Campo | Significado |
|---|---|
accepted | URLs aceptadas tras normalizar y quitar repetidas |
rejected | Cuántas entradas se descartaron |
rejectedUrls | Cuáles y por qué: not-a-string, not-a-url, not-http, foreign-origin, duplicate |
pending | Total en cola para esta fuente, contando avisos anteriores sin procesar |
queued | Si se encoló el ciclo que las procesa |
Si ninguna URL sobrevive al filtro, la respuesta es un 400 con esa misma lista y los origins que sí se habrían aceptado (y, si la fuente tiene otros dominios, esos hosts en expectedHosts):
json
{
"error": "No usable URL in the request",
"expectedOrigins": ["https://tusitio.com"],
"rejected": 1,
"rejectedUrls": [{ "url": "https://otro-sitio.com/x", "reason": "foreign-origin" }]
}Páginas que el rastreo no alcanza
Una URL que entra por el webhook y no sale del sitemap ni se alcanza siguiendo enlaces desde la semilla queda fijada automáticamente: se vuelve a pedir en cada rastreo completo, así que no se cae del inventario. Cuando el sitio responda 404, se desfija y se borra sola.
Si quieres declarar esas páginas tú, sin pasar por el webhook, en el panel hay un campo URLs adicionales en la configuración de la fuente web (una por línea, de alguno de los dominios de la fuente).
<id-de-la-file-source> es el UUID de la fuente —viene ya puesto en la URL que copias del panel—, no su identifier.
Cómo se firma
El secreto no viaja en la petición. Se usa para calcular un HMAC-SHA256 sobre el instante, un nonce y el cuerpo exacto:
HMAC_SHA256(secreto, timestamp + "." + nonce + "." + cuerpo)| Cabecera | Qué lleva |
|---|---|
X-UR-Timestamp | Segundos epoch (no milisegundos). Se aceptan ±5 minutos de desfase |
X-UR-Nonce | Una cadena distinta en cada petición, de 8 a 128 caracteres. Se recuerda 10 minutos |
X-UR-Signature | sha256= seguido del HMAC en hexadecimal minúscula |
Así, quien vea una petición tuya por el camino no se lleva la credencial, no puede cambiarle las URLs, y no puede reenviarla.
Ejemplo en Node.js
js
import { createHmac, randomBytes } from 'node:crypto';
const URL_WEBHOOK = 'https://ur.tuempresa.com/api/webhooks/web/<id>';
const SECRET = '<secreto-de-la-fuente>';
export async function avisarDeCambio(urls) {
const body = JSON.stringify({ urls });
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomBytes(16).toString('hex');
const signature = createHmac('sha256', SECRET)
.update(`${timestamp}.${nonce}.${body}`)
.digest('hex');
const res = await fetch(URL_WEBHOOK, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-UR-Timestamp': timestamp,
'X-UR-Nonce': nonce,
'X-UR-Signature': `sha256=${signature}`,
},
body,
});
if (!res.ok) {
throw new Error(`aviso rechazado: ${res.status} ${await res.text()}`);
}
return res.json();
}Ejemplo en PHP
php
<?php
const UR_WEBHOOK_URL = 'https://ur.tuempresa.com/api/webhooks/web/<id>';
const UR_WEBHOOK_SECRET = '<secreto-de-la-fuente>';
function ur_avisar_de_cambio(array $urls): array {
$body = json_encode(['urls' => array_values($urls)]);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$signature = hash_hmac('sha256', "$timestamp.$nonce.$body", UR_WEBHOOK_SECRET);
$ch = curl_init(UR_WEBHOOK_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"X-UR-Timestamp: $timestamp",
"X-UR-Nonce: $nonce",
"X-UR-Signature: sha256=$signature",
],
]);
$respuesta = curl_exec($ch);
$estado = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($estado < 200 || $estado >= 300) {
throw new RuntimeException("aviso rechazado: $estado $respuesta");
}
return json_decode($respuesta, true);
}Probarlo desde una terminal
El panel tiene un botón Copiar comando curl que te da esto con tu URL y tu secreto ya dentro. La firma se calcula en el momento, porque una firma precalculada caduca en cinco minutos.
bash
SECRET='<secreto-de-la-fuente>'
URL='https://ur.tuempresa.com/api/webhooks/web/<id>'
BODY='{"urls":["https://tusitio.com/blog/mi-articulo"]}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
SIG=$(printf '%s.%s.%s' "$TS" "$NONCE" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -X POST "$URL" \
-H 'Content-Type: application/json' \
-H "X-UR-Timestamp: $TS" \
-H "X-UR-Nonce: $NONCE" \
-H "X-UR-Signature: sha256=$SIG" \
-d "$BODY"Dónde está el secreto
En el panel, en Fuente Ficheros → la fuente → Editar, la tarjeta «Avisar de cambios en una URL»: ahí están la URL, el secreto (tapado, con un botón para verlo y otro para copiarlo), el botón del curl y una i con esta misma explicación y los ejemplos.
Solo aparece al editar una fuente de tipo web ya guardada.
Limitar por IP (opcional)
En esa misma tarjeta puedes poner las IPs o rangos CIDR desde los que se acepta el aviso (203.0.113.10, 10.0.0.0/8). Vacío significa que se acepta desde cualquier IP.
Es un filtro añadido a la firma, no un sustituto: una IP se falsifica en cuanto hay un proxy de por medio. Lo que te da es que un secreto filtrado no sirva desde cualquier sitio de internet. Pon la IP pública de salida de tu CMS, no la de un proxy intermedio: se compara contra la IP del cliente ya resuelta a través de la cadena de proxies.
Errores
| Código | Cuándo |
|---|---|
400 | El JSON no es válido, ninguna URL sobrevive al filtro, o mandas más de 100 |
401 | Firma inválida, caducada, o nonce ya usado. El campo reason de la respuesta dice cuál |
403 | El webhook está desactivado en esa fuente (webhook-disabled), o la IP no está en la lista de permitidas (ip-not-allowed) |
404 | Ese id no existe o no corresponde a una fuente de tipo web |
413 | El cuerpo pasa de 256 KB |
429 | Más de 20 peticiones por minuto y por IP. Retry-After dice cuántos segundos esperar |
503 | Redis no responde (contador del rate limit o registro de nonces) |
Los valores de reason en un 401: missing-headers, bad-timestamp, expired, bad-nonce, bad-signature, replayed-nonce, bad-token.
Authorization: Bearer <secreto>
La versión anterior se autenticaba así y se sigue aceptando, para no romper lo que ya esté desplegado. No la uses en una integración nueva: manda la credencial entera en cada petición y no ata nada al cuerpo, así que quien la intercepte puede reenviarla o cambiarle las URLs.
Notificaciones de Google Drive
POST /api/webhooks/driveEste no lo llamas tú: lo llama Google cuando cambia algo en la unidad que vigila la fuente. La suscripción no se configura a mano — ur la crea y la renueva por su cuenta como parte del escaneo de una fuente de Drive.
Se autentica con las cabeceras que manda Google (X-Goog-Channel-ID y X-Goog-Channel-Token), contrastadas contra el secreto guardado en la fuente. Si el canal no se reconoce, responde 200 de todas formas — es la forma de decirle a Google que deje de reintentar sobre una suscripción que ya no existe.
Los avisos se agrupan en ventanas de 10 segundos, para que una ráfaga de cambios no dispare docenas de escaneos.
Lo único que necesitas saber como integrador es que si las notificaciones están configuradas, los cambios en Drive se recogen en el siguiente ciclo del escáner en una fuente en tiempo real. Una fuente con frecuencia (cada hora, cada día, cada semana) sigue esperando a la suya: el aviso no la adelanta.
Comprobar que llegó
Los webhooks encolan trabajo; no indexan de forma síncrona. Para saber si un documento concreto ya está disponible, consulta su estado:
bash
curl "https://ur.tuempresa.com/api/inferences/<inferencia>/file-sources/<file-source>?file=https://tusitio.com/blog/mi-articulo" \
-H "Authorization: Bearer $UR_TOKEN"En file va el identificador externo del fichero: para una fuente web, la URL absoluta; en Google Drive, el fileId.