Skip to content
unzoi docs
Search and navigation
Start here
REST API
MCP
Limits and plans
Agent clients
SDKs
Guides

Watches

A watch is a /stories query the index runs for you on an interval. It records what changed as events, and can POST each one to a webhook.

A monitoring loop polls, keeps a watermark, remembers which stories it has reported, and checks that each answer was complete before it moves on. A watch is that loop, run by the index against your key. You save the query once. You read the events when you want them, or receive them as they happen.

OperationMCP toolWhat it does
GET /watches list_watches This account's watches.
POST /watches create_watch Save a story query the index runs for you.
GET /watches/{id} get_watch One watch.
DELETE /watches/{id} delete_watch Delete a watch.
GET /watches/{id}/events watch_events A watch's events, newest first.

How a watch runs

Each run asks /stories for the saved query, newest stories first, over a window that starts one interval before the last complete run. The overlap catches articles that reached the index late. A new watch is due at once, and its first run looks back one interval, or an hour if that is longer, so a story already running when you create the watch is reported once.

A run reads the 100 newest stories in its window. A query that matches more than that in one interval is too broad for a watch: narrow it, or shorten the interval.

The watch owns its window, its page and its order. from, to, offset, limit, sort and signal_percentile_min are not part of a watch, and sending one is 400 with code: unknown_parameter.

How often: interval_minutes

Required. A whole number of minutes, from your plan's floor up to 1440, one day. Each run costs its query's credits, so the floor is what bounds what a watch can spend.

PlanWatchesShortest intervalRequests a month, one watch at that interval
Free 2 60 minutes ~720
Build 25 15 minutes ~2,880
Scale 200 5 minutes ~8,640
Archive 1,000 1 minute ~43,200

An interval below the floor, or above 1440, is 400 with code: invalid_parameter, and the detail names your plan's floor.

How big: minimum_outlets

A story is reported once it has reached minimum_outlets distinct outlets: 1 to 100, default 2. The default skips the single-outlet items that make up most of any feed. At 1, every story the query matches is reported the first time a run sees it. Outlets are domains, not newsrooms, for the reason /stories gives.

What to record: notify_on

Two kinds of event. notify_on names the ones to record; the default is both.

  • new_story: a run sees a story at or above minimum_outlets for the first time.
  • story_growth: a story already reported crosses 5, 15, 40 or 100 outlets, or has 10 more outlets than at its last event.

A story reported new at 3 outlets is reported again at 5, a step. At 12 it is not: no step was crossed and it grew by 7. At 15 it is, a step; at 25, ten more; at 40, a step; then every ten more. A story that jumps from 3 to 45 between two runs is one event, with previous_outlets 3 and current_outlets 45. The steps are geometric on purpose: 3 to 15 is news, 40 to 50 is the same story.

With story_growth alone, the run that first sees a story at minimum_outlets records nothing, and growth is counted from the outlets it saw then. A story the watch has not seen for seven days is forgotten; if it comes back, it is new again.

Statuses

status on a watch says whether it is running, and if not, why.

  • active: running on its interval.
  • paused_key: the key that created the watch was revoked or suspended. Nothing runs and nothing is charged. The watch is checked again each interval and runs as soon as the key is valid. A watch belongs to its key: to keep one running under a new key, create it again with that key.
  • paused_credits: the key stops at its allocation, and a run's credits do not fit in what is left this month. Only such a key pauses. The watch is checked again after an hour, or after its interval if that is longer, and runs again when the month resets or the cap is lifted. A key that bills overage never pauses for credits: its watches keep running, and the runs are billed as overage.
  • failing: eight events in a row ended with their webhook delivery failed. The watch still runs, still records events, and still tries each new delivery. The next delivery that succeeds clears it.

consecutive_incomplete is not a status, and it is worth watching as closely. Below.

Create a watch

POST https://api.unzoi.com/watches

Save a story query the index runs for you.

The index runs the /stories query every `interval_minutes` and records an event when a story first reaches `minimum_outlets` (`new_story`) or keeps growing (`story_growth`). Read events from /watches/{id}/events; with `webhook_url` each is also POSTed, signed with the `webhook_secret` returned only in this response. Each run is charged the credits its query takes, and each webhook delivery attempt 1 credit, to the key that created the watch; a run whose answer is partial is asked again. The plan caps the number of watches (`watch_limit`) and the shortest interval. Equivalent to the `create_watch` MCP tool. Credits: 1 to create. Every run is then charged the credits its query takes, and every webhook delivery attempt costs 1, retries included. Pass estimate=true to get the exact range without running the call, or max_credits to refuse anything costlier.

