Saltar a contenido

Vigilancia (watchlists)

Vigilancia te deja registrar watchlists que monitorean el corpus de marcas de INAPI y generan hits cuando aparecen coincidencias — presentaciones nuevas, cambios sobre marcas previas o cruces con el histórico (baseline). Cada watchlist entrega sus hits de forma dual: el pull por keyset está siempre disponible, y el push por webhook firmado es opcional.

El esquema completo y siempre actualizado por campo vive en la referencia OpenAPI interactiva en /docs (Scalar). Esta guía explica el contrato de Vigilancia y enlaza a esa referencia — no reformula el esquema. Cuando un campo no está documentado aquí, /docs es autoritativo.

A lo largo de la guía la URL base se escribe como $TARNO_BASE_URL y la clave como $TARNO_API_KEY, y cada llamada lleva el encabezado X-API-Key — igual que en la guía de primeros pasos y la guía de autenticación.

Qué es Vigilancia

Una watchlist es un conjunto de ítems a vigilar (marcas por denominación, o números de solicitud concretos) más filtros opcionales. El motor de vigilancia recorre el corpus de forma incremental tras cada sync y, cuando un ítem coincide, registra un hit con un snapshot congelado de la marca detectada.

La entrega es dual:

  • Pull (siempre) — lees los hits por keyset con GET /v1/watch/:id/hits.
  • Push (opcional) — si registras un callbackUrl, Tarno te envía un webhook firmado como notificación cada vez que hay hits nuevos. El webhook no transporta hits: te avisa, y tú luego haces el pull.

Scopes y tenencia

Vigilancia usa dos scopes, además de las mismas reglas de scopes que el resto del contrato (ver la guía de autenticación):

Scope Otorga acceso a
watch:read Lecturas: GET /v1/watch, GET /v1/watch/:id, GET /v1/watch/:id/hits, GET /v1/watch/:id/items.
watch:write Escrituras: POST /v1/watch, PATCH /v1/watch/:id, DELETE /v1/watch/:id, y los ítems POST/DELETE /v1/watch/:id/items.

Un arreglo de scopes vacío no tiene restricción (misma regla que brands:read): una clave sin scopes recibe todos los scopes de lectura, incluidos los de watch. Una clave con scopes no vacíos recibe solo los listados; llamar a una ruta cuyo scope requerido está ausente se rechaza con 403 forbidden nombrando el scope faltante.

La org dueña se resuelve desde la clave de API (el consumerId de la clave, Phase 8), nunca desde el cuerpo de la solicitud. Consecuencias:

  • Una clave sin org dueña (consumerId nulo) no puede crear ni listar watchlists → 403 con mensaje "API key has no owning org".
  • Cada clave solo ve sus propias watchlists (las de su org). Una watchlist de otra org es, a todos los efectos, inexistente → 404 uniforme.

En resumen: para usar Vigilancia una clave necesita a la vez una org dueña y los scopes watch:*. Nota operativa: la clave actual de tarno-app todavía no lleva watch:*.

Crear una watchlist

POST /v1/watch — scope watch:write.

Cuerpo:

Campo Tipo Notas
label string Nombre legible de la watchlist.
items array (≥ 1) Ítems a vigilar: marcas (por denominación) o números de solicitud (nro).
callbackUrl url | null Opcional. URL del webhook. null u omitido = solo pull.
filters objeto Opcional. Ver Filtros.
baseline bool Opcional. true = cruzar contra todo el corpus histórico una vez (D-14).

La respuesta 201 devuelve la watchlist creada más un signingSecret (formato whsec_...).

El signingSecret se devuelve UNA SOLA VEZ, aquí en la creación (y al rotarlo — ver Gestión). Nunca aparece en lecturas (GET). Guárdalo de inmediato en un lugar seguro; si lo pierdes, tendrás que rotarlo.

curl

curl -sS -X POST "$TARNO_BASE_URL/v1/watch" \
  -H "X-API-Key: $TARNO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Cafeterías clase 43",
    "items": [{ "marca": "CAFE EXAMPLE" }],
    "callbackUrl": "https://tu-app.example/webhooks/tarno",
    "filters": { "clase": [43], "liveOnly": true }
  }'

TypeScript (fetch)

