> ## Documentation Index
> Fetch the complete documentation index at: https://docs.signa.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Watches

> Pick a watch type, build the query, tune the threshold, preview the volume

A watch is a saved query that Signa runs on every ingestion sync. Pick the type whose required filter is the narrowest fit for what you want to track, build the query body, and use [Preview](#preview-before-you-launch) to estimate volume before going live.

<Note>
  Creating, listing, and managing watches requires the `portfolios:manage` scope on your API key. The monitoring API is in beta: see [Known beta limitations](/guides/monitoring/beta-limitations).
</Note>

## Pick a watch type

| You want to...                                                                | `watch_type` | Required field                         |
| ----------------------------------------------------------------------------- | ------------ | -------------------------------------- |
| Track one specific mark you own (renewals, status drift).                     | `mark`       | `filters.trademarkIds` (one ID)        |
| Watch an entire portfolio of marks at once.                                   | `portfolio`  | `filters.trademarkIds` (1 or more IDs) |
| Track a competitor by owner.                                                  | `owner`      | `filters.ownerId`                      |
| Watch new filings in a Nice class (or class set), optionally by jurisdiction. | `class`      | `filters.niceClasses`                  |
| Detect confusingly-similar new filings.                                       | `similarity` | `q` (text)                             |

The `query` body uses the same vocabulary as [trademark search](/api-reference/trademarks/list-trademarks), **with one casing difference**: REST search query params are snake\_case (`nice_classes=9`), while watch-DSL filter keys are **camelCase** (`niceClasses: [9]`). If you can build the search, you can save it as a watch — just translate the key casing.

## The query DSL

This is the canonical accepted shape — it is validated strictly, and anything outside it returns `400`:

```ts theme={null}
interface WatchQuery {
  version: 'v1';                       // REQUIRED — non-empty string
  q?: string;                          // keyword query (required for similarity)
  filters?: WatchFilters;              // camelCase keys — see the full list below
  trigger_events?: WatchTriggerEvent[]; // non-empty subset of the five events; default = first three
  score_threshold?: number;            // similarity only, JSON number 0..1
}
```

### `version` (required)

Always `"v1"`. Omitting it returns `400`.

### `q` keyword constraints

* Whitespace-separated. Up to 20 keywords. Each keyword must be at least 3 characters.
* Stop words are rejected with `400` to prevent watches that match too broadly. The current list is `the, and, or, not, for, a, an, of, in, on, to, is`.

### `filters` — the full key list

Filter keys are **camelCase**. Unknown keys — including snake\_case typos like `nice_classes` — are rejected with `400` (previously they were stored and silently ignored, making the watch broader than intended; the error message suggests the camelCase spelling when it recognizes the typo).

Allowed keys:

`applicationNumber`, `attorneyFirmName`, `attorneyId`, `challengeStates`,
`expiryDate`, `filingDate`, `filingRoute`, `firmId`, `goodsServicesText`,
`hasMedia`, `hasProceedings`, `irNumber`, `isMadrid`, `isRetracted`,
`isSeriesMark`, `jurisdictions`, `markFeatureType`, `markLegalCategory`,
`niceClasses`, `office`, `offices`, `originOfficeCode`, `ownerCountry`,
`ownerHasLei`, `ownerId`, `ownerLei`, `ownerName`, `ownerPubliclyTraded`,
`ownerTicker`, `publicationDate`, `registrationDate`, `registrationNumber`,
`renewalDueDate`, `rightKind`, `scopeKind`, `statusPrimary`, `statusReason`,
`statusStage`, `terminationDate`, `trademarkIds`, `updatedAt`, `viennaCodes`.

Notes:

* **ID filters accept both forms.** `trademarkIds`, `ownerId`, `attorneyId`,
  and `firmId` accept prefixed IDs (`tm_*`, `own_*`, `att_*`, `firm_*`) or
  raw UUIDs. Wrong-type or malformed IDs return `400` with the offending
  index in the field path.
* **Office codes are case-insensitive** — `filters.offices: ["USPTO"]` is
  accepted and stored lowercase (`["uspto"]`). Each code must be a 2–10
  character string that exists in [`GET /v1/offices`](/api-reference/reference/list-offices).
* `filters.jurisdictions` (e.g. `["US", "EU"]`) is auto-translated to
  `filters.offices` at create time, so either spelling of scope works.

### `trigger_events`

Any **non-empty** subset of these five values (anything else returns `400`; an empty array is also rejected — omit the field to subscribe to the default set):

**Default set** (applied when `trigger_events` is omitted):

* `trademark.created`
* `trademark.updated`
* `trademark.status_changed`

**Opt-in** (valid, but only delivered when you list them explicitly):

* `trademark.retracted`
* `trademark.corrected`

Narrow the default set to silence events you don't care about. For example, a portfolio watch that only fires on status changes:

```ts theme={null}
trigger_events: ['trademark.status_changed']
```

Or subscribe to the default events **plus** retractions:

```ts theme={null}
trigger_events: [
  'trademark.created',
  'trademark.updated',
  'trademark.status_changed',
  'trademark.retracted',
]
```

<Note>
  **`trademark.retracted` / `trademark.corrected` are about the source feed, not legal status.** A `trademark.retracted` alert fires on a pure `is_retracted` **false → true** flip — the upstream office feed pulled or retracted the record. `trademark.corrected` fires on the reverse **true → false** flip, when a previously-retracted record reappears (a source-side correction). These are distinct from a **legal cancellation**, which surfaces as `trademark.status_changed` (see the cancellation note below). Both are **opt-in**: a watch that omits `trigger_events`, or that lists only the default three, will never receive them — existing watches are unaffected.
</Note>

<Note>
  **Cancellation monitoring is built in.** Whenever a matching mark's status changes — including when it is **cancelled, withdrawn, abandoned, or otherwise goes dead** — the watch fires a `trademark.status_changed` alert. A cancellation is a status transition (the mark's stage moves to `cancelled` and its primary status to `inactive`), so any mark, owner, portfolio, or class watch already covers it with no special setup. Subscribe to `trademark.status_changed` (or leave `trigger_events` at the default) to be alerted the moment a blocking mark is cancelled.
