Skip to content

Watchlists (Vigilancia)

Watchlists let you register monitors over the INAPI brand corpus that surface hits when matches appear — new filings, updates on prior brands, or cross-matches against history (baseline). Each watchlist delivers its hits dual: keyset pull is always available, and a signed webhook push is optional.

The full, always-current per-field schema lives in the interactive OpenAPI reference at /docs (Scalar). This guide explains the watch contract and links to that reference — it does not restate the schema. When a field is not documented here, /docs is authoritative.

Throughout this guide the base URL is written as $TARNO_BASE_URL and the key as $TARNO_API_KEY, and every call carries the X-API-Key header — just like the getting-started guide and the authentication guide.

What Vigilancia is

A watchlist is a set of items to watch (brands by denomination, or specific application numbers) plus optional filters. The watch engine sweeps the corpus incrementally after each sync and, when an item matches, records a hit with a frozen snapshot of the detected brand.

Delivery is dual:

  • Pull (always) — you read hits by keyset with GET /v1/watch/:id/hits.
  • Push (optional) — if you register a callbackUrl, Tarno sends you a signed webhook as a notification whenever there are new hits. The webhook carries no hits: it tells you, and you then pull.

Scopes and tenancy

Vigilancia uses two scopes, on top of the same scope rules as the rest of the contract (see the authentication guide):

Scope Grants access to
watch:read Reads: GET /v1/watch, GET /v1/watch/:id, GET /v1/watch/:id/hits, GET /v1/watch/:id/items.
watch:write Writes: POST /v1/watch, PATCH /v1/watch/:id, DELETE /v1/watch/:id, and items POST/DELETE /v1/watch/:id/items.

An empty scopes array is unrestricted (same rule as brands:read): a key with no scopes is granted all read scopes, including the watch ones. A key with a non-empty scopes array is granted only those listed; calling a route whose required scope is absent is rejected with 403 forbidden naming the missing scope.