Body

A JSON object. These fields describe the watch:

Name Type Description
label string A label for the watch, for your own lists. Not a filter: `name` is the proper-name filter, as on list_stories.
interval_minutes required integer How often the query runs, in minutes. The plan sets the floor (free 60, build 15, scale 5, archive 1); each run is one request.
minimum_outlets integer Report a story once it has reached this many distinct outlets (default 2).
notify_on `new_story` · `story_growth`[] Which events to record: `new_story` (a story first reaches minimum_outlets) and `story_growth` (it crosses 5, 15, 40 or 100 outlets, or grows by 10 since the last event). Default both.
webhook_url string Optional https URL on a public host. Each event is POSTed as JSON, signed with the webhook_secret returned when the watch is created (header x-unzoi-signature: sha256=<HMAC-SHA256 of the body>), and retried after 1, 5 and 25 minutes.

label names the watch in your own lists. It is not a filter. name in the body is the proper-name filter, part of the query like any other, as on /stories. In 0.4.0 the label was name, so a watch labelled that way saved its label as a filter and matched nothing: delete it and create it again with label.

Everything else in the body is the /stories query the watch runs, with the names and values the query string takes. Each group is described in full on the page linked beside it. signals takes either the packed name:min:max string or the array of {name, min, max} objects the tools use.

Ranking
qmode
Filters
amount_objectauthorcitycountrylanguagelocationlocation_idmentioned_datenameorganizationpersonpublisher_countryquote_verbregionsourcesource_typestory_idtopic
Entity ids
entity_id
Place
nearradius_kmbbox
Event classes
event_type
Amounts
amount_minamount_max
Signals
signalsignal_minsignal_maxsignals

The query is checked by the same parser /stories uses before anything is saved. An unknown field is unknown_parameter with the name you probably meant, an unpublished signal is unknown_signal, and a value out of range is invalid_parameter. A watch cannot save a query the endpoint would refuse.

Response

201, with the watch as saved and its webhook secret.

Field Type Description
consecutive_failures integer Deliveries in a row that exhausted their retries.
consecutive_incomplete integer Runs in a row whose answer was partial or failed.
created_at string 14-digit YYYYMMDDHHMMSS UTC.
id string The watch id.
interval_minutes integer How often the query runs.
label string The label given at creation; empty when none was. Not a filter: a proper-name filter is `name` in `query`.
last_run_at string When the query last ran.
minimum_outlets integer Outlets a story needs before it is reported.
next_run_at string When it runs next.
notify_on `new_story` · `story_growth`[] The event kinds recorded.
query object The /stories parameters the watch runs, as query-string values.
status `active` · `paused_key` · `paused_credits` · `failing` `active`; `paused_key` when the owning key was revoked or suspended; `paused_credits` when the owning key stops at its allocation and does not have a run's credits left this month (keys that bill overage keep running), checked again after an hour or the interval, whichever is longer; `failing` after 8 deliveries in a row exhausted their retries (events are still recorded).
watermark string The start of the last run that came back complete. The next run overlaps it by one interval; an incomplete run leaves it where it was.
webhook_secret string The secret deliveries are signed with. Returned only here: store it. Null when the watch has no webhook_url.
webhook_url string Where events are POSTed, or null for the event feed only.
curl -s -X POST "https://api.unzoi.com/watches" -H "x-api-key: $UNZOI_KEY" \
  -H "content-type: application/json" \
  -d '{
    "label": "port congestion",
    "q": "port congestion OR container backlog",
    "language": "eng",
    "interval_minutes": 15,
    "minimum_outlets": 3,
    "webhook_url": "https://hooks.example.com/unzoi"
  }'
{
  "id": "w-20260914081500-3f9a1c2b7d4e",
  "label": "port congestion",
  "query": { "language": "eng", "q": "port congestion OR container backlog" },
  "interval_minutes": 15,
  "minimum_outlets": 3,
  "notify_on": ["new_story", "story_growth"],
  "webhook_url": "https://hooks.example.com/unzoi",
  "status": "active",
  "created_at": "20260914081500",
  "last_run_at": null,
  "next_run_at": "20260914081500",
  "watermark": null,
  "consecutive_failures": 0,
  "consecutive_incomplete": 0,
  "webhook_secret": "whsec_7c2f91d04ab35e68c1f09a7b2d4e6f8013579bdf2468ace0"
}

