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,/docsis 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 (
consumerIdnull) cannot create or list watchlists →403with 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
signingSecretis 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— thecallbackUrlfailed 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'viaPATCH) is reversible and only suspends the watch. Retiring isDELETE(not astatusyou 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
markreturns201with the existing row and does not grow the count (no duplicates). - Per-tenant cap: adding a new
markthat would exceedmax_watch_itemsis rejected with422 watchlist_too_large(same as create); a re-add at the cap is allowed. - Tenancy: a nonexistent or cross-tenant watchlist → uniform
404; anitemIdthat 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
- Authentication and scopes —
X-API-Key,watch:read/watch:write, key lifecycle. - Connect your AI agent (MCP) — the same Vigilancia tools over MCP.
- Getting started — base URL, keyset pagination, error envelope.
- The live OpenAPI reference at
/docs— the authoritative per-field schema.