The owning org is resolved from the API key (the key's consumerId, Phase 8), never from the request body. Consequences:

  • A key with no owning org (consumerId null) cannot create or list watchlists → 403 with message "API key has no owning org".
  • Each key sees only its own watchlists (its org's). Another org's watchlist is, for all purposes, nonexistent → uniform 404.

In short: to use Vigilancia a key needs both an owning org and the watch:* scopes. Operational note: today's tarno-app key does not yet carry watch:*.

Create a watchlist

POST /v1/watch — scope watch:write.

Body:

Field Type Notes
label string Human-readable name for the watchlist.
items array (≥ 1) Items to watch: brands (by denomination) or application numbers (nro).
callbackUrl url | null Optional. Webhook URL. null or omitted = pull-only.
filters object Optional. See Filters.
baseline bool Optional. true = cross-match once against the whole historical corpus (D-14).

The 201 response returns the created watchlist plus a signingSecret (format whsec_...).

The signingSecret is returned ONE TIME ONLY, here at create (and on rotate — see Manage). It never appears in reads (GET). Store it immediately somewhere safe; if you lose it, you must rotate it.

curl

curl -sS -X POST "$TARNO_BASE_URL/v1/watch" \
  -H "X-API-Key: $TARNO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Coffee shops class 43",
    "items": [{ "marca": "CAFE EXAMPLE" }],
    "callbackUrl": "https://your-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: "Coffee shops class 43",
    items: [{ marca: "CAFE EXAMPLE" }],
    callbackUrl: "https://your-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 arrives ONLY here — store it now, it never appears again.
await storeSecret(watchlist.id, watchlist.signingSecret); // "whsec_..."

Create errors:

  • 422 watchlist_too_large — you exceeded a cap (too many watchlists, or too many items in one).
  • 422 unsafe_callback — the callbackUrl failed anti-SSRF validation.

Filters

The filters object (jsonb) is optional and all of its fields are optional:

Field Type Effect
minScore number (0..1) Relevance floor. Overrides the default watch relevance floor (0.7).
clase array\<integer> Niza classes to consider.
allClasses bool Widen a by-id watch back to all classes (default is only the brand's class).
liveOnly bool Exclude terminal states (denegada, vencida, anulada).

Additionally, baseline: true at create opts into a one-time baseline cross-match against the whole historical corpus (D-14).

Manage watchlists

List — GET /v1/watch

Scope watch:read. Lists your own only (your org's). Never includes the signingSecret.

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

Get one — GET /v1/watch/:id

Scope watch:read. A nonexistent or cross-tenant watchlist returns a uniform 404 (it does not distinguish "does not exist" from "not yours").

Update — PATCH /v1/watch/:id

Scope watch:write. Sparse body: send only the fields that change.

Field Type Notes
label string Rename the watchlist.
callbackUrl url | null Change or remove (null) the webhook.
filters object Replace the filters.
status 'active' | 'paused' Pause/resume the watch.
rotateSecret bool true → returns a new signingSecret once in the response.

An unknown or cross-tenant watchlist → 404.

Retire — DELETE /v1/watch/:id

Scope watch:write. This is a soft retire (status = 'retired'): it stops sweeps and push, but preserves the hits, which stay pull-readable. Returns 204 No Content.

Retire ≠ pause. Pausing (status: 'paused' via PATCH) is reversible and only suspends the watch. Retiring is DELETE (not a status you can send), it is the watchlist's end of life, and it still keeps the hit history for querying.

Edit the watched items

To adjust the watched portfolio without recreating the watchlist (which would mint a new id and signingSecret and lose the hit history), edit the items incrementally:

Operation Endpoint Scope Response
List items GET /v1/watch/:id/items watch:read 200 — array of WatchItem (below).
Add an item POST /v1/watch/:id/items watch:write 201 — the created WatchItem.
Remove an item DELETE /v1/watch/:id/items/:itemId watch:write 204 No Content.

A WatchItem is { id: number, watchlistId: string, mark: string, createdAt: string }. The numeric id is what you pass to DELETE .../items/:itemId (get it from the list).

POST body: { "mark": "SONDA" } — a mark (by denomination) or an application number.

Notes:

  • Idempotent: re-adding an existing mark returns 201 with the existing row and does not grow the count (no duplicates).
  • Per-tenant cap: adding a new mark that would exceed max_watch_items is rejected with 422 watchlist_too_large (same as create); a re-add at the cap is allowed.
  • Tenancy: a nonexistent or cross-tenant watchlist → uniform 404; an itemId that is not on your watchlist → 404.

Dual delivery

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

Scope watch:read. Always available, whether or not a callbackUrl is set.

Query Type Notes
since string Opaque keyset cursor. Treat it as a black box.
limit integer Page size. Default 50, max 200.

Response: { items: WatchHit[], nextCursor: string | null }. nextCursor: null = end — same keyset semantics as getting started. For the next page, resend the cursor unchanged as since.

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

Push (optional) — signed webhook

If you registered a callbackUrl, Tarno makes a signed POST to that URL when there are new hits. Verify the signature (below), then pull /hits.

Webhook verification

The webhook is signed with HMAC-SHA256. The header is:

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

The signed message is `${t}.${rawBody}` (the timestamp t, a dot, and the raw request body) using that watchlist's signing_secret (whsec_...). The anti-replay skew tolerance is 300 s.

The webhook carries NO hits (D-02 / D-11 — never result bodies): it is a notification. On receipt, verify the signature, reject if the skew exceeds 300 s, and then pull GET /v1/watch/:id/hits.

Node verification example (node:crypto):

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

// rawBody = the exact RAW body (Buffer/string), not re-serialized.
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;

  // Reject outside the anti-replay window (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);
}

Warning: always verify the signature and reject on skew before acting on the notification.

Hit shape

Each element of GET /v1/watch/:id/hits is a WatchHit:

Field Type Notes
id string Hit identifier.
watchItemId string Watchlist item that matched.
watchlistId string Watchlist it belongs to.
matchedNro string Application number of the detected brand.
triggerHash string Dedup hash of the trigger.
score number Relevance score of the match.
signals object Signals explaining the match.
hitKind 'new_filing' | 'update_on_prior' | 'baseline' Kind of hit.
snapshot BrandSummary Frozen snapshot of the detected brand.
detectedAt string (date-time) When it was detected.

The snapshot fields are the same BrandSummary shape documented in getting started and in /docs.

MCP

REST is equivalent to MCP: same key, same scopes, same Zod schemas. The MCP server exposes these Vigilancia tools:

MCP tool REST equivalent
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

See the MCP guide for the endpoint, transport, and authentication.

Errors

Every error uses the uniform envelope { error: { code, message, requestId } } — described in getting started.

HTTP code When
401 unauthorized Missing, malformed, unknown, or revoked key.
403 forbidden Missing required watch:* scope, or the key has no owning org.
404 not_found Nonexistent or cross-tenant watchlist (uniform — does not distinguish both).
422 watchlist_too_large You exceeded a cap (too many watchlists or too many items).
422 unsafe_callback The callbackUrl failed anti-SSRF validation.

See also