API reference
Create a watcher
POST /v1/watchers
Creates a watcher and schedules its first run. Returns the watcher object and, when notify has a webhook, webhook_secret.
Body
| Field | Type | |
|---|---|---|
target | object | Required. What to watch. Matching products |
conditions | object | Required. { "all": [...] } or { "any": [...] }, 1 to 10. Conditions |
where | object | Required. sites (1 to 10) and aggregate |
schedule | object | Required. every and until, optionally start_at and burst. Schedules |
notify | object | Where results go. Webhooks |
location | object | country, zip, radius, currency |
fetch | object | proxy and accept_site_terms |
then | object | A follow-up watcher. Follow-up watchers |
target
| Field | Type | |
|---|---|---|
query | string | Required. A URL, an identifier or words, by match |
match | string | Required. url, sku, exact, line, category, listing |
attributes | object | Variant filters, such as { "size": 10, "color": "Black" }. A list means any of them. Some sites also read settings here (from, to, time_zone, party_size, selector, contains); those tell the site what to fetch and never filter. Sites |
exclude | string[] | Phrases that drop a product, as whole words |
Condition
| Field | Type | |
|---|---|---|
field | string | Required. price, price_drop, availability, quantity, version, release_status, release_date, date, opens_at, seller, or an attribute |
op | string | Required. = != < <= > >= in not_in exists changed |
value | any | A number, "$19.99", "10%", a date, a status, or a list for in |
compare_to | string | first_snapshot or last_snapshot |
over | object | key (sizes, dates, items), require (all, any, min_n), n, same (site, seller) |
where
| Field | Type | |
|---|---|---|
sites | string[] | Required. Hosts, repos, pages or URLs, and feed:, statuspage:, instatus: or change: names. Sites |
aggregate | string | each (default), best, first, dedupe |
schedule
| Field | Type | |
|---|---|---|
every | string | Required. "15m", "1h", "1d" |
until | string | Required. A date, "+30d", "after_first_match" or "never" |
start_at | string | First run, ISO 8601. UTC without an offset |
burst | object | near (release_date, opens_at), window, every |
notify
| Field | Type | |
|---|---|---|
channels | string[] | Required. ["webhook"] with an API key |
webhook | object | url (https) and events: matched, changed, field_found, blocked, clamped |
repeat | string | once, on_change (default), every_match |
cooldown | string | Shortest gap between sends. Default "6h" |
location and fetch
| Field | Type | |
|---|---|---|
location.zip | string | US ZIP. With radius, keeps places within that distance |
location.radius | string | "25mi" or "40km" |
location.country | string | US |
location.currency | string | USD |
fetch.proxy | string | auto (default) or off |
fetch.accept_site_terms | boolean | Required for sites whose robots.txt restricts the data. Sites |
Request
curl https://watcher-api.manwhas.shop/v1/watchers \
-H "Authorization: Bearer $SNAG_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: wool-runner-10" \
-d '{
"target": { "query": "https://www.allbirds.com/products/mens-wool-runners", "match": "url", "attributes": { "size": 10 } },
"conditions": { "all": [{ "field": "availability", "op": "=", "value": "back_in_stock" }] },
"where": { "sites": ["allbirds.com"] },
"schedule": { "every": "1h", "until": "+30d" },
"notify": { "channels": ["webhook"], "webhook": { "url": "https://you.example/snag", "events": ["matched", "blocked"] } }
}'const watcher = await fetch('https://watcher-api.manwhas.shop/v1/watchers', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SNAG_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'wool-runner-10',
},
body: JSON.stringify({
target: { query: 'https://www.allbirds.com/products/mens-wool-runners', match: 'url', attributes: { size: 10 } },
conditions: { all: [{ field: 'availability', op: '=', value: 'back_in_stock' }] },
where: { sites: ['allbirds.com'] },
schedule: { every: '1h', until: '+30d' },
notify: { channels: ['webhook'], webhook: { url: 'https://you.example/snag', events: ['matched', 'blocked'] } },
}),
}).then((response) => response.json());import os, requests
watcher = requests.post(
"https://watcher-api.manwhas.shop/v1/watchers",
headers={"Authorization": f"Bearer {os.environ['SNAG_KEY']}", "Idempotency-Key": "wool-runner-10"},
json={
"target": {"query": "https://www.allbirds.com/products/mens-wool-runners", "match": "url", "attributes": {"size": 10}},
"conditions": {"all": [{"field": "availability", "op": "=", "value": "back_in_stock"}]},
"where": {"sites": ["allbirds.com"]},
"schedule": {"every": "1h", "until": "+30d"},
"notify": {"channels": ["webhook"], "webhook": {"url": "https://you.example/snag", "events": ["matched", "blocked"]}},
},
).json()
Response
201 Created
{
"watcher_id": "wch_dhjqcvaqnhrchsnv7gtc",
"kind": "availability",
"status": "active",
"parent_id": null,
"request": { "target": { "query": "https://www.allbirds.com/products/mens-wool-runners", "match": "url", "attributes": { "size": 10 } } },
"schedule": { "every": "1h", "next_run_at": "2026-11-04T19:38:44.390Z", "until": "2026-12-04T19:38:43.902Z" },
"clamped": [],
"warnings": [],
"created_at": "2026-11-04T19:38:43.902Z",
"last_run_at": null,
"first_match_at": null,
"last_snapshot": null,
"events": [],
"webhook_secret": "whsec_Qm9vcGllcy1hcmUtbm90LWEta2V5"
}request is shortened here; it holds the full body you sent. A broken rule returns 422 with every error at once. See Errors.
A page read by a learned recipe is learned now, before the watcher is saved, so the first run already knows how to read it. If it can't be learned, the answer is 422 with recipe_not_learned.
Suggestions
On a 422, the body may carry a suggestion: a corrected request we checked by validating and previewing it. It's never applied for you. Read it, and send suggestion.request yourself if it's what you meant.
{
"error": { "code": "site_cant_match", "message": "Any online store can't run a listing match. It supports: url, line, category.", "field": "where.sites[0]" },
"errors": [{ "code": "site_cant_match", "message": "Any online store can't run a listing match. It supports: url, line, category.", "field": "where.sites[0]" }],
"suggestion": {
"request": { "target": { "query": "Q1", "match": "line" }, "where": { "sites": ["keychron.com"] } },
"explanation": ["keychron.com can't run a listing match; a line match on \"Q1\" finds 7 products."],
"evidence": { "found": 26, "matching_target": 7, "would_match_now": true, "sites": [{ "site": "keychron.com", "outcome": "ok", "found": 26, "matching_target": 7, "sample": [] }] }
}
}| Field | |
|---|---|
request | The whole corrected body, ready to send. Shortened here |
explanation | What looked wrong and what changed, in plain sentences |
evidence | What the preview of the suggestion found: items, how many fit the target, and a few samples per site |
There's no suggestion when nothing better was found in time.