const res = await fetch(`${process.env.TARNO_BASE_URL}/v1/watch`, {
  method: "POST",
  headers: {
    "X-API-Key": process.env.TARNO_API_KEY!,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    label: "Cafeterías clase 43",
    items: [{ marca: "CAFE EXAMPLE" }],
    callbackUrl: "https://tu-app.example/webhooks/tarno",
    filters: { clase: [43], liveOnly: true },
  }),
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${res.status} ${error.code}: ${error.message} (requestId=${error.requestId})`);
}

const watchlist = await res.json();
// ⚠️ signingSecret llega SOLO aquí — guárdalo ahora, nunca vuelve a aparecer.
await storeSecret(watchlist.id, watchlist.signingSecret); // "whsec_..."

Errores de creación:

  • 422 watchlist_too_large — superaste un tope (demasiadas watchlists, o demasiados ítems en una).
  • 422 unsafe_callback — el callbackUrl no pasó la validación anti-SSRF.

Filtros

El objeto filters (jsonb) es opcional y todos sus campos lo son:

Campo Tipo Efecto
minScore number (0..1) Piso de relevancia. Sobrescribe el piso por defecto de la vigilancia (0.7).
clase array\<integer> Clases de Niza a considerar.
allClasses bool Ensancha una vigilancia por nro de vuelta a todas las clases (por defecto solo la clase de la marca).
liveOnly bool Excluye estados terminales (denegada, vencida, anulada).

Además, baseline: true en la creación habilita el cruce baseline una única vez contra todo el corpus histórico (D-14).

Gestionar watchlists

Listar — GET /v1/watch

Scope watch:read. Lista solo las tuyas (las de tu org). Nunca incluye el signingSecret.

curl -sS "$TARNO_BASE_URL/v1/watch" -H "X-API-Key: $TARNO_API_KEY"

Obtener una — GET /v1/watch/:id

Scope watch:read. Una watchlist inexistente o de otra org devuelve un 404 uniforme (no se distingue "no existe" de "no es tuya").

Actualizar — PATCH /v1/watch/:id

Scope watch:write. Cuerpo disperso (sparse): manda solo los campos que cambian.

Campo Tipo Notas
label string Renombra la watchlist.
callbackUrl url | null Cambia o quita (null) el webhook.
filters objeto Reemplaza los filtros.
status 'active' | 'paused' Pausa/reanuda la vigilancia.
rotateSecret bool true → devuelve un nuevo signingSecret una sola vez en la respuesta.

Una watchlist desconocida o de otra org → 404.

Retirar — DELETE /v1/watch/:id

Scope watch:write. Es un retiro suave (status = 'retired'): detiene los barridos y el push, pero preserva los hits, que siguen siendo legibles por pull. Devuelve 204 No Content.

Retirar ≠ pausar. Pausar (status: 'paused' vía PATCH) es reversible y solo suspende la vigilancia. Retirar es DELETE (no un status que puedas enviar), es el fin de vida de la watchlist, y aun así conserva el historial de hits para consulta.

Editar los ítems vigilados

Para ajustar la cartera vigilada sin recrear la watchlist (que generaría un nuevo id y signingSecret y perdería el historial de hits), edita los ítems de forma incremental:

Operación Endpoint Scope Respuesta
Listar ítems GET /v1/watch/:id/items watch:read 200 — array de WatchItem (ver abajo).
Agregar un ítem POST /v1/watch/:id/items watch:write 201 — el WatchItem creado.
Quitar un ítem DELETE /v1/watch/:id/items/:itemId watch:write 204 No Content.

Un WatchItem es { id: number, watchlistId: string, mark: string, createdAt: string }. El id numérico es el que pasas a DELETE .../items/:itemId (lo obtienes del listado).

Cuerpo de POST: { "mark": "SONDA" } — una marca (por denominación) o un número de solicitud.

Notas:

  • Idempotente: reagregar un mark que ya existe devuelve 201 con la fila existente y no incrementa el conteo (no hay duplicados).
  • Tope por tenant: agregar un mark nuevo que supere max_watch_items se rechaza con 422 watchlist_too_large (igual que en la creación); un reagregado al tope se permite.
  • Tenencia: una watchlist inexistente o de otra org → 404 uniforme; un itemId que no es de tu watchlist → 404.
# Agregar
curl -sS -X POST "$TARNO_BASE_URL/v1/watch/<id>/items" \
  -H "X-API-Key: $TARNO_API_KEY" -H "Content-Type: application/json" \
  -d '{"mark":"SONDA"}'

# Listar
curl -sS "$TARNO_BASE_URL/v1/watch/<id>/items" -H "X-API-Key: $TARNO_API_KEY"

# Quitar (itemId del listado)
curl -sS -X DELETE "$TARNO_BASE_URL/v1/watch/<id>/items/<itemId>" -H "X-API-Key: $TARNO_API_KEY"

Entrega dual

Pull (siempre) — GET /v1/watch/:id/hits

Scope watch:read. Siempre disponible, haya o no callbackUrl.

Query Tipo Notas
since string Cursor opaco de keyset. Trátalo como caja negra.
limit integer Tamaño de página. Por defecto 50, máximo 200.

Respuesta: { items: WatchHit[], nextCursor: string | null }. nextCursor: null = fin — misma semántica de keyset que primeros pasos. Para la próxima página, reenvía el cursor sin cambios como since.

curl -sS "$TARNO_BASE_URL/v1/watch/<id>/hits?limit=50" -H "X-API-Key: $TARNO_API_KEY"

Push (opcional) — webhook firmado

Si registraste un callbackUrl, Tarno hace un POST firmado a esa URL cuando hay hits nuevos. Verifica la firma (abajo) y luego haz el pull de /hits.

Verificación del webhook

El webhook va firmado con HMAC-SHA256. El encabezado es:

X-Tarno-Signature: t=<unix>,v1=<hmac-hex>

El mensaje firmado es `${t}.${rawBody}` (el timestamp t, un punto, y el cuerpo crudo de la solicitud) usando el signing_secret (whsec_...) de esa watchlist. La tolerancia anti-replay (skew) es de 300 s.

El webhook NO transporta hits (D-02 / D-11 — nunca lleva cuerpos de resultados): es una notificación. Al recibirlo, verifica la firma, rechaza si el skew excede 300 s, y luego haz el pull de GET /v1/watch/:id/hits.

Ejemplo de verificación en Node (node:crypto):

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody = el cuerpo CRUDO exacto (Buffer/string), sin re-serializar.
function verifyTarnoWebhook(rawBody: string, header: string, signingSecret: string): boolean {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;

  // Rechaza fuera de la ventana anti-replay (300 s).
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = createHmac("sha256", signingSecret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Advertencia: verifica siempre la firma y rechaza por skew antes de actuar sobre la notificación.

Forma del hit

Cada elemento de GET /v1/watch/:id/hits es un WatchHit:

Campo Tipo Notas
id string Identificador del hit.
watchItemId string Ítem de la watchlist que coincidió.
watchlistId string Watchlist a la que pertenece.
matchedNro string Número de solicitud de la marca detectada.
triggerHash string Hash de deduplicación del disparo.
score number Puntaje de relevancia de la coincidencia.
signals objeto Señales que explican la coincidencia.
hitKind 'new_filing' | 'update_on_prior' | 'baseline' Tipo de hit.
snapshot BrandSummary Snapshot congelado de la marca detectada.
detectedAt string (fecha-hora) Cuándo se detectó.

Los campos de snapshot son la misma forma BrandSummary documentada en primeros pasos y en /docs.

MCP

REST es equivalente a MCP: misma clave, mismos scopes, mismos esquemas de Zod. El servidor MCP expone estas herramientas de Vigilancia:

Herramienta MCP Equivalente REST
watch_register POST /v1/watch
watch_list GET /v1/watch
watch_get GET /v1/watch/:id
watch_patch PATCH /v1/watch/:id
watch_delete DELETE /v1/watch/:id
watch_list_hits GET /v1/watch/:id/hits
watch_list_items GET /v1/watch/:id/items
watch_add_item POST /v1/watch/:id/items
watch_remove_item DELETE /v1/watch/:id/items/:itemId

Consulta la guía de MCP para el endpoint, el transporte y la autenticación.

Errores

Todos los errores usan el sobre uniforme { error: { code, message, requestId } } — descrito en primeros pasos.

HTTP code Cuándo
401 unauthorized Clave ausente, malformada, desconocida o revocada.
403 forbidden Falta el scope watch:* requerido, o la clave no tiene org dueña.
404 not_found Watchlist inexistente o de otra org (uniforme — no distingue ambos casos).
422 watchlist_too_large Superaste un tope (demasiadas watchlists o demasiados ítems).
422 unsafe_callback El callbackUrl no pasó la validación anti-SSRF.

Ver también