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.
| Operation | MCP tool | What 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.
| Plan | Watches | Shortest interval | Requests 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 aboveminimum_outletsfor 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 deliveryfailed. 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
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
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
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 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
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/12and192.168.0.0/16; - link-local
169.254.0.0/16, which holds the cloud metadata address169.254.169.254; - carrier-grade NAT
100.64.0.0/10and benchmarking198.18.0.0/15; - documentation, multicast, broadcast, reserved and unspecified addresses,
0.0.0.0/8and192.0.0.0/24; -
the IPv6 equivalents: loopback, unspecified, multicast, unique-local
fc00::/7, link-localfe80::/10, documentation2001:db8::/32and the NAT64 prefix64: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`.
| Header | Value |
|---|---|
content-type | application/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=truecreates nothing and answers withper_run,runs_per_monthandwebhook_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.