Skip to content

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 sitioLo que hacemos
200 y la URL no estaba indexadaLa damos de alta: entra en el inventario y se indexa en todos los RAGs que usen esa fuente
200 y el contenido ha cambiadoBorramos sus vectores anteriores y reindexamos el nuevo contenido
200 y el contenido es el mismoNada. Avisar de más sale barato
404 o 410La 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 }
CampoSignificado
acceptedURLs aceptadas tras normalizar y quitar repetidas
rejectedCuántas entradas se descartaron
rejectedUrlsCuáles y por qué: not-a-string, not-a-url, not-http, foreign-origin, duplicate
pendingTotal en cola para esta fuente, contando avisos anteriores sin procesar
queuedSi 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)
CabeceraQué lleva
X-UR-TimestampSegundos epoch (no milisegundos). Se aceptan ±5 minutos de desfase
X-UR-NonceUna cadena distinta en cada petición, de 8 a 128 caracteres. Se recuerda 10 minutos
X-UR-Signaturesha256= 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ódigoCuándo
400El JSON no es válido, ninguna URL sobrevive al filtro, o mandas más de 100
401Firma inválida, caducada, o nonce ya usado. El campo reason de la respuesta dice cuál
403El webhook está desactivado en esa fuente (webhook-disabled), o la IP no está en la lista de permitidas (ip-not-allowed)
404Ese id no existe o no corresponde a una fuente de tipo web
413El cuerpo pasa de 256 KB
429Más de 20 peticiones por minuto y por IP. Retry-After dice cuántos segundos esperar
503Redis 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/drive

Este 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.

Ver Estado de un fichero concreto.