</Note>

### `score_threshold` (similarity only)

For similarity watches, alerts fire only when `_score >= score_threshold`. It must be a JSON **number** between 0 and 1 inclusive — a quoted string like `"0.85"` returns `400`. The field is silently ignored for other watch types.

* `0.7` is a reasonable starting point.
* `0.85` is appropriate for production opposition workflows.

Use [Preview](#preview-before-you-launch) to estimate volume at different thresholds before going live.

## `customer_reference` — your own label

`customer_reference` is a top-level field on the **watch** (alongside `name` and `query`, not inside the query DSL) — a free-text passthrough string you control. Signa never interprets it; it's there for your own correlation (a case number, internal watch ID, team name, routing key).

* Set it on **create**, **`PATCH`**, or in a **bulk** create — max **200 characters**. Pass `null` to clear it.
* It's returned on the watch resource (`GET /v1/watches/{id}`).
* It is **frozen at emit time** and echoed onto every alert the watch produces, as `data.customer_reference` — on both the REST `Alert` resource and the `alert.created` webhook body. (Changing it later affects only alerts emitted after the change; already-emitted alerts keep the value they were stamped with.)

```ts theme={null}
await signa.watches.create({
  name: 'Acme core mark -- status changes',
  watch_type: 'mark',
  customer_reference: 'matter-2026-0481',
  query: {
    version: 'v1',
    filters: { trademarkIds: ['tm_018f9b2e-9b6c-7c9c-b4f1-1234567890ab'] },
    trigger_events: ['trademark.status_changed'],
  },
});
```

## Per-jurisdiction watches

Build separate watches per jurisdiction when you need different delivery cadence per region, or when different regional teams own different jurisdictions. Otherwise pass `filters.jurisdictions: ["US", "EU", "GB"]` once and let a single watch cover the set.

```ts theme={null}
await signa.watches.create({
  name: 'Apple Inc -- class 9 worldwide',
  watch_type: 'owner',
  query: {
    version: 'v1',
    filters: {
      ownerId: 'own_018f9b2e-9b6c-7c9c-b4f1-1234567890ab',
      niceClasses: [9],
      jurisdictions: ['US', 'EU', 'GB', 'CA'],
    },
  },
});
```

## Worked examples

### Track one mark

```ts theme={null}
await signa.watches.create({
  name: 'My core mark -- status changes',
  watch_type: 'mark',
  query: {
    version: 'v1',
    filters: { trademarkIds: ['tm_018f9b2e-9b6c-7c9c-b4f1-1234567890ab'] },
    trigger_events: ['trademark.status_changed'],
  },
});
```

### Track a portfolio

```ts theme={null}
await signa.watches.create({
  name: 'Q4 acquisitions portfolio',
  watch_type: 'portfolio',
  query: {
    version: 'v1',
    filters: {
      trademarkIds: [
        'tm_018f9b2e-9b6c-7c9c-b4f1-1234567890ab',
        'tm_018f9b2e-9b6c-7c9c-b4f1-1234567890ac',
        'tm_018f9b2e-9b6c-7c9c-b4f1-1234567890ad',
      ],
    },
  },
});
```

### Track a Nice class

```ts theme={null}
await signa.watches.create({
  name: 'New class-9 filings in US/EU',
  watch_type: 'class',
  query: {
    version: 'v1',
    filters: { niceClasses: [9], jurisdictions: ['US', 'EU'] },
    trigger_events: ['trademark.created'],
  },
});
```

### Detect a similar mark

```ts theme={null}
await signa.watches.create({
  name: 'Marks similar to ACME',
  watch_type: 'similarity',
  query: {
    version: 'v1',
    q: 'ACME',
    filters: { niceClasses: [9, 35] },
    score_threshold: 0.85,
  },
});
```

## Preview before you launch

Dry-run the query against the last N days of data to estimate volume:

```ts theme={null}
const preview = await signa.watches.preview({
  query: myQuery,
  trial_window_days: 30,
});
console.log(`${preview.estimated_match_count} alerts in the last 30 days`);
```

Preview uses the same evaluation logic as live watches, so the count is faithful. If you see thousands of matches, the query is too broad -- tighten `filters` or raise `score_threshold`.

Preview semantics worth knowing before you script against it (full details on
[Preview Watch](/api-reference/monitoring/watches/preview)):

* **`estimate_basis`.** When the response carries
  `estimate_basis: "candidacy_upper_bound"`, the count is an upper-bound
  estimate, not an exact match count (the scan hit the server-side cap, the
  search backend was unreachable, or the \~20s time budget expired partway).
  Absent field = exact count.
* **One preview at a time.** A second concurrent preview for your org
  returns `429` with a `Retry-After` header (\~20s). Honor it -- the SDK does.
* **`preview_timeout` (504).** If the time budget expires before any usable
  result exists you get a `preview_timeout` envelope with
  `retryable: false`. Narrow the query instead of retrying.
* **Latency.** `class` / `mark` / `owner` previews complete in a few
  seconds; broad `similarity` previews are the heaviest and may approach the
  budget.
* **ID forms.** Preview accepts the same prefixed (`tm_*` / `own_*`) or
  raw-UUID ID filters as create.

## Create up to 100 at once

```ts theme={null}
await signa.watches.bulk({ watches: [w1, w2, w3, /* ... */] });
```

The whole batch validates upfront. Partial failures don't insert -- all or nothing.

## See what a watch would catch

To dry-run a watch before you create or update it, use [`POST /v1/watches/preview`](/api-reference/monitoring/watches/preview). By default it returns the actual matching marks (set `count_only: true` to get just the count). After you update a live watch (widen its offices, broaden `trigger_events`), the new criteria take effect automatically on the next ingestion sync — the watch re-evaluates with its current shape going forward. See [Preview before you launch](#preview-before-you-launch).

## What's not allowed in `query`

These are rejected with `400`:

* **DSL/presentation keys** (anywhere in the query, at any depth): `function_score`, `script_score`, `script`, `sort`, `cursor`, `aggregations`, `aggs`, `highlight` -- scripting and presentation concerns don't belong in a saved monitor.
* **`query.match` in any form** -- both the object form (`match: {"nice_classes": [9]}`) and the string form (`match: "fuzzy"`). Neither has ever been honored by the evaluator. Matching is driven by `watch_type` plus `q` / `score_threshold` (similarity) and the scoping fields under `filters`.
* **Unknown `filters` keys** -- including snake\_case typos of valid keys (`nice_classes` instead of `niceClasses`). See [the full key list](#filters--the-full-key-list).
* **Unknown `trigger_events` values** (anything outside the five listed in [`trigger_events`](#trigger_events)) and empty `trigger_events` arrays.

## Common errors

| Status | Cause                                                                                                                                    |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Invalid query DSL, stop words in `q`, more than 20 keywords, a forbidden key, or a missing required field for the selected `watch_type`. |
| `409`  | Your plan's watch limit has been reached.                                                                                                |
| `413`  | `query` exceeds 256 KB.                                                                                                                  |

See [Create Watch](/api-reference/monitoring/watches/create) for the full error schema.