The watch cap

An account keeps at most its plan's number of watches, counted across all its keys. Creating one more is 400 with code: watch_limit; the detail says how many the plan allows and how many the account has. Delete one, or change plan. GET /watches reports both numbers as count and limit.

Status Meaning
201 Success
400 Invalid request, charged nothing. Bounds, enums, dates and offsets are validated BEFORE any index work, so this never means a partially-served query. `credit_budget_exceeded`: the call's worst case is more than `max_credits`.
401 Missing or invalid API key. `code` is `unauthorized`.
402 Account suspended (subscription canceled or payment failed). `code` is `suspended`.
429 Refused before running, and charged nothing. `credit_rate_limited`: this minute's credits are spent. `message_rate_limited`: too many free calls or refused requests this minute. `insufficient_credits`: the key stops at its monthly allocation and does not have this call's worst case left. `retry-after` says how long to wait.
500 The watch store could not be read or written (`code` `internal`). Retry; a create that failed here created nothing.

List watches

GET https://api.unzoi.com/watches

This account's watches.

Every watch this account keeps, with its status and schedule, and the plan's cap. Equivalent to the `list_watches` MCP tool. Credits: 1. Pass estimate=true to get the exact range without running the call, or max_credits to refuse anything costlier.

Field Type Description
count integer How many watches the account has.
limit integer How many the plan allows.
watches Watch[] Oldest first.

Watch

Field Type Description
consecutive_failures integer Deliveries in a row that exhausted their retries.
consecutive_incomplete integer Runs in a row whose answer was partial or failed.
created_at string 14-digit YYYYMMDDHHMMSS UTC.
id string The watch id.
interval_minutes integer How often the query runs.
label string The label given at creation; empty when none was. Not a filter: a proper-name filter is `name` in `query`.
last_run_at string When the query last ran.
minimum_outlets integer Outlets a story needs before it is reported.
next_run_at string When it runs next.
notify_on `new_story` · `story_growth`[] The event kinds recorded.
query object The /stories parameters the watch runs, as query-string values.
status `active` · `paused_key` · `paused_credits` · `failing` `active`; `paused_key` when the owning key was revoked or suspended; `paused_credits` when the owning key stops at its allocation and does not have a run's credits left this month (keys that bill overage keep running), checked again after an hour or the interval, whichever is longer; `failing` after 8 deliveries in a row exhausted their retries (events are still recorded).
watermark string The start of the last run that came back complete. The next run overlaps it by one interval; an incomplete run leaves it where it was.
webhook_url string Where events are POSTed, or null for the event feed only.
# Every watch that is not running normally, and why.
curl -s "https://api.unzoi.com/watches" -H "x-api-key: $UNZOI_KEY" \
  | jq -r '.watches[] | select(.status != "active" or .consecutive_incomplete > 0)
           | "\(.id)\t\(.status)\t\(.consecutive_incomplete)\t\(.label)"'
Status Meaning
200 Success
400 Invalid request, charged nothing. Bounds, enums, dates and offsets are validated BEFORE any index work, so this never means a partially-served query. `credit_budget_exceeded`: the call's worst case is more than `max_credits`.
401 Missing or invalid API key. `code` is `unauthorized`.
402 Account suspended (subscription canceled or payment failed). `code` is `suspended`.
429 Refused before running, and charged nothing. `credit_rate_limited`: this minute's credits are spent. `message_rate_limited`: too many free calls or refused requests this minute. `insufficient_credits`: the key stops at its monthly allocation and does not have this call's worst case left. `retry-after` says how long to wait.
500 The watch store could not be read or written (`code` `internal`). Retry; a create that failed here created nothing.

Get a watch

GET https://api.unzoi.com/watches/{id}

One watch.

The watch's query, schedule and status. Equivalent to the `get_watch` MCP tool. Credits: 1. Pass estimate=true to get the exact range without running the call, or max_credits to refuse anything costlier.

  • id — The watch id.

Returns one Watch, without its secret. A watch that belongs to another account is 404, the same as one that does not exist. The same request is the get_watch tool. Read status, last_run_at, next_run_at, watermark and consecutive_incomplete to learn why a watch is quiet before creating another.

