---
title: "Create watch"
method: POST
path: "/create_watch"
---

# Create watch

`POST /create_watch`

## Request

- Base URL: `https://newsmind.app/api/v1`
- URL: `https://newsmind.app/api/v1/create_watch`
- Auth: OAuth 2 (authorization code, authorization URL `https://newsmind.app/oauth/authorize`, token URL `https://newsmind.app/oauth/token`, scopes: `mcp`)

Save a watch so new matching articles surface later. Keyword watches (default, all plans) match the query as an exact phrase; semantic watches match by meaning (Max only). Optionally scope to one feed or folder, and deliver by email or webhook on a cadence. Returns the created watch.

## Request body

- object
  - `query` string, required: What to watch for. For keyword watches: an exact phrase — words must appear adjacent and in order, so write it like a headline ('OLED iPad mini', not 'iPad mini OLED'). For semantic watches: a natural phrase describing the topic/product (order-insensitive).
  - `label` string: Optional display name (e.g. 'M5 Mac mini'). Returned as-is by list_watches, watch_summaries and matched_watches - null when unset, with no fallback there. Only the email digest substitutes the query when the label is empty.
  - `kind` 'keyword' | 'semantic': keyword (default, all plans) or semantic (Max only — matches by meaning). Semantic on a non-Max account returns max_required.
  - `feed_id` integer: Optional: limit to one feed. Omit for all subscriptions.
  - `folder` string: Optional: limit to feeds in this subscription folder. Omit for all subscriptions.
  - `threshold` number: Semantic only: cosine similarity cutoff in (0,1]. Any value is settable — this is a dial, not a set of presets. Calibration from real data: below ~0.45 is effectively unfiltered (it sits under the median similarity of arbitrary articles and will match thousands); 0.55-0.60 is on-topic; above 0.70 is near-exact and starts missing paraphrases. The useful band is narrow and near the top. Default 0.55. The response includes `threshold_preview` — how many of this watch's matches each stricter cutoff would leave — so pick the next value from that rather than guessing.
  - `delivery` 'in_session' | 'email' | 'webhook': Where matches are delivered. in_session (default) = retrieved when you call get_watch_matches or get_new_articles. email = digest to the account email. webhook = POST to webhook_url. email and webhook default to a daily cadence when none is passed.
  - `cadence` 'realtime' | 'weekdays' | 'daily' | 'weekly': For email/webhook delivery: how often to send. weekdays = daily but Monday-Friday only (weekend matches ride Monday's digest). Defaults to daily when delivery is out-of-band.
  - `delivery_hour` integer: Daily/weekly email or webhook delivery: hour of day to send, 0-23, in the account's timezone ('email me daily at 7am' → 7). Default 8 (08:00).
  - `delivery_day` 'sunday' | 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday': Weekly cadence only: weekday to send ('weekly on friday'). Default monday.
  - `webhook_url` string: Required when delivery='webhook': an https:// endpoint we POST match batches to. Must be a public host — loopback, private and link-local addresses are rejected.
  - `require_terms` string[]: Words an article must ALSO carry to match (max 12). ANY one is enough, not all - ['missing','Columbus','Ohio State'] keeps anything mentioning any of them. Use this to disambiguate a name that points at more than one person or thing: a watch on 'Brian Shaffer' with require_terms ['missing'] keeps the missing-persons coverage and drops the baseball player's stats page and two unrelated obituaries. DO NOT append the word to `query` instead - the query is matched as an exact phrase, so 'Brian Shaffer missing' matches nothing at all. Changing this rebuilds the match set.
  - `exclude_terms` string[]: Words that DEMOTE a match rather than dropping it (max 12). Matches still appear, sorted last and tagged `user_excluded`, so an over-broad guess is recoverable. Prefer require_terms when you know what you DO want.

## Response `200`

Success

- Watch: A saved-search definition. kind is keyword (phrase matching) or semantic (Max only, matching by meaning). delivery routes matches in-session, to email digests, or to a webhook; email/webhook take a cadence and optional delivery slot (hour + weekday, in the account's timezone). Stale keyword-only shape corrected in v0.42.0 — it had lagged reality since v0.22.
  - `watch_id` integer, required
  - `query` string, required
  - `label` string, nullable: Optional human-readable name. Returned as-is and null when unset; only the email digest substitutes the query.
  - `feed_id` integer, nullable: Optional scope to one feed.
  - `folder` string, nullable: Optional scope to a subscription folder.
  - `kind` 'keyword' | 'semantic', required
  - `threshold` number, nullable: Semantic only: cosine similarity cutoff.
  - `active` boolean, required
  - `delivery` 'in_session' | 'email' | 'webhook', required
  - `cadence` 'realtime' | 'weekdays' | 'daily' | 'weekly', nullable
  - `require_terms` string[], nullable: issue #23: OR-list an article must also carry. null = no filter.
  - `exclude_terms` string[], nullable: issue #23: OR-list that demotes a match at read time. Never deletes.
  - `delivery_hour` integer, nullable: Digest delivery hour in the account's timezone; null = default 08:00.
  - `delivery_day` string, nullable: Weekly digests: weekday name; null = default monday.
  - `webhook_url` string, nullable
  - `created_at` string, date-time, required
  - `backfilled_matches` integer: create: in-window articles that already matched.
  - `match_sources` string[]: create: publishers this watch already matched. Unrelated publishers suggest the query is ambiguous.
  - `threshold_preview` object: Semantic only: cutoff -> surviving match count, for picking a stricter threshold from data.
  - `rebackfilled_matches` integer: update: match count after an edit that rebuilt the set.
  - `rebackfilled_because` 'query' | 'threshold' | 'scope' | 'require_terms': update: which edit rebuilt the match set.
  - `zeroed_by` 'query' | 'threshold' | 'scope' | 'require_terms': update: present when the edit emptied a watch that previously had matches - usually an over-narrow edit.
  - `cadence_inert` boolean: update: a cadence was set on an in_session watch, where it schedules nothing.
  - `settled_backlog` integer: update: pending matches settled rather than emailed when delivery is sped up (in_session to email, or to a faster cadence), so the first digest is not the slower setting's backlog. Still readable via get_watch_matches.
  - `settled_backlog_message` string: update: accompanies settled_backlog - what was dropped from email and where it is still readable.
  - `ok` boolean
  - `message` string
  - `error` string: Set instead of a watch when the call was refused: max_required (semantic on a non-Max plan), invalid_webhook_url (not https, or a loopback/private/link-local host), watch_limit_reached, embedding_failed.

## Other responses

- `400`: Bad request
- `401`: Unauthorized
- `402`: Subscription required (trial expired or cancelled)
- `429`: Too many calls. Either the per-account rate limit ({error: "rate_limited", message}) or the account's in-flight work ({error: <sentence>, code: "busy"}). Wait Retry-After seconds.
- `500`: Server error on our side (error: internal_error). The team has been notified.
- `503`: Temporarily unable to answer. error: timeout (the request ran too long; narrow it), server_busy or tier_unavailable (retry after Retry-After seconds when sent).

---

[API](https://skmtc.dev/newsmind/apis/newsmind-api.md) · [All operations](https://skmtc.dev/newsmind/apis/newsmind-api/llms.txt) · [OpenAPI document](https://skmtc.dev/newsmind/apis/newsmind-api/revisions/f8af23fb5a10?raw)
