API reference
Watchtower API
Post a URL and a baseline; get back what the page says now, whether it changed, and what moved. One synchronous call, JSON in and out. Three rails share one meter: 3 Ounie credits per check on REST and MCP, $0.036 USDC per check keyless over x402.
Quickstart
# 1. Record a baseline. changed comes back null — there is nothing to compare to yet.
curl -X POST https://watch.ounie.com/api/checks \
-H "Authorization: Bearer wtc_live_…" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/pricing"}'
# 2. Later, compare against it.
curl -X POST https://watch.ounie.com/api/checks \
-H "Authorization: Bearer wtc_live_…" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/pricing", "baseline": "<snapshot_id from step 1>"}'The answer comes back on the same request — typically in 5–15 seconds, since it drives a real browser. There is no job to poll.
Authentication
Three credentials are accepted, in this order:
- Your app key —
Authorization: Bearer wtc_live_…, minted at /dashboard/api-keys. Up to 5 active. Only the sha256 hash is stored; the raw token is shown once. - The fleet master key — your ounie.com developer key (
ounie_live_…) works here too, once you enable “Use across Ounie apps” in your ounie.com settings. - The shared session cookie — the dashboard uses it. Any signed-in
.ounie.combrowser session works.
Hosts that can't set a header (the Ounie AI Team's manual MCP entries, for one) may pass the key as ?api_key=wtc_live_… on any endpoint.
402 with the exact shortfall — it cannot overdraw and cannot run up a balance.POST /api/checks
| Field | Type | Meaning |
|---|---|---|
| url | string, required | The page to read. Must be a public http(s) address — a non-public one (localhost, private ranges, link-local, .internal, .local) is a 400, refused for free before any price is quoted. |
| baseline | string, optional | A previous check's snapshot_id, or its 64-hex content_hash. With an id you get a full diff; with only a hash you get changed and no “before” to show. Omit it entirely and this call records a baseline. |
| baseline_text | string, optional | Your own copy of the prior content. Gives you a full diff without us holding anything of yours. |
| selector | string, optional | A CSS selector narrowing the watch to one region. All matches are used, in document order — a selector like .price on a listing page is usually meant to cover every row. |
| schema | object, optional | A field map: { name: "css selector" } or { name: { selector, attr } }. Up to 25 fields. Mutually exclusive with selector. |
| scope | "main" | "full" | main(default) reads the page's main content, with navigation, footers, asides and advertising already dropped. full reads the whole document. A selector or schema forces full so it can reach anything. |
| ignore_volatile | string[], optional | Which self-regenerating values to mask. Defaults to timestamps, relative_times, tokens, cache_busters. Pass [] to compare raw. An unknown value is a 400. |
| ignore | string[], optional | Literal substrings stripped before comparing. Literal, not regex — a caller-supplied regular expression is a denial-of-service waiting to happen. |
| image | boolean, optional | Attach a visual snapshot. Defaults to true, runs in parallel with the render so it costs no latency, and never fails a check. |
400 json_schema_unsupported rather than quietly doing something else. Model-based extraction is not reproducible across runs or model versions, so two identical pages would routinely extract differently and be reported as changed — and every false report would bill you and fire your webhook.Narrowing a watch
Scope is almost always the difference between a useful alert and a noisy one.
// the whole main content
{ "url": "https://example.com/terms" }
// one region
{ "url": "https://example.com/status", "selector": ".incident:first-child" }
// named values
{ "url": "https://example.com/pricing",
"schema": { "pro": ".tier-pro .price", "og": { "selector": "meta[property='og:title']", "attr": "content" } } }In field mode the diff is per field, so an alert reads pro: $19.00 → $29.00. A field that stops matching comes back null, which is a different fact from matching and being empty — and both are reportable changes.
What counts as a change
changed is decided by comparing sha256 digests of the normalised extraction. Nothing is inferred and no model is involved. Before hashing:
- Always. Whitespace, indentation, line wrapping, and non-breaking or zero-width spaces. Line order is preserved — a reordered list has changed.
- By default. The volatile classes below. A page whose only difference is a rotating token would otherwise report a change on every pass, forever.
- Upstream, in `main` scope. Navigation, footers, asides and advertising are dropped by the renderer before we see the page.
| Class | Default | What it masks |
|---|---|---|
timestamps | on | Dates and clock times, in ISO, numeric and written forms. |
relative_times | on | "3 minutes ago", "just now", "yesterday". |
tokens | on | UUIDs, CSRF and nonce values, and long opaque hashes such as asset fingerprints. |
cache_busters | on | Versioning query parameters on URLs (?v=, ?t=, ?cb=, ?hash=). |
numbers | off | Every number on the page. Off by default — a number is usually the answer. |
If the page you care about genuinely publishes a meaningful date, pass "ignore_volatile": [] and compare raw, or narrow to a selector so the date is the only thing in scope. The same policy is machine-readable at GET /api/pricing and over MCP as get_change_policy.
The response
{
"ok": true,
"check": {
"id": "…",
"url": "https://example.com/pricing",
"label": "example.com/pricing",
"status": "succeeded",
"changed": true,
"diff": {
"kind": "text",
"removed": ["$19.00 per month"],
"added": ["$29.00 per month"],
"removed_count": 1, "added_count": 1,
"similarity": 0.987, "truncated": false
},
"fields": null,
"content_hash": "9f2c…",
"snapshot_id": "…", // pass this as your next baseline
"snapshot_url": "https://…", // signed, expires
"page_status": 200,
"final_url": "https://example.com/pricing",
"thin": false,
"credits_spent": 3,
"ignored": ["Dates and clock times…", "…"],
"checked_at": "…"
}
}changed is true, false, or null when there was no baseline. It is never guessed: null means we genuinely have nothing to compare against, and the call recorded a baseline instead.
snapshot_url is a picture, not the verdict. Two renders of an unchanged page produce different pixels constantly, so comparing screenshots would report a change on nearly every pass. The image is evidence a human looks at.Monitors
A monitor repeats the same check on a schedule and pushes the result when it changed. Creating one is free; each pass costs the same 3 credits a one-off check does, because it does the same work.
POST /api/monitors
{
"url": "https://example.com/pricing",
"schema": { "pro": ".tier-pro .price" },
"cadence": "daily",
"webhook_url": "https://your.app/hooks/watchtower",
"notify_email": "you@example.com"
}
# → 201 { monitor, webhook_secret } ← the secret is shown ONCE
GET /api/monitors # list
GET /api/monitors/<id> # monitor + recent passes + delivery log
PATCH /api/monitors/<id> # { "status": "paused" | "active" }
DELETE /api/monitors/<id>snapshot_id as your next baseline.The first pass records a baseline and does not alert. If your wallet cannot cover a pass, it is skipped, not failed: nothing is reserved, the monitor stays due, and the next tick after a top-up runs it. last_skip_reason says why.
Webhooks
POST <your webhook_url>
X-Watchtower-Event: watchtower.changed
X-Watchtower-Monitor: <monitor id>
X-Watchtower-Signature: sha256=<hmac of the exact body, keyed with your secret>
{ "event": "watchtower.changed", "monitor_id": "…", "check_id": "…",
"url": "…", "changed": true, "summary": "1 field changed",
"diff": { … }, "fields": { … }, "content_hash": "…",
"snapshot_url": "…", "checked_at": "…" }Verify the signature and nobody who merely learns your URL can feed you a forged alert. A delivery is attempted once per change; successes and failures are both logged and readable at GET /api/monitors/<id>, so a monitor that has been 500ing for a week is never a mystery.
A webhook URL gets the same non-public-address refusal a watched URL does. Otherwise a monitor would become exactly the thing a check refuses to be.
Thin results & refunds
A check is thin only when the page yielded nothing at all and there was no baseline to compare against — you learned nothing, so it refunds in full and credits_spent is 0.
Reading past checks
Free forever, on any rail.
GET /api/checks?limit=20&changed=true
GET /api/checks?monitor_id=<id>
GET /api/checks/<id> # add ?content=1 for the normalised extractionMCP
Endpoint https://watch.ounie.com/api/mcp (legacy SSE: /api/sse)
Auth Authorization: Bearer wtc_live_…
…or https://watch.ounie.com/api/mcp?api_key=wtc_live_…| Tool | Cost | What it does |
|---|---|---|
| check_page | 3 cr | One look at one page, compared against a baseline. |
| get_check | free | Re-read a check by id, with its diff and snapshot. |
| list_checks | free | The caller's history, newest first. |
| create_monitor | free to create | Then 3 cr per pass. Returns the webhook secret once. |
| list_monitors | free | Schedules, last results and skip reasons. |
| get_monitor | free | One monitor with its passes and delivery log. |
| set_monitor_status | free | Pause or resume. |
| delete_monitor | free | Remove it; past checks stay readable. |
| get_change_policy | free · public | Exactly what counts as a change and what is discarded. Read this before interpreting changed: false. |
| get_credit_balance | free | The account's spendable Ounie credits. |
| get_pricing | free · public | Credit price and the x402 endpoint. |
| whoami | free | The authenticated key's owner metadata. |
get_change_policy is public and exists for a reason: an agent that does not know what we ignore will read changed: false as “nothing happened” when what it really means is “nothing happened that we count”.
x402 — keyless, pay per call
Agents with no Ounie account pay in USDC on Base. Two gates protect the payer, and the order is deliberate.
- Before quoting. Anything wrong with the request itself — a malformed URL, a non-public address, an unknown ignore class, a JSON Schema where a field map belongs — returns a
4xxwith no price in the body. You are never asked to sign a payment for work that cannot succeed. The same applies while the page-reading engine is refusing: a503with no quote in it. - Before settling. The signature is verified off-chain, the check runs, and only then does the money move. A page that yielded nothing returns
402 thin_resultand is never settled — an on-chain settlement is final, so the refusal has to come first.
curl -X POST https://watch.ounie.com/api/x402/check \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/pricing"}'
# → 402 { "x402Version": 1, "accepts": [{
# "scheme": "exact", "network": "base",
# "maxAmountRequired": "36000",
# "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
# "payTo": "0x…",
# "extra": { "name": "USD Coin", "version": "2" } }] }
curl -X POST https://watch.ounie.com/api/x402/check \
-H "X-Payment: <base64 signed payload>" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/pricing","baseline":"<snapshot_id>"}'$0.036 per check. The exact scheme is an offline EIP-3009 authorization — the facilitator pays the gas, so a wallet holding only USDC can pay.
note saying so.Availability
A scheduled probe checks the page-reading engine on both edges: it heals a refusing engine once it recovers, and it re-verifies a healthy one whose evidence has gone stale. While the engine is refusing, both rails answer 503 upstream_unavailable up front and charge nothing.
That 503 carries no price of any kind — no x402Version, no accepts, no payTo — so an agent cannot read it as a quote and sign against it. Quoting a price for work we already know we cannot deliver is the failure this exists to prevent.
Errors
| Status | Body | Meaning |
|---|---|---|
| 400 | url_required · invalid_url · private_address · invalid_selector · invalid_schema · json_schema_unsupported · invalid_ignore_volatile · selector_and_schema · invalid_cadence | Something about the request is always fatal. Fix it and retry. Never carries a price. |
| 401 | unauthorized | No usable credential. Bearer routes never redirect. |
| 402 | insufficient_credits | Carries required_credits, balance_credits and buy_credits_url. |
| 402 | thin_result (x402 only) | The page yielded nothing. Your payment was not settled. |
| 422 | thin_result (credits) | The page yielded nothing. Refunded in full. |
| 429 | too_many_running · too_many_monitors | More than 5 checks in flight, or 50 active monitors. |
| 502 | check_failed | An upstream broke. You were not charged. |
| 503 | upstream_unavailable | The page-reading engine is refusing. Nothing was charged, and no price is quoted. |
Limits
| Limit | Value |
|---|---|
| Concurrent checks per owner | 5 |
| Active monitors per owner | 50 |
| Cadences | hourly · daily · weekly |
| Fields in a schema | 25 |
| Active API keys per owner | 5 |
| Price | 3 credits · $0.036 on x402 |
Credits are shared across every Ounie app and bought at ounie.com/dashboard/settings.