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 keyAuthorization: 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.com browser 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.

A key can only ever spend its owner's credits, and the reserve happens before any upstream work. An agent that runs out gets a 402 with the exact shortfall — it cannot overdraw and cannot run up a balance.

POST /api/checks

FieldTypeMeaning
urlstring, requiredThe 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.
baselinestring, optionalA 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_textstring, optionalYour own copy of the prior content. Gives you a full diff without us holding anything of yours.
selectorstring, optionalA 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.
schemaobject, optionalA 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_volatilestring[], optionalWhich self-regenerating values to mask. Defaults to timestamps, relative_times, tokens, cache_busters. Pass [] to compare raw. An unknown value is a 400.
ignorestring[], optionalLiteral substrings stripped before comparing. Literal, not regex — a caller-supplied regular expression is a denial-of-service waiting to happen.
imageboolean, optionalAttach a visual snapshot. Defaults to true, runs in parallel with the render so it costs no latency, and never fails a check.
`schema` is a field map, not a JSON Schema document. Passing a draft-07 schema returns 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.
ClassDefaultWhat it masks
timestampsonDates and clock times, in ISO, numeric and written forms.
relative_timeson"3 minutes ago", "just now", "yesterday".
tokensonUUIDs, CSRF and nonce values, and long opaque hashes such as asset fingerprints.
cache_bustersonVersioning query parameters on URLs (?v=, ?t=, ?cb=, ?hash=).
numbersoffEvery number on the page. Off by default — a number is usually the answer.
Never masked, at any setting: Prices, stock levels, counts and any other number (unless you turn on `numbers`). Version strings, release names and copyright years. The order of a list — a reordered page has changed. Capitalisation, punctuation and wording.

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>
Monitors need an Ounie account. This is the one thing the keyless x402 rail cannot sell: a recurring watch has to bill somebody on every renewal and has to have somewhere to deliver, and a keyless caller has neither. On x402, keep your own schedule and pass the previous 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.

A page that simply did not change is not thin. “No change” is the answer this product exists to give, and it bills normally. So does a selector that stopped matching — the element you were watching is gone, which is one of the most valuable things a watch can tell you.

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 extraction

MCP

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_…
ToolCostWhat it does
check_page3 crOne look at one page, compared against a baseline.
get_checkfreeRe-read a check by id, with its diff and snapshot.
list_checksfreeThe caller's history, newest first.
create_monitorfree to createThen 3 cr per pass. Returns the webhook secret once.
list_monitorsfreeSchedules, last results and skip reasons.
get_monitorfreeOne monitor with its passes and delivery log.
set_monitor_statusfreePause or resume.
delete_monitorfreeRemove it; past checks stay readable.
get_change_policyfree · publicExactly what counts as a change and what is discarded. Read this before interpreting changed: false.
get_credit_balancefreeThe account's spendable Ounie credits.
get_pricingfree · publicCredit price and the x402 endpoint.
whoamifreeThe 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 4xx with 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: a 503 with 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_result and 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.

This rail sells one check. Recurring monitors need an account. Rather than let you discover that after paying, the 402 itself carries a 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

StatusBodyMeaning
400url_required · invalid_url · private_address · invalid_selector · invalid_schema · json_schema_unsupported · invalid_ignore_volatile · selector_and_schema · invalid_cadenceSomething about the request is always fatal. Fix it and retry. Never carries a price.
401unauthorizedNo usable credential. Bearer routes never redirect.
402insufficient_creditsCarries required_credits, balance_credits and buy_credits_url.
402thin_result (x402 only)The page yielded nothing. Your payment was not settled.
422thin_result (credits)The page yielded nothing. Refunded in full.
429too_many_running · too_many_monitorsMore than 5 checks in flight, or 50 active monitors.
502check_failedAn upstream broke. You were not charged.
503upstream_unavailableThe page-reading engine is refusing. Nothing was charged, and no price is quoted.

Limits

LimitValue
Concurrent checks per owner5
Active monitors per owner50
Cadenceshourly · daily · weekly
Fields in a schema25
Active API keys per owner5
Price3 credits · $0.036 on x402

Credits are shared across every Ounie app and bought at ounie.com/dashboard/settings.