Status Meaning
200 Success
400 Invalid request, charged nothing. Bounds, enums, dates and offsets are validated BEFORE any index work, so this never means a partially-served query. `credit_budget_exceeded`: the call's worst case is more than `max_credits`.
401 Missing or invalid API key. `code` is `unauthorized`.
402 Account suspended (subscription canceled or payment failed). `code` is `suspended`.
404 No watch with that id on this account (`code` `not_found`). Another account's watch is reported the same way.
429 Refused before running, and charged nothing. `credit_rate_limited`: this minute's credits are spent. `message_rate_limited`: too many free calls or refused requests this minute. `insufficient_credits`: the key stops at its monthly allocation and does not have this call's worst case left. `retry-after` says how long to wait.
500 The watch store could not be read or written (`code` `internal`). Retry; a create that failed here created nothing.

Delete a watch

DELETE https://api.unzoi.com/watches/{id}

Delete a watch.

Removes the watch, its state and its events. Equivalent to the `delete_watch` MCP tool. Credits: 1. Pass estimate=true to get the exact range without running the call, or max_credits to refuse anything costlier.

  • id — The watch id.
Field Type Description
deleted boolean Always true.
id string The deleted watch.

The definition goes first, then the watch's state and its events. A run in progress when you delete does not bring the watch back. Deleting is also how a watch is changed: delete it and create it again. The new watch starts with no memory of what the old one reported, so its first run reports the stories already running as new.

Status Meaning
200 Success
400 Invalid request, charged nothing. Bounds, enums, dates and offsets are validated BEFORE any index work, so this never means a partially-served query. `credit_budget_exceeded`: the call's worst case is more than `max_credits`.
401 Missing or invalid API key. `code` is `unauthorized`.
402 Account suspended (subscription canceled or payment failed). `code` is `suspended`.
404 No watch with that id on this account (`code` `not_found`). Another account's watch is reported the same way.
429 Refused before running, and charged nothing. `credit_rate_limited`: this minute's credits are spent. `message_rate_limited`: too many free calls or refused requests this minute. `insufficient_credits`: the key stops at its monthly allocation and does not have this call's worst case left. `retry-after` says how long to wait.
500 The watch store could not be read or written (`code` `internal`). Retry; a create that failed here created nothing.

Read a watch's events

GET https://api.unzoi.com/watches/{id}/events

A watch's events, newest first.

`new_story` and `story_growth` events with the story's counts and each webhook delivery's status, kept seven days. Equivalent to the `watch_events` MCP tool. Credits: 1. Pass estimate=true to get the exact range without running the call, or max_credits to refuse anything costlier.

  • id — The watch id.
Name Type Description
after string Return events older than this event id: pass the previous page's `next_after`.
limit integer Events per page, 1-100 (default 20).
estimate boolean Return this call's price instead of running it: the credit range, a breakdown, your remaining credits and whether the call would run. A preview costs 0 credits.
max_credits integer Refuse the call, at no cost, when its worst case would cost more than this many credits. The refusal carries the estimate.

The feed is always there, webhook or not. A webhook is a copy of each event pushed to you; the feed is the record. Events are kept seven days.

Paging

Newest first, 20 to a page by default and at most 100. next_after is set when older events remain: pass it as after for the next page. null means you have reached the oldest event kept.

Events are ordered by id, which begins with recorded_at: when the run that recorded the event was due. A later run's events always sort above an earlier run's, so the feed read newest first is the order events were recorded in. To pick up what is new, read from the top until you reach an id you have already handled, and stop there. Keeping the newest id you handled is enough.

occurred_at is still the publication time of the story's newest article when the event was recorded. Articles reach the index at different lags, so an event recorded later can carry an earlier occurred_at. Use it to say when the story moved, not to order the feed or to mark your place in it.

The id is recorded_at, then the kind, the story and, for growth, the outlet count. A repeated run names the same events, and a story crossing a step it has already been reported at is not recorded again, so an event is never recorded twice.

Response

Field Type Description
events WatchEvent[] Newest first; kept seven days.
next_after string Pass as `after` for the next page; null on the last.
watch_id string The watch.

WatchEvent

recorded_at is when the run was due; occurred_at is the story's newest article. previous_outlets and current_outlets appear on story_growth only. delivery is always present. Pass story.story_id to /stories/{id} for the whole story.

