API docs
Everything in Watchpup works over JSON. Authenticate with
Authorization: Bearer <api_key>. Get a key by signing up (below) or from Settings.
Machine-readable spec: GET /openapi.json — OpenAPI 3.0 for the whole API.
Point your codegen, Postman/Bruno import, or AI agent at it.
Agents in a hurry: GET /llms.txt is a one-page plain-text quickstart.
Instant check (no account)
curl 'https://watchpup.watchpup.workers.dev/api/check?url=https://example.com'
# → {"url":"https://example.com","up":true,"http_status":200,"response_ms":143,…}
One-off "is it up right now?" check of any public URL — no signup, no key. Follows redirects,
same safety rules as monitors. Also accepts POST {"url":…}. CORS-open, so you can call it from your own pages.
Rate-limited to 10 checks/hour per IP; for continuous monitoring, create a monitor (below).
Browser version with a shareable result URL: /check.
CLI
# install (single bash script served by this API; re-run to update) curl -fsSL https://watchpup.watchpup.workers.dev/cli -o ~/.local/bin/watchpup && chmod +x ~/.local/bin/watchpup watchpup signup you@example.com # or: login / key wp_… watchpup add https://example.com # http monitor; also: tcp | tls | dns | domain | heartbeat watchpup add heartbeat nightly-backup cron="0 3 * * *" # or an interval: … nightly-backup 86400 watchpup ls # ✓/✗ table of your monitors watchpup status # summary incl. 🔧 maintenance; exit code 1 if anything is down watchpup watch # live status table + maintenance banner, refreshes until ctrl-c watchpup status tag=prod # …both take a tag filter (comma = must carry all: tag=prod,eu) watchpup incidents 7 # last 7 days watchpup check https://example.com # instant public check, no account watchpup channels add ntfy https://ntfy.sh/your-topic # alert channels (email|discord|slack|webhook|ntfy|telegram|teams|googlechat) watchpup channels # list channels + delivery health (⚠ = last delivery failed) watchpup test 1 # fire a test alert through channel 1 right now watchpup latency 2 # 24h p50/p90/p95/p99 for an http/tcp monitor watchpup export 2 # CSV of raw checks (~3d) → watchpup-name-checks.csv watchpup export 2 daily - # daily uptime rollups (~90d) to stdout watchpup backup # full account export → watchpup-export-YYYY-MM-DD.json watchpup pages add "My services" --auto # public status page with all your monitors watchpup pages subs myservices # list a page's email subscribers watchpup pages group myservices # show which section each monitor renders in watchpup pages group myservices ID "API" # group a monitor (merges the mapping for you) watchpup maint add 45m note='DB upgrade' # quiet checks + alerts for 45 minutes, starting now watchpup maint add 30m start=2026-08-01T02:00:00Z repeat=daily # nightly window; maint rm ID cancels watchpup api GET /api/monitors # raw authenticated call — the whole API
Needs only bash + curl (python3 makes the output pretty; without it you get raw JSON).
The API key is stored in ~/.config/watchpup/config (chmod 600); WATCHPUP_URL and
WATCHPUP_KEY environment variables override it. watchpup status's exit code makes it easy
to wire into shell prompts, cron jobs or CI.
MCP server (for AI agents & MCP clients)
Watchpup speaks the Model Context Protocol natively: point Claude Desktop, Claude Code,
Cursor, or any MCP-capable agent at https://watchpup.watchpup.workers.dev/mcp and it gets 12 tools —
create/update/pause/delete monitors, read incidents and acknowledge them, check any URL instantly,
read public status pages, and more. Streamable HTTP transport, stateless: authentication is just your
API key in a header, no OAuth dance, no session juggling.
Listed in the official MCP registry
as dev.workers.watchpup.watchpup/watchpup.
# Claude Code
claude mcp add --transport http watchpup https://watchpup.watchpup.workers.dev/mcp \
--header "Authorization: Bearer wp_YOUR_KEY"
# Cursor / generic mcpServers config
{
"mcpServers": {
"watchpup": {
"url": "https://watchpup.watchpup.workers.dev/mcp",
"headers": { "Authorization": "Bearer wp_YOUR_KEY" }
}
}
}
# or raw JSON-RPC with curl — list the tools:
curl -s https://watchpup.watchpup.workers.dev/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
The check_url and get_status_page tools work without any key
(same 10/hour-per-IP limit as the instant check) — everything else answers with
signup instructions until a key is configured, so an agent can bootstrap its own account. Tool calls run
through the exact same REST API rules as everything else (validation, rate limits, read-only keys honored),
and mutations show up in your activity log tagged mcp. One JSON-RPC
message per POST; no SSE stream, no server push — responses are plain JSON.
Sign up
curl -X POST https://watchpup.watchpup.workers.dev/api/signup \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"correct-horse"}'
# → {"api_key":"wp_…","user_id":1}
Forgot your password? POST /api/forgot {"email":…} emails a one-hour reset link, or use /forgot.
Create monitors
# HTTP check every 60s
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"kind":"http","name":"my api","url":"https://example.com/health","interval":60,"keyword":"ok","slow_ms":2000}'
# name is optional for every kind except heartbeat — omitted, it defaults to the URL host / target
# keyword: body must contain it (optional) · slow_ms: response-time alert threshold (optional, 100–60000, or "auto")
# keyword_absent:"absent" flips the rule — DOWN if the keyword IS in the body (catch "error", "out of stock", …)
# expect: accepted status codes — "200", "2xx,301", "200-204", "401" … (optional; default = anything <400)
# e.g. an auth-guarded endpoint that should answer 401:
# -d '{"kind":"http","name":"api auth","url":"https://example.com/private","expect":"401"}'
# Authenticated endpoints: custom headers, method + request body
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"kind":"http","name":"admin api","url":"https://example.com/api/me",
"headers":{"Authorization":"Bearer eyJ…","X-Api-Key":"abc123"},
"method":"POST","body":"{\"ping\":true}","expect":"200"}'
# headers: up to 10, sent with every check — Authorization, X-Api-Key, custom User-Agent, Cookie, content-type…
# (also accepted as a single string of "Name: value" lines; hop-by-hop/infra headers like Host or
# X-Forwarded-* are rejected; if a redirect leaves the original host, Authorization/Cookie/
# Proxy-Authorization are dropped before following — your token never reaches a third-party site)
# method: GET | HEAD | POST | PUT | PATCH | DELETE | OPTIONS (default GET)
# body: up to 4KB, sent for POST/PUT/PATCH/DELETE (set your own content-type header if it matters)
# JSON assertions: require a field in a JSON response — optionally with an exact value
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"kind":"http","name":"health json","url":"https://example.com/health",
"json_path":"data.services[0].state","json_expect":"ok"}'
# json_path: dot-separated keys + [N] array indices — "status", "data.items[0].state", "checks[2]"
# DOWN if the response isn't valid JSON or the path is missing. The status/keyword rules still apply first.
# json_expect (optional, needs json_path): DOWN unless the field equals this string —
# numbers/booleans/null compare as "3.5" / "true" / "null"; alert details show the actual value.
# PATCH json_path:"" turns the assertion off. Body parsing is capped at 256 KB.
# Response-header assertions: require a response header — optionally a value
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"kind":"http","name":"api content type","url":"https://example.com/api/health",
"header_name":"content-type","header_expect":"application/json"}'
# header_name: DOWN when the response is missing this header (names are case-insensitive) —
# catch a CDN dropping strict-transport-security, a misconfigured cache losing cache-control,
# an API that silently starts serving an HTML error page…
# header_expect (optional, needs header_name): DOWN unless the header VALUE contains this,
# case-insensitive — "application/json" matches "application/json; charset=utf-8".
# Alert details name the actual value. PATCH header_name:"" turns the assertion off.
# Note: values are as the checker sees them after the response crosses the network —
# content-encoding / content-length may differ from what your origin sent.
# TCP port check every 60s: up if the port accepts a connection
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"kind":"tcp","name":"prod postgres","target":"db.example.com:5432","slow_ms":500}'
# target: host:port — Postgres, Redis, SSH, game servers… (ports 25 and 53 blocked: Cloudflare-network limitation, as is
# anything proxied through Cloudflare — use an http monitor for those)
# response time = TCP connect latency; slow_ms works here too
# Cron heartbeat: expect a ping every hour (+10 min grace)
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"kind":"heartbeat","name":"nightly backup","interval":3600,"grace":600}'
# → includes "ping_url": https://watchpup.watchpup.workers.dev/ping/<id> — curl it from your job
# Domain expiry: checked daily against the registry (RDAP), alert 30 days out
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"kind":"domain","name":"example.com registration","domain":"example.com","warn_days":30}'
# warn_days: how many days before expiry to go DOWN and alert (1–365, default 30)
# use the registrable domain (example.com, not www.example.com); interval defaults to 1 day
# response includes "expires_at" + "days_left" once the first RDAP lookup completes
# TLS certificate expiry: real TLS handshake daily, alert 14 days out (default)
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"kind":"tls","name":"site cert","target":"example.com","warn_days":14}'
# target is host or host:port (default 443) — works for HTTPS, SMTPS :465, IMAPS :993, any TLS port
# self-signed / internal-CA certs fine (we read the cert, we don't validate the chain)
# DOWN when: within warn window, expired, not yet valid, or the TLS handshake fails
DNS record monitoring
# watch a DNS record: alert when the name stops resolving, the record type
# disappears, or no record matches an expected value
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"kind":"dns","name":"mail routing","target":"example.com","dns_type":"MX","dns_expect":"mail.example.com"}'
# target: any DNS name — subdomains and underscore names fine (www.example.com, _dmarc.example.com)
# dns_type: A | AAAA | CNAME | MX | TXT | NS | SRV | CAA (default A)
# dns_expect (optional): DOWN unless a record matches. Matching ignores case and
# trailing dots; TXT matches when a record CONTAINS the value (SPF/DKIM-friendly);
# MX/SRV can be given as just the target host ("mail.example.com" matches "10 mail.example.com.").
# Omit it and any record of the type counts as up. PATCH dns_expect:"" clears it.
# DOWN when: NXDOMAIN, SERVFAIL (broken delegation / DNSSEC), no records of the
# type, or expected-value mismatch — 2 consecutive failed checks alert, and
# transient resolver hiccups retry without ever alerting.
# checked via Cloudflare's DNS-over-HTTPS resolver; interval defaults to 300s (min 60)
# catches: botched DNS migrations, dropped MX/SPF records, hijacked or lapsed CNAMEs
Bulk import
# bring all your endpoints over in one call (≤50 items; same fields as single create)
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors/bulk \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"skip_existing":true,"monitors":[
{"name":"api","url":"https://example.com/health","keyword":"ok"},
{"name":"site","url":"https://example.com"},
{"kind":"heartbeat","name":"nightly backup","interval":86400,"grace":3600}
]}'
# → {"ok":true,"created":3,"skipped":0,"failed":0,"results":[…]} (results in input order)
# a raw JSON array works too; valid items are created even if others fail
# skip_existing:true → items matching an existing monitor (http: same URL, tcp/tls: same target, domain: same domain, heartbeat: same name) are skipped
Import from UptimeRobot or Healthchecks.io
# one call migrates your UptimeRobot monitors (read-only API key is enough:
# UptimeRobot → My Settings → API Settings → Read-Only API Key)
curl -X POST https://watchpup.watchpup.workers.dev/api/import/uptimerobot \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"api_key":"ur1234567-…"}'
# → {"ok":true,"found":7,"created":6,"skipped":0,"failed":0,"unsupported":1,"results":[…]}
# mapping: HTTP + keyword monitors → http (keyword "exists" alerts become our
# keyword_absent rule), port monitors → tcp host:port, heartbeats → heartbeat;
# ICMP ping monitors are reported as unsupported (recreate as http/tcp)
# monitors you already have are skipped by default (skip_existing:false to force)
# the key is used for a single read and never stored; up to 50 monitors fetched
# (your 15-monitor free-tier cap still applies, extra items report per-item errors)
# Healthchecks.io (or a self-hosted Healthchecks instance) — every check becomes
# a heartbeat monitor (the project's read-only key is enough:
# Healthchecks → Project Settings → API access)
curl -X POST https://watchpup.watchpup.workers.dev/api/import/healthchecks \
-H 'Authorization: Bearer wp_…' -H 'content-type: application/json' \
-d '{"api_key":"…"}' # add "base_url":"https://hc.example.com" for self-hosted
# → {"ok":true,"found":5,"created":4,"skipped":0,"failed":0,"unsupported":1,"results":[…]}
# mapping: simple checks → interval heartbeats (period ≤ 1 day), cron checks keep
# their schedule + timezone, tags come along (invalid tag names dropped), grace kept
# (clamped to 1 day); OnCalendar / non-standard schedules and periods over 1 day
# are reported unsupported with a hint. Checks with the same name as an existing
# heartbeat are skipped by default. Paused checks import safely — a heartbeat that
# has never been pinged here never alerts until its first ping arrives.
List / update / delete
curl https://watchpup.watchpup.workers.dev/api/monitors -H 'Authorization: Bearer wp_…'
curl -X PATCH https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…' \
-d '{"interval":120,"status":"paused"}' # or "new" to resume; slow_ms:0 disables slow alerts, "auto" learns a threshold
# also patchable: expect ("" = default <400), keyword ("" = off), keyword_absent ("absent"|""), sla_target (see below),
# renotify (seconds between "still down" reminders while down; 0 = off, min 300, max 86400),
# method, headers ({} or "" = clear), body ("" = clear),
# json_path ("" = off, also clears json_expect), json_expect ("" = existence-only) — http monitors
curl -X DELETE https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…'
Latency percentiles
curl https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…'
# … "latency_24h": {"count":1391,"avg_ms":184,"min_ms":102,"max_ms":2210,
# "p50_ms":161,"p90_ms":247,"p95_ms":312,"p99_ms":890} …
http and tcp monitors report 24-hour response-time percentiles (nearest-rank over successful
checks) on GET /api/monitors/<id> and on each monitor's page — averages hide tail latency;
p95/p99 show what your slowest users actually get. Public status-page JSON
(/s/<slug>.json) carries p50_ms_24h / p95_ms_24h per monitor.
latency_24h is null until a monitor has successful checks.
The same response carries trend_1h — average latency over the last hour vs the hour before
({"avg_ms_last_hour":300,"avg_ms_previous_hour":200,"change_pct":50,"direction":"slower"};
direction is slower/faster at ±15% change, else steady;
null until both hours have at least 3 successful checks). It powers the dashboard's
↗/↘ arrows and the trend line in watchpup latency. Prometheus users:
compute quantiles in PromQL from watchpup_monitor_response_ms instead
(e.g. quantile_over_time(0.95, watchpup_monitor_response_ms[1d])).
Slow-response alerts (fixed or adaptive)
curl -X PATCH https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…' \
-d '{"slow_ms": 2000}' # fixed: alert when responses exceed 2000ms
curl -X PATCH https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…' \
-d '{"slow_ms": "auto"}' # adaptive: learn the threshold from this monitor's 24h median
# … "slow_auto": true, "auto_threshold_ms": 340 … (null while still learning)
http and tcp monitors can alert when they're up but degraded: three consecutive checks over the
threshold fire a 🐢 slow alert, three back under send the ⚡ recovery (slow_ms: 0 disables).
With "auto", the threshold is 2× the monitor's own median response time over the last 24 hours,
re-learned hourly — a 40ms API and a 900ms report endpoint each get a line that fits their normal, with no
guesswork. Auto needs ~20 successful checks before it arms (auto_threshold_ms stays null
until then; enabling it on a monitor with history arms immediately) and never uses a threshold under 200ms.
Slow alerts go to all your channels; webhooks receive monitor.slow / monitor.fast events,
and the alert text names the threshold it used.
Alert channels
curl -X POST https://watchpup.watchpup.workers.dev/api/channels -H 'Authorization: Bearer wp_…' \
-d '{"kind":"email","target":"you@example.com"}'
# kinds: email | discord | slack | webhook (JSON POST on down/up/slow/fast/budget) | ntfy | telegram | teams | googlechat
# webhook events: monitor.down, monitor.up, monitor.slow (over slow_ms 3 checks running), monitor.fast (recovered),
# monitor.budget (SLA error budget 75% / 100% used; includes sla_target, budget_used_pct, budget_remaining_s),
# monitor.still ("still down" reminder — set renotify on the monitor; includes down_since, down_for_s)
# down reminders: create/PATCH a monitor with {"renotify":1800} and every channel is
# re-alerted every 30 min until it recovers — one 3am alert is easy to sleep through
# ntfy = free push notifications to your phone, no account needed: install the
# ntfy app (ntfy.sh), subscribe to a hard-to-guess topic, then:
# -d '{"kind":"ntfy","target":"https://ntfy.sh/<your-topic>"}'
# (self-hosted ntfy servers work too; down alerts send priority:high)
# telegram = alerts in any Telegram chat via your own bot. One-time setup:
# 1. message @BotFather in Telegram, /newbot → it gives you a BOT TOKEN
# (looks like 123456789:AAF…xyz — keep it secret)
# 2. open a chat with your new bot and press Start (or add it to a group)
# 3. find the CHAT ID: curl https://api.telegram.org/bot<TOKEN>/getUpdates
# → "chat":{"id":123456789,…} (group ids are negative; a public channel
# can use @channelname — add the bot as a poster first)
# 4. target is "<BOT_TOKEN>/<CHAT_ID>":
# -d '{"kind":"telegram","target":"123456789:AAF…xyz/123456789"}'
# then POST /api/channels/<id>/test to confirm it rings before you rely on it
# teams = alerts in a Microsoft Teams channel. In Teams: channel → ⋯ →
# Workflows → "Post to a channel when a webhook request is received" → copy the
# URL (…logic.azure.com…). We send that format an Adaptive Card automatically;
# legacy …webhook.office.com connector URLs (retired by Microsoft for most
# tenants) get plain {"text"} and still work where they exist:
# -d '{"kind":"teams","target":"https://prod-…logic.azure.com:443/workflows/…"}'
# googlechat = alerts in a Google Chat space. In Chat: space name → Apps &
# integrations → Webhooks → add one, copy the URL:
# -d '{"kind":"googlechat","target":"https://chat.googleapis.com/v1/spaces/…/messages?key=…&token=…"}'
# (Google Workspace accounts only — personal Gmail spaces can't add webhooks)
# email channels for an address other than your own require the recipient to
# click a confirmation link first (anti-abuse); "verified" shows in the list
curl https://watchpup.watchpup.workers.dev/api/channels -H 'Authorization: Bearer wp_…'
curl -X DELETE https://watchpup.watchpup.workers.dev/api/channels/<id> -H 'Authorization: Bearer wp_…'
# send a TEST alert through a channel right now — verify it works before a real
# incident. Unlike real alert fan-out, delivery failures are reported back:
curl -X POST https://watchpup.watchpup.workers.dev/api/channels/<id>/test -H 'Authorization: Bearer wp_…'
# → {"ok":true,"status":200,"note":"test alert delivered — webhook target answered HTTP 200"}
# → 502 {"error":"delivery failed: webhook endpoint answered HTTP 404"} (wrong URL)
# → 502 {"error":"delivery failed: could not reach the target (network error or timeout)"}
# webhook channels receive {"event":"test","detail":"…","ts":…} — handle unknown
# event types gracefully. Limit: 10 test alerts/hour.
# DELIVERY RETRIES — a failed delivery is a lost alert, so Watchpup retries.
# Transient failures (network error, timeout, HTTP 408/425/429/5xx) are re-sent
# after 1, 5 and 15 minutes, then given up on. Permanent-looking answers
# (other 4xx, redirects) are not retried — fix the target URL instead.
# Every channel reports its delivery health in GET /api/channels and Settings:
# "last_error": "endpoint answered HTTP 500", "last_error_ts": 1750000000
# (null when the last delivery succeeded; any success — including a test
# alert — clears it). Webhook retries are re-signed with a fresh timestamp,
# so signature verification with a ±5 min window keeps working.
# ALERT ROUTING — pick which channels a monitor's alerts go to.
# Default: every monitor alerts ALL your channels (including ones added later).
# Route with the "channels" field on monitor create or PATCH (ids from GET /api/channels):
curl -X PATCH https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…' \
-d '{"channels":[1,3]}' # only these channels get this monitor's alerts
# {"channels":null} (or "all") = back to all channels (default)
# {"channels":[]} = MUTE this monitor's owner alerts entirely (it still shows
# on status pages; status-page subscribers still get emails)
# Applies to every alert type: down/up, slow/fast, budget, "still down" reminders.
# Channel test alerts ignore routing (they're per-channel, you asked for one).
# Deleting a channel removes its id from any routing lists — a monitor routed
# ONLY to that channel becomes muted ([]) and is flagged 🔕 on the dashboard.
# Current routing echoes as "alert_channels" on monitor JSON (null = all).
# UI: each monitor page has an Alert-routing panel. CLI: watchpup route ID [all|none|1,3]
# QUIET HOURS — a per-channel do-not-disturb window ("22:00-07:00" wraps midnight):
curl -X PATCH https://watchpup.watchpup.workers.dev/api/channels/<id> -H 'Authorization: Bearer wp_…' \
-d '{"quiet":"22:00-07:00","quiet_tz":"Europe/Berlin","quiet_down":true}'
# During the window nothing is delivered on the channel. Held alerts arrive when
# the window ends, with the ORIGINAL event payload and timestamp — webhook
# consumers may want to widen signature-timestamp windows for held deliveries,
# or just verify the signature (it is re-signed fresh at send time).
# quiet_tz: IANA zone the window is read in (wall clock, so DST just works;
# omit or "" for UTC). quiet_down: true = down/up/reminder alerts
# break through — quiet then only silences slow/fast/budget noise.
# "Still down" reminders are SKIPPED during quiet, not held (a stale 3am
# reminder at 7am is noise; reminders resume on their own after the window).
# Test alerts always send immediately (you asked for one), with a heads-up note.
# Clear with {"quiet":""}. Fields echo in GET /api/channels ("quiet":"22:00-07:00",
# "quiet_tz":"Europe/Berlin", "quiet_down":true; null/false when off).
# Also settable at channel creation (same fields on POST /api/channels).
# ESCALATION DELAY — per-channel "only wake me if it's really down":
curl -X PATCH https://watchpup.watchpup.workers.dev/api/channels/<id> -H 'Authorization: Bearer wp_…' -d '{"delay_s":300}'
# A channel with a delay only hears about outages that OUTLAST it: the down
# alert is held for delay_s seconds and delivered only if the monitor is still
# down then. If it recovers first, this channel gets NOTHING — no down, no
# recovery — while your immediate channels (email, chat) still record the blip.
# While the hold is pending, "still down" reminders to this channel are skipped
# too (it hasn't been told yet), and a recovery cancels the hold.
# Classic setup: email/Slack immediate, phone push (ntfy/Telegram) with
# {"delay_s":300} — a 1-minute blip never buzzes anyone's pocket.
# 30–86400 seconds (clamped); {"delay_s":0} clears. Echoes as delay_s in
# GET /api/channels. Also settable at channel creation. Delay applies only to
# down alerts (and their recovery); slow/fast/budget alerts are not delayed.
# Quiet hours still apply on top when the delay elapses. Test alerts send
# immediately, with a heads-up note. CLI: watchpup channels delay ID 5m
#
# PER-MONITOR delay — for a known-flaky endpoint, delay ALL channels at once:
curl -X PATCH https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…' -d '{"alert_delay_s":300}'
# Every channel then waits at least alert_delay_s before hearing THIS monitor's
# down alerts; a channel's own delay_s still applies on top (the longer of the
# two wins). Same cancel-on-recovery semantics. 30–86400 s, 0 clears; echoed as
# alert_delay_s in monitor JSON; also settable at create and in the monitor
# page's "Alert delay" panel. CLI: watchpup delay MONITOR-ID 5m
# WEBHOOK SIGNING — every webhook channel gets a signing secret (whsec_…),
# returned when you create the channel and shown in GET /api/channels and Settings.
# Every POST we send (alerts AND test alerts) carries:
# X-Watchpup-Signature: t=<unix-seconds>,v1=<hex>
# where v1 = HMAC-SHA256(secret, "<t>.<raw-request-body>")
# Verify before trusting a payload (python):
# t, v1 = (p.split('=',1)[1] for p in sig_header.split(','))
# expected = hmac.new(secret.encode(), f'{t}.{raw_body}'.encode(), 'sha256').hexdigest()
# ok = hmac.compare_digest(expected, v1) and abs(time.time()-int(t)) < 300
# node: crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
# Notes: compute over the RAW body bytes (before JSON parsing); use a
# constant-time compare; reject stale timestamps (±5 min) to stop replays.
# Discord/Slack/ntfy/Telegram/Teams/Google Chat channels are not signed (their URLs/tokens are already secrets).
curl -X POST https://watchpup.watchpup.workers.dev/api/channels/<id>/secret/rotate -H 'Authorization: Bearer wp_…'
# → {"ok":true,"secret":"whsec_…"} (old secret is invalid immediately)
Heartbeat pings
# GET or POST, no auth needed (the id is the secret) curl -fsS https://watchpup.watchpup.workers.dev/ping/<monitor-id> # ping by email: the create response also includes "ping_email" — a dedicated # address for this monitor. Any mail delivered there counts as a ping (subject # and body don't matter; recorded within ~1 minute). Point your backup tool, # RAID controller, or cron MAILTO at it — no HTTP needed. Keep it private: # like the ping URL, it works without auth. # FAILURE SIGNAL — the job reports its own failure and alerts fire NOW, # instead of waiting until the next expected ping is missed: curl -fsS https://watchpup.watchpup.workers.dev/ping/<monitor-id>/fail # a POSTed body (first 300 chars) becomes the incident detail your alerts # and status pages show — send the error message: my-job.sh || curl -fsS https://watchpup.watchpup.workers.dev/ping/<monitor-id>/fail -d "exit $?: $(tail -c 200 job.log)" # START SIGNAL — report when a run begins and Watchpup also measures how long # runs take (last_duration_s / running_since in the monitor API, shown on the # monitor page). If neither success nor /fail arrives within the grace time # after a /start, the monitor goes down: a hung job is caught within minutes, # not at the next schedule slot. Grace = the longest a run should take. curl -fsS https://watchpup.watchpup.workers.dev/ping/<monitor-id>/start my-job.sh && curl -fsS https://watchpup.watchpup.workers.dev/ping/<monitor-id> || curl -fsS https://watchpup.watchpup.workers.dev/ping/<monitor-id>/fail # note: during a maintenance window, /fail is recorded but doesn't alert or # count downtime — like every other check kind.
Run duration limits
# a run that SUCCEEDS but takes longer than it should is a warning you want
# early: the backup that used to take 5 minutes and now takes 50 will blow
# past its window eventually. Set max_duration_s on a heartbeat monitor:
curl -X PATCH https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…' -d '{"max_duration_s": 600}' # alert when a run takes over 10 min
# (also on create, and in the "Run duration limit" panel on the monitor page)
# needs the /start signal — only measured runs are judged. ONE over-limit run
# fires the 🐢 alert (runs are scheduled and rare — no 3-strike streak like
# http latency); the next run back under the limit sends the ⚡ recovery.
# Webhook events monitor.slow / monitor.fast, same as latency alerts: alert
# routing, quiet hours and delivery retries all apply. The monitor STAYS UP —
# this is a warning, not downtime. Plain pings without /start carry no
# duration and never trigger it; while the monitor is down, the down/recovery
# alerts dominate and the next measured run re-evaluates.
# Note grace is the hung-run limit (started, never finished); max_duration_s
# is the finished-too-slowly limit. Use both: grace generous, limit honest.
# API: max_duration_s + "slow": true on the monitor; Prometheus gauge
# watchpup_monitor_slow covers these monitors too; the weekly digest flags
# "last run took 14m (over its 10m limit)".
Cron schedules for heartbeats
# give the monitor the job's REAL cron schedule instead of a fixed interval
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors -H 'Authorization: Bearer wp_…' -d '{"kind":"heartbeat","name":"nightly backup","cron":"0 3 * * *","grace":1800}'
# Watchpup now expects a ping after every scheduled run: the 03:00 backup that
# didn't run alerts at 03:30 (grace covers job duration) — no interval math.
# standard 5-field cron: */15, ranges (9-17), lists (5,35), steps (9-17/4),
# day-of-week 0-7, @hourly/@daily/@weekly/@monthly aliases.
# names (MON, JAN) aren't supported — use numbers.
# schedules run in UTC unless you give an IANA timezone — DST is handled:
# a wall time skipped by spring-forward is simply missed that day; a repeated
# fall-back wall time counts once.
curl -X POST https://watchpup.watchpup.workers.dev/api/monitors -H 'Authorization: Bearer wp_…' -d '{"kind":"heartbeat","name":"backup","cron":"0 3 * * *","tz":"Europe/Berlin"}'
curl -X PATCH https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…' -d '{"cron":"*/15 * * * *"}' # change it
curl -X PATCH https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…' -d '{"tz":"America/New_York"}' # move zones
curl -X PATCH https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…' -d '{"cron":""}' # back to interval mode (clears tz too)
# GET /api/monitors/<id> → "cron" + "tz" + "next_due" (unix ts of the next expected ping)
Status pages
curl -X POST https://watchpup.watchpup.workers.dev/api/status-pages -H 'Authorization: Bearer wp_…' \
-d '{"title":"Acme services","slug":"acme"}'
# → public at https://watchpup.watchpup.workers.dev/s/acme (starts with all your monitors)
# each monitor shows 24h check bar, response-time chart, and a 90-day uptime history bar
# pages are live: viewers' browsers auto-update within ~30s of a status change (no refresh)
curl https://watchpup.watchpup.workers.dev/s/acme.json # machine-readable: status, uptime (24h/30d/90d), incidents, scheduled_maint (CORS *)
# scheduled & active maintenance windows show on the page ahead of time — see #maintenance
# choose which monitors appear (add/remove any time; also editable on the dashboard)
curl -X PATCH https://watchpup.watchpup.workers.dev/api/status-pages/acme -H 'Authorization: Bearer wp_…' \
-d '{"title":"Acme services","monitor_ids":["<id1>","<id2>"]}'
# or set auto_include:true — every monitor you have (now and in the future) shows up,
# no list to maintain; set it back to false to freeze the current set
curl -X PATCH https://watchpup.watchpup.workers.dev/api/status-pages/acme -H 'Authorization: Bearer wp_…' \
-d '{"auto_include":true}' # also accepted on create
# or scope it by tag: auto_include_tag:"public" — the page always shows every
# monitor carrying that tag (current and future). Tag a monitor and it appears;
# untag it and it disappears. One tag per page; "" turns it off (freezes the
# currently matching set). Can't be combined with auto_include.
curl -X PATCH https://watchpup.watchpup.workers.dev/api/status-pages/acme -H 'Authorization: Bearer wp_…' \
-d '{"auto_include_tag":"public"}' # also accepted on create; see /docs#tags
# brand it: custom accent color + your logo (both optional, also on create & dashboard)
curl -X PATCH https://watchpup.watchpup.workers.dev/api/status-pages/acme -H 'Authorization: Bearer wp_…' \
-d '{"accent":"#e8590c","logo_url":"https://example.com/logo.png"}'
# accent = any hex color; logo_url must be https. Send "" to clear either.
# group monitors into sections ("API", "Websites", …) — groups render as headed
# sections on the page, each with a worst-status dot; ungrouped monitors appear on top
curl -X PATCH https://watchpup.watchpup.workers.dev/api/status-pages/acme -H 'Authorization: Bearer wp_…' \
-d '{"groups":{"<id1>":"API","<id2>":"API","<id3>":"Websites"}}'
# groups replaces the whole mapping; {} clears it. Max 20 groups, names ≤40 chars.
# /s/acme.json exposes each monitor's "group"; also editable per-monitor on the dashboard.
# opt in to public SLA display: monitors with an sla_target (see #sla below) show
# month-to-date SLA + error-budget state on the page and in /s/acme.json ("sla" object)
curl -X PATCH https://watchpup.watchpup.workers.dev/api/status-pages/acme -H 'Authorization: Bearer wp_…' \
-d '{"show_sla":true}' # also accepted on create; off by default, false hides it again
curl https://watchpup.watchpup.workers.dev/api/status-pages -H 'Authorization: Bearer wp_…' # list pages + monitor_ids + auto_include(_tag) + branding + groups + show_sla
Every status page is also a subscribable Atom feed at
/s/<slug>/feed — last 30 days of incidents (outage start, resolution, duration, cause),
no auth needed. Point any RSS reader, Slack RSS app, or feed-to-notification service at it.
Private (password-protected) status pages
# set a password — the page now asks visitors for it before showing anything
curl -X PATCH https://watchpup.watchpup.workers.dev/api/status-pages/acme -H 'Authorization: Bearer wp_…' \
-d '{"password":"s3cret-team-pw"}' # 4–64 chars; also accepted on create
curl -X PATCH https://watchpup.watchpup.workers.dev/api/status-pages/acme -H 'Authorization: Bearer wp_…' \
-d '{"password":""}' # make it public again
# CLI: watchpup pages set acme password=s3cret-team-pw (password= clears)
For internal dashboards, client-only status pages, or staging environments.
While a page is protected: the HTML page shows an unlock form (correct password sets a signed
30-day cookie for that browser; changing or clearing the password invalidates every issued cookie
instantly); /s/<slug>.json, /metrics, the Atom feed, the embed widget
and the subscribe form all answer 401/403 for locked visitors — and work
normally for unlocked ones. You always see your own pages while logged in, no password needed.
Protected pages are marked noindex for search engines. Unlock attempts are rate-limited
(10 per 15 min per IP). Note: per-monitor badges use their own unguessable
badge ids and stay public — don’t share badge URLs for monitors you want fully private.
The owner API lists protected: true per page.
Embeddable status widget
<script src="https://watchpup.watchpup.workers.dev/s/acme/embed.js" async></script>
One line on your own site renders a small live pill — “All systems operational”,
“2 services down”, or “Under maintenance” — linking to your status page. It refreshes once a minute
while the tab is visible. No dependencies, ~2 KB, works on any site. Options via attributes on the
script tag: data-position="bottom-right|bottom-left|top-right|top-left" (floating corner,
default bottom-right), data-target="#some-element" (render inline inside that element
instead of floating), data-theme="dark" (dark pill for dark sites) or
data-theme="auto" (follows each visitor’s system light/dark preference, live — switches
with no reload if they change it). The widget only ever
shows aggregate status — monitor URLs and internals stay private.
Prefer the whole page? Status pages may also be embedded full-page in an
<iframe> — <iframe src="https://watchpup.watchpup.workers.dev/s/acme"></iframe> works from any origin
(they’re the only Watchpup pages that allow framing; the app itself refuses it).
Status-page email subscriptions
# visitors subscribe right on the page — or via the API:
curl -X POST https://watchpup.watchpup.workers.dev/s/acme/subscribe -d '{"email":"visitor@example.com"}'
# → confirmation email (double opt-in); once confirmed they get an email when any
# monitor on the page goes down or recovers, with one-click unsubscribe
# page owner: see / remove subscribers
curl https://watchpup.watchpup.workers.dev/api/status-pages/acme/subscribers -H 'Authorization: Bearer wp_…'
curl -X DELETE https://watchpup.watchpup.workers.dev/api/status-pages/acme/subscribers/<id> -H 'Authorization: Bearer wp_…'
Up to 50 subscribers per page (free beta). Subscriber emails go out within a few minutes of the incident and never expose monitor internals — just the status page link. Subscribers are also emailed when you schedule a maintenance window covering a monitor on their page (what's affected, when it starts and ends, your note). Anyone can unsubscribe instantly from any email. This is the feature status-page vendors usually charge for; here it's free.
Incidents & postmortem notes
curl 'https://watchpup.watchpup.workers.dev/api/incidents?days=30' -H 'Authorization: Bearer wp_…'
# → [{id, monitor, started, ended, duration_s, resolved, detail, note}, …] (max 90 days)
# attach a note (postmortem) to an incident — published with it on your status pages
curl -X POST https://watchpup.watchpup.workers.dev/api/incidents/<id>/note -H 'Authorization: Bearer wp_…' \
-d '{"note":"Bad deploy rolled back. Added a canary check so this class of bug can’t ship again."}'
curl -X POST https://watchpup.watchpup.workers.dev/api/incidents/<id>/note -H 'Authorization: Bearer wp_…' -d '{"note":""}' # clear
Incidents are derived automatically from down/up transitions — the last 30 days also appear on your public status page.
A note tells visitors what happened and what you did about it: it shows with the incident on the status page,
in /s/<slug>.json, the Atom feed and the weekly digest. Max 500 characters; from the dashboard,
use the + note button next to any DOWN event on the monitor page. Notes age out with incident history (90 days).
Incident updates (live timeline)
# post progress updates while an incident runs — Statuspage-style, free
curl -X POST https://watchpup.watchpup.workers.dev/api/incidents/<id>/updates -H 'Authorization: Bearer wp_…' \
-d '{"label":"investigating","text":"We’re seeing elevated errors on the API and are digging in."}'
curl -X POST https://watchpup.watchpup.workers.dev/api/incidents/<id>/updates -H 'Authorization: Bearer wp_…' \
-d '{"label":"identified","text":"Bad config push at 14:02 UTC. Rolling back now."}'
curl -X POST https://watchpup.watchpup.workers.dev/api/incidents/<id>/updates -H 'Authorization: Bearer wp_…' \
-d '{"text":"Error rate back to normal, watching closely."}' # label defaults to "update"
curl -X DELETE https://watchpup.watchpup.workers.dev/api/incidents/<id>/updates/<uid> -H 'Authorization: Bearer wp_…' # remove one
Labels: investigating, identified, monitoring, resolved, update (default).
Updates publish chronologically with the incident on your public status pages, in /s/<slug>.json
(incidents_30d[].updates) and the Atom feed — and open status pages in visitors’ browsers refresh within
~30 seconds of a new update, so people watching your status page during an outage see progress without reloading.
Team members can post updates too (the author is recorded for your account only — member emails are never published).
From the dashboard: the + update button next to any DOWN event on the monitor page. Or
watchpup update <id> investigating "…" from the CLI.
Posting an update also emails the verified subscribers of every status page
showing that monitor — the update text, stage, and incident state, with the usual one-click unsubscribe.
Only ongoing incidents and ones resolved less than a day ago notify (so a postmortem posted right after
still lands, but editing ancient history emails nobody), capped at 20 notified updates per day per account;
the API response's subscriber_notices field tells you what happened. Deleting an update can't
recall emails already queued.
Max 500 characters, 20 updates per incident; updates age out with incident history (90 days).
The ongoing-incident row on your status page also shows the latest stage label, so the page headline
answers “do they know? are they on it?” at a glance.
Acknowledging an incident
# "I'm on it" — pause the still-down reminder alerts for this incident
curl -X POST https://watchpup.watchpup.workers.dev/api/incidents/<id>/ack -H 'Authorization: Bearer wp_…'
# → {ok, acked_by, ack_ts} (acking an already-acked incident returns the original ack)
curl -X DELETE https://watchpup.watchpup.workers.dev/api/incidents/<id>/ack -H 'Authorization: Bearer wp_…' # un-ack: reminders resume
If a monitor has down reminders (renotify) configured, they nag every interval until recovery —
that's the point, until someone is actually working on it. Acknowledging the ongoing incident pauses the
reminders (the initial down alert and the recovery alert are unaffected), and records who acked and when:
in GET /api/incidents (acked_by, ack_ts), on the monitor page's event list, and in
watchpup incidents. Team members can ack; recovery clears the pause automatically, and un-acking
re-arms reminders from now. Acknowledgements are team-internal — they never appear on public status pages
(post an incident update when you want to tell the world). Only the ongoing
incident can be acked; the ack button lives next to the DOWN event on the monitor page.
Maintenance windows
# pause checks & alerts during a deploy — downtime won't count against uptime
curl -X POST https://watchpup.watchpup.workers.dev/api/maintenance -H 'Authorization: Bearer wp_…' \
-d '{"duration":3600,"note":"db upgrade"}' # all monitors, starting now
curl -X POST https://watchpup.watchpup.workers.dev/api/maintenance -H 'Authorization: Bearer wp_…' \
-d '{"monitor_id":"<id>","start":"2026-08-01T02:00:00Z","end":"2026-08-01T04:00:00Z"}'
curl -X POST https://watchpup.watchpup.workers.dev/api/maintenance -H 'Authorization: Bearer wp_…' \
-d '{"monitor_id":"<id>","start":"2026-08-01T02:00:00Z","duration":1800,"repeat":"daily"}' # nightly backup job
curl https://watchpup.watchpup.workers.dev/api/maintenance -H 'Authorization: Bearer wp_…' # list
curl -X DELETE https://watchpup.watchpup.workers.dev/api/maintenance/<id> -H 'Authorization: Bearer wp_…'
start/end accept unix seconds or ISO 8601; or pass duration (seconds, max 7 days).
During a window monitors show a blue “maintenance” state on dashboards, status pages and badges,
checks are skipped, and no incidents are recorded. When it ends, checks resume within a minute —
if the service is still down you'll be alerted then. Scheduling a window also emails the verified
subscribers of any status page showing a covered monitor (heads-up with
start/end times and your note; capped at 5 notified windows per day so subscribers can't be spammed).
Add "repeat": "daily" or "weekly" and the window automatically rolls forward to its next
occurrence when it ends — set it once for that nightly backup or weekly reboot and never think about it again.
A repeating window must be shorter than its period; if the first occurrence you give is already past, it starts
from the next one. Subscribers are emailed only when the window is first scheduled, not on every recurrence.
Cancel any time with DELETE.
Windows also show up on your public status pages ahead of time: a “🔧 Maintenance” panel lists active
and upcoming (next 7 days) windows with times, your note and scope — account-wide windows on every page,
monitor-scoped ones only on pages showing that monitor. Machine-readable as scheduled_maint in
/s/{slug}.json, and open status pages in viewers' browsers refresh automatically when you schedule or cancel one.
Visitors can also subscribe to your maintenance schedule as a calendar: /s/{slug}/maintenance.ics
is a public iCalendar feed of every active and upcoming window covering the page — import or subscribe in Google
Calendar, Apple Calendar, Outlook or anything else that speaks ICS. Repeating windows carry a proper
RRULE, events keep stable UIDs (rescheduling updates the event instead of duplicating it), and events
are marked free/transparent so they never block anyone's availability. Linked from every status page footer.
Apps that prefer the webcal:// scheme can use webcal://<host>/s/{slug}/maintenance.ics — same feed;
Google Calendar wants the plain https URL under “From URL”.
SLA targets & error budgets
# give any http/tcp/heartbeat monitor a monthly uptime target (90–99.999)
curl -X PATCH https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…' \
-d '{"sla_target":99.9}' # also settable at create; "" or 0 turns it off
curl https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_…'
# → adds "sla": {"target":99.9,"month":"2026-07","uptime_pct":99.9876,
# "allowed_downtime_s":2678,"used_downtime_s":332,"remaining_s":2346,
# "budget_used_pct":12.4,"within_budget":true, …}
The error budget is computed for the current UTC calendar month: at 99.9% a 31-day month
allows ~44 minutes of downtime. Downtime is estimated from your check results (elapsed month time × failure
rate; time before the month's first check counts as up). A down heartbeat monitor accrues one failed check per
expected ping interval, so missed-ping time burns the budget (and lowers uptime %) like any other downtime.
Your alert channels are notified automatically — once when the budget crosses 75% used and
once when it's exhausted (100%), re-armed each calendar month or when you change the target
(webhook event monitor.budget). The monitor page shows a budget burn-down bar, and
/api/metrics exports watchpup_monitor_sla_target,
watchpup_monitor_sla_budget_used_ratio and
watchpup_monitor_sla_budget_remaining_seconds so you can alert on budget burn in Grafana.
SLA state is private to you by default — a status page can opt in to publishing it: set show_sla
on the page (its manage panel on the dashboard, or PATCH /api/status-pages/{slug} {"show_sla":true})
and every monitor on it that has a target shows month-to-date SLA + error-budget state publicly
(also in /s/{slug}.json as an sla object per monitor).
Prometheus / Grafana
curl https://watchpup.watchpup.workers.dev/api/metrics -H 'Authorization: Bearer wp_…'
# watchpup_monitor_up{id="…",name="my api",kind="http"} 1
# watchpup_monitor_response_ms{…} 142
# watchpup_monitor_uptime_ratio_30d{…} 0.999884 … and more
Standard Prometheus text format: up/down, maintenance, slow flag, last response ms + HTTP code, last-check timestamp, 7d/30d uptime ratios, and SLA error-budget gauges per monitor. Scrape config:
scrape_configs:
- job_name: watchpup
metrics_path: /api/metrics
scheme: https
authorization: { credentials: wp_… }
scrape_interval: 60s
static_configs: [{ targets: ['watchpup.watchpup.workers.dev'] }]
Every public status page also exposes an unauthenticated scrape at
/s/<slug>/metrics — same gauges, scoped to the page's monitors, with the public
badge id as the id label. Handy for community dashboards: no API key to share.
curl https://watchpup.watchpup.workers.dev/s/my-page/metrics # no auth needed
Exporting your data
curl -OJ https://watchpup.watchpup.workers.dev/api/export -H 'Authorization: Bearer wp_…' # full account export (JSON) curl -OJ https://watchpup.watchpup.workers.dev/api/monitors/<id>/checks.csv -H 'Authorization: Bearer wp_…' # every raw check (~3 days) curl -OJ https://watchpup.watchpup.workers.dev/api/monitors/<id>/daily.csv -H 'Authorization: Bearer wp_…' # daily uptime + avg ms (~90 days)
Account export (/api/export, also watchpup backup in the CLI and a link in
Settings): one JSON file with your whole account — every monitor with its full configuration in the same
shape the API uses, alert channels, status pages with their subscribers, team members, maintenance windows, 90 days of down/up events (with
incident notes) and 90 days of per-day uptime/latency rollups. Deliberately secret-free: no API key, no password hashes,
no webhook signing secrets, no 2FA material — safe to keep as a backup. Limited to 10 exports per hour.
Per-monitor CSV — checks.csv: time,unix_ts,ok,response_ms,http_status — one row per check, oldest first.
daily.csv: date,checks,ok,uptime_pct,avg_response_ms,partial — one row per UTC day; today has partial=1.
Your data is yours: pull it into a spreadsheet, notebook, or another tool any time. Download links are also on each monitor's page.
Restoring a backup
# feed an account export back in — into the same account (undo an accident),
# a fresh account, or a different Watchpup instance
curl -X POST https://watchpup.watchpup.workers.dev/api/import/watchpup -H 'Authorization: Bearer wp_…' \
-H 'content-type: application/json' --data-binary @watchpup-export-2026-07-28.json
# → {"ok":true,"channels":{…},"monitors":{…},"status_pages":{…},"maintenance_windows":{…},"notes":[…]}
# preview first — same report, nothing created, no emails sent:
curl -X POST 'https://watchpup.watchpup.workers.dev/api/import/watchpup?dry_run=1' -H 'Authorization: Bearer wp_…' \
-H 'content-type: application/json' --data-binary @watchpup-export-2026-07-28.json
# CLI: watchpup restore watchpup-export-2026-07-28.json [--dry-run]
# dashboard: "Restore a Watchpup backup" panel (pick the file, Preview or Restore)
Restores in dependency order: alert channels → monitors (full configuration, tags, thresholds,
paused state and alert routing — routing is remapped onto the restored channel ids) →
status pages (monitor lists and groups remapped, branding kept) → active and upcoming
maintenance windows (created silently — no subscriber notices go out for restored windows). Idempotent:
anything that already exists (same channel target, same monitor URL/target, heartbeats by name, same page slug) is
skipped, so re-running a restore is safe. Because backups are deliberately secret-free, some things can't come back
from the file: webhook channels get new signing secrets (update your receivers — the new secret is in the
response), Telegram channels are reported unsupported (bot tokens are masked in backups — re-add them in Settings),
password-protected status pages come back public (set new passwords), and status-page subscribers must opt in
again (subscriptions are double opt-in). Alert emails to addresses other than your own trigger a fresh confirmation
email to the recipient. Event and rollup history in the file is for your records and is not written back.
Dry run: add ?dry_run=1 (or "dry_run": true in a wrapped body) to get the exact
same per-item report — what would be created, skipped, failed or unsupported — without creating anything, sending
any email, or consuming the restore rate limit. Limited to 5 restores per hour (dry runs: 10/hour, counted separately).
Weekly digest
# Monday morning email: 7-day uptime, avg response time, incident summary
curl https://watchpup.watchpup.workers.dev/api/digest/preview -H 'Authorization: Bearer wp_…' # see yours right now
curl -X POST https://watchpup.watchpup.workers.dev/api/settings -H 'Authorization: Bearer wp_…' -d '{"digest":false}' # opt out
curl -X POST https://watchpup.watchpup.workers.dev/api/settings -H 'Authorization: Bearer wp_…' -d '{"digest":true}' # opt back in
On by default for accounts with monitors; every digest email has a one-click unsubscribe link. Also toggleable in Settings. If your monitors carry tags, the digest groups them under tag headers with a per-group uptime figure — a monitor with several tags appears once, under its alphabetically-first tag; untagged monitors are listed first. No tags = flat list, as before. Heartbeat monitors that use the start signal also show how long the last run took.
Change your password
curl -X POST https://watchpup.watchpup.workers.dev/api/password/change -H 'Authorization: Bearer wp_…' \
-d '{"current_password":"…","new_password":"…"}'
Requires your current password even on an authenticated call, so a stolen session cookie or API
key can't be used to lock you out. On success every other browser session is logged out (the session making the
request stays), outstanding password-reset links are voided, and your API key is unchanged — rotate that separately
if it leaked. Also available as a form in Settings and as watchpup passwd in the CLI.
Forgot the current password? Use /forgot instead.
Two-factor authentication (TOTP)
# 1. get a secret (pending — 2FA is not on yet)
curl -X POST https://watchpup.watchpup.workers.dev/api/2fa/setup -H 'Authorization: Bearer wp_…'
# → {"secret":"BASE32…","otpauth":"otpauth://totp/…"}
# 2. add it to any authenticator app, then prove it works:
curl -X POST https://watchpup.watchpup.workers.dev/api/2fa/enable -H 'Authorization: Bearer wp_…' -d '{"code":"123456"}'
# → {"ok":true,"recovery_codes":["…×8 — shown exactly once"]}
# from now on, login needs the code too:
curl -X POST https://watchpup.watchpup.workers.dev/api/login -d '{"email":"…","password":"…","code":"123456"}'
# turn it off (requires the account password):
curl -X POST https://watchpup.watchpup.workers.dev/api/2fa/disable -H 'Authorization: Bearer wp_…' -d '{"password":"…"}'
Standard TOTP (RFC 6238: SHA-1, 6 digits, 30 s — Google Authenticator, Aegis, 1Password, …).
With 2FA on, password logins (web and API) require a current authenticator code; a login without one returns
401 {"needs_totp":true}. The 8 one-time recovery codes are shown exactly once at enable time and
each substitutes for an authenticator code once — store them somewhere safe. Password-reset emails no longer
auto-log-in on 2FA accounts, and enabling logs out other browser sessions. API-key access is deliberately
unaffected (the key is already a full-strength secret — rotate it if it leaks); the same goes for public status
pages and badges. Also available as a panel in Settings and as watchpup 2fa on|off
in the CLI.
Team members
# invite a teammate (they get an email with an accept link)
curl -X POST https://watchpup.watchpup.workers.dev/api/members -H 'Authorization: Bearer wp_…' \
-d '{"email":"teammate@example.com"}'
curl https://watchpup.watchpup.workers.dev/api/members -H 'Authorization: Bearer wp_…' # list (status: invited|active, last_login)
curl -X DELETE https://watchpup.watchpup.workers.dev/api/members/3 -H 'Authorization: Bearer wp_…' # remove / revoke invite
Invite up to 10 teammates per account, free. Each member accepts the emailed link, picks
their own password and logs in with their own email at /login — no password sharing. Members see
and manage the same monitors, status pages, alert channels and maintenance windows as you; the owner-only
surface is credentials and account control: your password, 2FA, the API key (it's never shown to members), the weekly
digest, the team list itself and account deletion — those return 403 for member logins. Members can
change their own password (Settings, or the forgot-password flow). Removing a member (or
revoking a pending invite) takes effect immediately — their sessions die on the next request. The member list
(API, Settings and watchpup team) shows each member's last login — accepting the invite and
completing a password reset count — so a seat nobody has used in months is easy to spot and remove. Notes: an email can
only be on one team for now and can't double as its own Watchpup account; owner 2FA covers only the owner's login
— members who want a second factor should ask us for it (tell us!). Also a panel in Settings
and watchpup team in the CLI.
Activity log (audit trail)
curl https://watchpup.watchpup.workers.dev/api/audit -H 'Authorization: Bearer wp_…' # last 30 days
curl 'https://watchpup.watchpup.workers.dev/api/audit?days=90&limit=500' -H 'Authorization: Bearer wp_…'
# → {"entries":[{"ts":1753750000,"actor":"ana@example.com","via":"web",
# "action":"monitor.update","target":"monitors/…","target_name":"api server",
# "detail":"fields: slow_ms"}, …]}
Every change on the account is recorded automatically: who did it (owner or
team member), from where (web dashboard vs api key — the CLI counts
as API), and what — monitors added/changed/deleted, alert channels, status pages, maintenance, team invites and
removals, sign-ins, password/key/2FA changes, imports and restores. For edits only the names of the fields that
changed are recorded, never the values — a pasted bot token or webhook URL can't end up in the log. Entries are kept
90 days (max 1000), visible to the owner and all team members, and deleted with the account. Restore
dry runs and failed attempts aren't logged; the log is not part of account exports.
Recent activity also shows in Settings, and watchpup audit prints it in the
CLI.
Rotate your API key
curl -X POST https://watchpup.watchpup.workers.dev/api/key/rotate -H 'Authorization: Bearer wp_…'
# → {"ok":true,"api_key":"wp_<new key>", …}
Leaked a key in a repo, log or paste? Rotating issues a fresh key and invalidates the old one
immediately — the very next request with the old key gets a 401. Update your scripts, Prometheus
/api/metrics scrape configs and any machine running the CLI (watchpup key rotate does the
rotate-and-save in one step; on other machines run watchpup key wp_<new>). Browser sessions and
public status pages/badges are unaffected. Also available as a button in Settings.
Read-only API key
# every account also has a wp_ro_… key (Settings, or ro_key in GET /api/me)
curl https://watchpup.watchpup.workers.dev/api/monitors -H 'Authorization: Bearer wp_ro_…' # works
curl -X DELETE https://watchpup.watchpup.workers.dev/api/monitors/<id> -H 'Authorization: Bearer wp_ro_…'
# → 403 {"error":"this key is read-only — changes need your full API key …"}
curl -X POST https://watchpup.watchpup.workers.dev/api/key/ro/rotate -H 'Authorization: Bearer wp_…' # rotate (full key needed)
The read-only key sees the whole account through the same API — monitors, incidents,
latency stats, CSV exports, the Prometheus scrape, even the full
account backup (which is secret-free by design) — but every non-GET request is refused with a
403. It also never reveals your full API key (/api/me omits it), and webhook signing
secrets and Telegram bot tokens are hidden from GET /api/channels. Use it wherever a credential sits in
a config file you don't fully control: Prometheus scrape configs, Grafana, CI status dashboards, backup crons, a
shared jump box running the CLI (watchpup key wp_ro_… — read commands like ls,
status, watch, incidents, latency, export and
backup work; anything that changes state reports the 403). One honest caveat: monitor listings include
heartbeat ping URLs (a ping-only capability), so treat the key as team-grade, not public. Rotating either key
(above, or the buttons in Settings) never affects the other.
Delete your account
curl -X DELETE https://watchpup.watchpup.workers.dev/api/account -H 'Authorization: Bearer wp_…' \
-d '{"confirm":"you@example.com"}' # confirm = your account email
Permanently removes the account and everything in it: monitors, check history, status pages, their subscribers, alert channels and maintenance windows. No grace period, no undo. Also available at the bottom of Settings. Your data is yours — including the right to walk away with none of it left behind (grab your CSV exports first if you want the history).
Uptime & response-time badges
# every monitor gets a public SVG badge (badge_url in API responses)
<img src="https://watchpup.watchpup.workers.dev/badge/<badge-id>.svg"> # 24h uptime shield
https://watchpup.watchpup.workers.dev/badge/<badge-id>.svg?window=30d # 7d / 30d windows
https://watchpup.watchpup.workers.dev/badge/<badge-id>.svg?label=api # custom label
https://watchpup.watchpup.workers.dev/badge/<badge-id>.svg?metric=response # avg response time instead of uptime
https://watchpup.watchpup.workers.dev/badge/<badge-id>.svg?metric=p95 # latency percentile (p50|p90|p95|p99, 24h)
https://watchpup.watchpup.workers.dev/badge/<badge-id>.json # {status, uptime_24h/7d/30d, avg_ms_24h/7d/30d, p50/p90/p95/p99_ms_24h}
The badge id is separate from the monitor id, so embedding it never exposes your ping URL.
The response badge shows the average response time over the window (24h default; window=7d|30d work here too)
and colors by your monitor's slow threshold when one is set (green under slow_ms, amber at or over it; neutral blue when unset).
Percentile badges (metric=p50|p90|p95|p99, nearest-rank) always use the last 24h of raw checks —
percentiles can't be derived from daily rollups, so window= has no effect on them; colors follow the same slow-threshold rule.
Is Watchpup itself up?
curl https://watchpup.watchpup.workers.dev/status.json # {"status":"operational","last_cycle_age_s":12,"checker_reliability_24h_pct":100,...}
Watchpup's own health, self-measured by the check loop: whether it's running on schedule, its 24h reliability, and check/alert volume. Human version at /status. Public, CORS-enabled, cached 30s — you're welcome to point a monitor at it.
Limits (free beta)
15 monitors per account · 8 alert channels per account · minimum interval 60s · alerts fire after 2 consecutive failures ·
slow alerts after 3 consecutive checks over slow_ms (set it to auto and Watchpup learns the threshold: 2× the monitor's own 24h median response time, re-learned hourly so it tracks your normal — auto_threshold_ms in the API shows the current value) ·
raw results kept ~3 days, daily rollups ~90 days. Be nice; abuse gets removed.
Checks follow up to 5 redirects; a redirect to a private/internal address (or more than 5 hops) fails the check with a matching detail. Monitor URLs must be public http(s) hosts — IPv6 literals and private/reserved IPv4 ranges are rejected.