Field Type Description
current_outlets integer On story_growth only: outlets now.
delivery WatchDelivery The webhook delivery; its status is `none` when the watch has no webhook_url.
id string Stable event id: when the run that recorded it was due, then the kind, the story and, for growth, the outlet count. Ids sort in the order events were recorded, so reading newest first until an id you have handled finds everything new. Pass as `after` to page older.
kind `new_story` · `story_growth` `new_story` or `story_growth`.
occurred_at string The publication time of the story's newest article when the event was recorded, 14-digit YYYYMMDDHHMMSS UTC. It can be earlier than an older event's, because articles are indexed some time after they are published.
previous_outlets integer On story_growth only: the outlets the story had at its last reporting threshold (its first report or its last growth step), whether or not that step was recorded as an event.
recorded_at string When the run that recorded the event was due, 14-digit YYYYMMDDHHMMSS UTC. The feed is ordered by it.
story WatchStory The story as the run saw it.
watch_id string The watch.

WatchDelivery

Every event carries delivery, webhook or not. Its status is none when the watch has no webhook, with no attempts, pending while attempts remain, delivered once one got a 2xx, and failed after the fourth attempt did not.

Field Type Description
attempts integer Attempts so far.
delivered_at string When it was delivered.
last_error string Why the last attempt failed.
last_status integer The HTTP status of the last attempt, if it got one.
next_attempt_at string When the next attempt is due.
status `none` · `pending` · `held_credits` · `delivered` · `failed` `none` when the watch has no webhook; `held_credits` while the owning key, which stops at its allocation, has no credit for the next attempt (retried hourly, attempts not counted); `failed` after four attempts, or when a held event reaches its seven-day retention. Every attempt costs 1 credit.
curl -s "https://api.unzoi.com/watches/w-20260914081500-3f9a1c2b7d4e/events?limit=2" -H "x-api-key: $UNZOI_KEY"
{
  "watch_id": "w-20260914081500-3f9a1c2b7d4e",
  "events": [
    {
      "id": "20260914101500-story_growth-s-9f2c-15",
      "watch_id": "w-20260914081500-3f9a1c2b7d4e",
      "kind": "story_growth",
      "recorded_at": "20260914101500",
      "occurred_at": "20260914101200",
      "story": {
        "story_id": "s-9f2c",
        "title": "Container backlog spreads to northern European ports",
        "url": "https://example.com/backlog",
        "count": 22,
        "outlets": 15,
        "published_at": "20260914101200"
      },
      "previous_outlets": 5,
      "current_outlets": 15,
      "delivery": {
        "status": "delivered",
        "attempts": 1,
        "last_status": 204,
        "last_error": null,
        "next_attempt_at": null,
        "delivered_at": "20260914101502"
      }
    },
    {
      "id": "20260914091500-story_growth-s-9f2c-5",
      "watch_id": "w-20260914081500-3f9a1c2b7d4e",
      "kind": "story_growth",
      "recorded_at": "20260914091500",
      "occurred_at": "20260914090500",
      "story": {
        "story_id": "s-9f2c",
        "title": "Rotterdam terminals report container backlog",
        "url": "https://example.com/rotterdam",
        "count": 6,
        "outlets": 5,
        "published_at": "20260914090500"
      },
      "previous_outlets": 3,
      "current_outlets": 5,
      "delivery": {
        "status": "delivered",
        "attempts": 2,
        "last_status": 200,
        "last_error": null,
        "next_attempt_at": null,
        "delivered_at": "20260914091601"
      }
    }
  ],
  "next_after": "20260914091500-story_growth-s-9f2c-5"
}
Status Meaning
200 Success
400 Invalid request, charged nothing. Bounds, enums, dates and offsets are validated BEFORE any index work, so this never means a partially-served query. `credit_budget_exceeded`: the call's worst case is more than `max_credits`.
401 Missing or invalid API key. `code` is `unauthorized`.
402 Account suspended (subscription canceled or payment failed). `code` is `suspended`.
404 No watch with that id on this account (`code` `not_found`). Another account's watch is reported the same way.
429 Refused before running, and charged nothing. `credit_rate_limited`: this minute's credits are spent. `message_rate_limited`: too many free calls or refused requests this minute. `insufficient_credits`: the key stops at its monthly allocation and does not have this call's worst case left. `retry-after` says how long to wait.
500 The watch store could not be read or written (`code` `internal`). Retry; a create that failed here created nothing.

Webhooks

With a webhook_url, each event is also POSTed to you as JSON, signed with the watch's webhook_secret. The first attempt follows the run that recorded the event.

Where a webhook may point

https only, on a public address, with no user name or password in the URL: deliveries are signed instead. The host must resolve, and every address it resolves to must be public. These are refused:

  • loopback, and the private ranges 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16;
  • link-local 169.254.0.0/16, which holds the cloud metadata address 169.254.169.254;
  • carrier-grade NAT 100.64.0.0/10 and benchmarking 198.18.0.0/15;
  • documentation, multicast, broadcast, reserved and unspecified addresses, 0.0.0.0/8 and 192.0.0.0/24;
  • the IPv6 equivalents: loopback, unspecified, multicast, unique-local fc00::/7, link-local fe80::/10, documentation 2001:db8::/32 and the NAT64 prefix 64:ff9b::/96;
  • an IPv4 address inside an IPv4-mapped IPv6 address, judged as the IPv4 address it carries.

The check runs when the watch is created, where a refusal is 400 with code: invalid_parameter. It runs again before every delivery attempt, and the delivery connects to the address that check approved. A name re-pointed at a private address after the watch was created fails the delivery rather than reaching the address.

What is sent

One request per event, as JSON. Verify `x-unzoi-signature` before trusting the body. Answer any 2xx within 10 seconds: any other status, a redirect or a timeout is a failed attempt, retried after 1, 5 and 25 minutes. The signature carries no timestamp, so deduplicate on `x-unzoi-delivery`.

HeaderValue
content-typeapplication/json
x-unzoi-event The event kind.
x-unzoi-delivery The event id, the same on every attempt.
x-unzoi-signature `sha256=` and the hex HMAC-SHA256 of the raw body, keyed with the watch's webhook_secret.

The body is the event without its delivery record, with its id as event_id, published in the spec as the WatchWebhook schema under webhooks. It carries recorded_at. On new_story, previous_outlets and current_outlets are left out rather than sent as null.

Field Type Description
current_outlets integer On story_growth only; absent on new_story. Outlets now.
event_id string The event's id in the feed, also sent as `x-unzoi-delivery`. A retry repeats it, so deduplicate on it.
kind `new_story` · `story_growth` `new_story` or `story_growth`, also sent as `x-unzoi-event`.
occurred_at string The publication time of the story's newest article when the event was recorded.
previous_outlets integer On story_growth only; absent on new_story. The outlets the story had at its last reporting threshold.
recorded_at string When the run that recorded the event was due, 14-digit YYYYMMDDHHMMSS UTC.
story WatchStory The story as the run saw it.
watch_id string The watch.
POST /unzoi HTTP/1.1
host: hooks.example.com
content-type: application/json
x-unzoi-event: story_growth
x-unzoi-delivery: 20260914101500-story_growth-s-9f2c-15
x-unzoi-signature: sha256=4f1c9e0b6a2d8e3f5c7b9a1d0e2f4a6c8b0d2e4f6a8c0e1b3d5f7a9c1e3b5d7f

{"watch_id":"w-20260914081500-3f9a1c2b7d4e","event_id":"20260914101500-story_growth-s-9f2c-15","kind":"story_growth","recorded_at":"20260914101500","occurred_at":"20260914101200","story":{"story_id":"s-9f2c","title":"Container backlog spreads to northern European ports","url":"https://example.com/backlog","count":22,"outlets":15,"published_at":"20260914101200"},"previous_outlets":5,"current_outlets":15}

Answer with any 2xx within ten seconds, and do the work afterwards. Redirects are not followed. A slow answer, an error status, a redirect or a refused address is a failed attempt.

Retries

A failed attempt is tried again after 1, 5 and 25 minutes. After the fourth failed attempt the event's delivery.status is failed and it is not sent again. It stays in the feed with last_status and last_error, so after an outage on your side, page the events and handle the ones whose delivery failed.

Deliveries can arrive more than once, when your 2xx did not reach the index in time, and out of order, when a retry lands after a newer event. Deduplicate on x-unzoi-delivery. Eight events in a row that end failed put the watch in failing.

Verifying the signature

Compute HMAC-SHA256 over the raw request body, the bytes as received, with the whole webhook_secret string as the key, whsec_ prefix included. Hex-encode it, prefix sha256=, and compare it with the header in constant time. Parse the JSON only after that: parsing and re-serialising changes the bytes, and the signature will not match.

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

const SECRET = process.env.UNZOI_WEBHOOK_SECRET!; // the whole whsec_... string

export function verify(rawBody: Buffer, header: string | undefined): boolean {
  const expected = Buffer.from("sha256=" + createHmac("sha256", SECRET).update(rawBody).digest("hex"));
  const received = Buffer.from(header ?? "");
  // timingSafeEqual throws when the lengths differ, so compare those first.
  return received.length === expected.length && timingSafeEqual(received, expected);
}

createServer((req, res) => {
  const chunks: Buffer[] = [];
  req.on("data", (chunk: Buffer) => chunks.push(chunk));
  req.on("end", () => {
    const raw = Buffer.concat(chunks); // the bytes that were signed
    if (!verify(raw, req.headers["x-unzoi-signature"] as string | undefined)) {
      res.writeHead(401).end();
      return;
    }
    res.writeHead(204).end(); // answer first
    const delivery = req.headers["x-unzoi-delivery"] as string;
    handle(delivery, JSON.parse(raw.toString("utf8"))); // skip a delivery id already handled
  });
}).listen(8080);

With Express, mount express.raw({ type: "application/json" }) on the webhook route rather than express.json(), so req.body is the raw Buffer.

import hashlib
import hmac
import os

from flask import Flask, request

SECRET = os.environ["UNZOI_WEBHOOK_SECRET"].encode()  # the whole whsec_... string


def verify(raw_body: bytes, header: str | None) -> bool:
    expected = b"sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest().encode()
    return hmac.compare_digest(expected, (header or "").encode())


app = Flask(__name__)


@app.post("/unzoi")
def unzoi():
    raw = request.get_data()  # the bytes that were signed, before any JSON parsing
    if not verify(raw, request.headers.get("x-unzoi-signature")):
        return "", 401
    enqueue(request.headers["x-unzoi-delivery"], raw)  # handle it after answering
    return "", 204

The signature covers the body only and carries no timestamp, so it does not stop a captured delivery being sent again. Keeping the delivery ids you have handled does.

What a watch costs

Each run is charged what its /stories query costs, to the key that created the watch, whether or not it finds anything: 1 credit for a plain poll by recency, more with an entity_id, a threshold or a derived filter. Runs spend the monthly credits and show in /account like any other call. One plain watch at 15 minutes is about 2,880 credits a month.

  • A paused watch does not run and is not charged. On a key that stops at its allocation, a run the credits left cannot cover pauses the watch with status: paused_credits, and it is tried again hourly.
  • Creating, listing, reading and deleting watches, and each page of events, cost 1 credit each.
  • Every webhook delivery attempt costs 1 credit, retries included, so one event can cost up to 4. When a hard-capped key runs out, pending deliveries are held (delivery.status: held_credits) without counting an attempt, and resume when credits return.
  • A create with estimate=true creates nothing and answers with per_run, runs_per_month and webhook_attempts_per_event_max.

Credits and overage has the per-plan figures.

Completeness

A run reads the index through the same fan-out as /stories, and its answer carries the same partial and coverage. A run is incomplete when partial is true, when coverage is anything but complete, or when the request failed. A query with a geographic, event-class or amount filter is incomplete, with coverage: field_unavailable, while part of its window is on an index not yet rebuilt with those fields.

An incomplete run records the events it did see. It does not move watermark, and it adds one to consecutive_incomplete. The next run asks for the same window again, so a story in the part of the index that could not be read is reported late rather than never. A complete run moves the watermark and resets the count.

A consecutive_incomplete that keeps climbing is the index failing to answer this query, not a quiet topic. Check it before concluding that nothing happened. Reading completeness explains the values.

Over MCP

create_watch takes the same fields as the body above, list_watches takes none, get_watch and delete_watch take id, and watch_events takes id, after and limit. An agent asked to keep an eye on something can create a watch in one session and read its events in the next, instead of polling inside a conversation.

{
  "name": "create_watch",
  "arguments": {
    "label": "port congestion",
    "q": "port congestion OR container backlog",
    "interval_minutes": 60,
    "minimum_outlets": 3
  }
}

Watches are served by the hosted API, https://api.unzoi.com, where the key that owns them is known. A server without a watch store, such as the local stdio server, lists the 5 watch tools but answers each with a tool error carrying code: unsupported, and has no /watches routes. Over the cap, create_watch is a tool error with code: watch_limit. MCP errors has the shapes.