API reference

Every WatchDog endpoint,
with the curl you actually paste.

REST reference for monitors, incidents, and status pages — method, path, auth, request shape, response shape, and a copy-paste curl for each. Today auth rides on a session cookie (connect.sid from POST /auth/login); an API key in /dashboard/settings is on the roadmap.

1. Authentication

There is no API key today. Every protected endpoint expects the session cookie connect.sid that WatchDog sets when you sign in. Sign up via POST /auth/signup or log in via POST /auth/login; copy the resulting connect.sid cookie value out of your browser's devtools (Application → Cookies → connect.sid), URL-encoded and all, and export it once in your shell:

export WD_COOKIE='connect.sid=s%3A…'

Every curl example on this page reuses -b "$WD_COOKIE", so a single export is all you need to walk through the whole surface. Status pages under /status/:slug are the only public endpoints — no cookie required for those.

Roadmap: a per-account API-key option, surfaced under /dashboard/settings, is planned so you can mint a key that is — unlike a session cookie — revocable and safe to bake into CI. This page will be updated the day that ships; the docs will not invent or document a behavior the app does not yet implement.

2. Endpoints

One entry per route, ordered roughly in the path a developer would walk them in. Field names and response shapes below are copied verbatim from routes/monitors.js and routes/status.js; if the live wiring changes, change here too.

GET /api/monitors

Auth: session cookie. List all monitors owned by the calling user, each joined with its most recent check result.

Response

{
  "monitors": [
    {
      "id": 201,
      "url": "https://api.example.com/checkout",
      "name": "Checkout API",
      "owner_email": "you@example.com",
      "poll_interval_minutes": 5,
      "enabled": true,
      "created_at": "2026-08-04T11:14:02.000Z",
      "tags": "api,production",
      "is_public": true,
      "status_code": 200,
      "response_time_ms": 142,
      "is_anomaly": false,
      "checked_at": "2026-08-08T14:30:00.000Z"
    }
  ]
}

curl

# Grab your session cookie from browser devtools
# (Application → Cookies → https://watchdog.app → connect.sid) and save it:
export WD_COOKIE='connect.sid=s%3A…'

curl -X GET https://watchdog.app/api/monitors -b "$WD_COOKIE"

Returns every monitor owned by the calling user. Newest-id-last ordering. There is no `?limit` / `?offset` yet — see the 2. Pagination section below for the two endpoints that do cap today.

POST /api/monitors

Auth: session cookie. Add a new monitor. Pro subscription required once the free 1-monitor limit is reached (see notes).

Request fields

url  (string, required)
    The URL to poll. Must be an http:// or https:// URL.

name  (string, required)
    Display name shown in the dashboard and email alerts.

owner_email  (string, required)
    Where anomaly alerts are sent. Must be a valid email address.

poll_interval_minutes  (integer, optional)
    Defaults to 5. The app reaffirms this at the scheduler level; the 5-minute cron is what today’s deployment uses.

tags  (string, optional)
    Free-form CSV. Trimmed, lowercased, deduped, max 200 chars / 10 tokens.

Response

{
  "monitor": {
    "id": 202,
    "url": "https://api.example.com/health",
    "name": "Health",
    "owner_email": "you@example.com",
    "poll_interval_minutes": 5,
    "user_id": "e8d2a4c1-7b3c-4f99-9a52-91f7b6df7e22",
    "tags": "api",
    "enabled": true,
    "created_at": "2026-08-08T14:31:12.000Z"
  }
}

curl

# Grab your session cookie from browser devtools
# (Application → Cookies → https://watchdog.app → connect.sid) and save it:
export WD_COOKIE='connect.sid=s%3A…'

curl -X POST https://watchdog.app/api/monitors \
  -H "Content-Type: application/json" \
  -b "$WD_COOKIE" \
  -d '{
  "url": "https://api.example.com/health",
  "name": "Health",
  "owner_email": "you@example.com",
  "poll_interval_minutes": 5,
  "tags": "api, production"
}'

Free users are limited to 1 monitor; the second POST returns 403 with the `upgrade_required` flag (see 3. Errors). Pro users have no cap.

GET /api/monitors/:id

Auth: session cookie. Fetch a single monitor with its last 100 check results, 7-day uptime, average response time, and anomaly count.

Response

{
  "monitor": {
    "id": 201,
    "url": "https://api.example.com/checkout",
    "name": "Checkout API",
    "owner_email": "you@example.com",
    "poll_interval_minutes": 5,
    "enabled": true,
    "created_at": "2026-08-04T11:14:02.000Z",
    "tags": "api,production",
    "is_public": true
  },
  "checks": [
    {
      "id": 10042,
      "monitor_id": 201,
      "status_code": 200,
      "response_time_ms": 142,
      "error_message": null,
      "is_anomaly": false,
      "checked_at": "2026-08-08T14:30:00.000Z"
    }
  ],
  "uptimePct": 99,
  "avgResponseTime": 156,
  "anomalyCount": 0
}

curl

# Grab your session cookie from browser devtools
# (Application → Cookies → https://watchdog.app → connect.sid) and save it:
export WD_COOKIE='connect.sid=s%3A…'

curl -X GET https://watchdog.app/api/monitors/201 -b "$WD_COOKIE"

The `checks` array is hard-capped at 100 rows by the underlying SQL (`LIMIT 100`). For more history use `GET /api/monitors/:id/history`.

PATCH /api/monitors/:id

Auth: session cookie. Pause, resume, or update the settings (timeout, custom headers, push toggle, tags) of a monitor.

Request fields

enabled  (boolean, optional)
    Pause (`false`) or resume (`true`) polling.

timeout_ms  (integer, optional)
    Per-request timeout override.

custom_headers  (object, optional)
    JSON object of extra HTTP headers to send with each check.

push_enabled  (boolean, optional)
    Send browser push notifications on incidents.

tags  (string, optional)
    Replace the stored CSV tag list.

Response

{
  "ok": true
}

curl

# Grab your session cookie from browser devtools
# (Application → Cookies → https://watchdog.app → connect.sid) and save it:
export WD_COOKIE='connect.sid=s%3A…'

curl -X PATCH https://watchdog.app/api/monitors/201 \
  -H "Content-Type: application/json" \
  -b "$WD_COOKIE" \
  -d '{
  "enabled": false
}'

Setting `enabled: false` is the canonical way to pause a monitor without losing its history. Unknown fields are ignored.

DELETE /api/monitors/:id

Auth: session cookie. Permanently remove a monitor and all of its check history.

Response

{
  "ok": true
}

curl

# Grab your session cookie from browser devtools
# (Application → Cookies → https://watchdog.app → connect.sid) and save it:
export WD_COOKIE='connect.sid=s%3A…'

curl -X DELETE https://watchdog.app/api/monitors/201 -b "$WD_COOKIE"

Returns 404 if the monitor does not exist or is owned by a different user. The deletion is irreversible — export via GET first if you need a backup.

GET /api/monitors/:id/history

Auth: session cookie. Recent check results, newest first. This is the only endpoint that exposes a real `?limit` query param.

Request fields

limit  (integer, optional)
    Query param. Defaults to 20, server caps at 100 (`Math.min(limit, 100)`).

Response

{
  "checks": [
    {
      "id": 10042,
      "monitor_id": 201,
      "status_code": 200,
      "response_time_ms": 142,
      "error_message": null,
      "is_anomaly": false,
      "checked_at": "2026-08-08T14:30:00.000Z"
    }
  ]
}

curl

# Grab your session cookie from browser devtools
# (Application → Cookies → https://watchdog.app → connect.sid) and save it:
export WD_COOKIE='connect.sid=s%3A…'

curl -G "https://watchdog.app/api/monitors/201/history" \
  --data-urlencode "limit=50" \
  -b "$WD_COOKIE"

`limit=100` is the ceiling — passing larger values still returns 100. There is no `?offset`; for older history export the full check_results table via the dashboards rather than curl.

GET /api/monitors/:id/incidents

Auth: session cookie. Alert deliveries for a monitor. Unparameterized requests remain capped at 50 rows; the monitor detail page requests 25-row cursor-paginated pages. Free users see only a count.

Request fields

limit  (integer, optional)
    Optional page size for cursor pagination. Defaults to 25 and is capped at 50.

before_started_at  (ISO-8601 timestamp, optional)
    Use with `before_id` to fetch the page strictly older than the last row returned.

before_id  (integer, optional)
    Use with `before_started_at` as the stable delivery-id half of the cursor.

Response

{
  "incidents": [
    {
      "id": 9931,
      "monitor_id": 201,
      "check_result_id": 10042,
      "channel_id": 14,
      "channel_type": "email",
      "channel_name": "Owner",
      "status": "delivered",
      "error_message": null,
      "started_at": "2026-08-08T14:30:01.000Z",
      "alert_kind": "incident",
      "downtime_ms": null,
      "maintenance_window_id": null
    }
  ],
  "has_more": true,
  "next_cursor": {
    "before_started_at": "2026-08-08T14:30:01.000Z",
    "before_id": 9931
  }
}

curl

# Grab your session cookie from browser devtools
# (Application → Cookies → https://watchdog.app → connect.sid) and save it:
export WD_COOKIE='connect.sid=s%3A…'

curl -X GET https://watchdog.app/api/monitors/201/incidents?limit=25 -b "$WD_COOKIE"

The monitor detail page uses `limit=25`, then passes `before_started_at` and `before_id` from `next_cursor` to load older rows. `has_more` is false and `next_cursor` is null when the feed is exhausted. An unparameterized Pro request remains `{ incidents: [...] }` with the existing 50-row cap. On free accounts the response is always `{ count: <integer> }` with no row list.

GET /api/monitors/:id/incidents/export.csv

Auth: session cookie; Pro subscription required. Download one cursor-paginated page of the selected monitor’s incident and escalation history as CSV.

Request fields

limit  (integer, optional)
    Optional page size. Defaults to 25 and is capped at 50.

before_started_at  (ISO-8601 timestamp, optional)
    Use with `before_id` to fetch the page strictly older than the last exported row.

before_id  (integer, optional)
    Use with `before_started_at` as the stable delivery-id half of the cursor.

Response

{
  "content_type": "text/csv; charset=utf-8",
  "content_disposition": "attachment; filename=\"monitor-<id>-incidents.csv\"",
  "columns": [
    "timestamp",
    "event_type",
    "status",
    "trigger_context",
    "delivery_error"
  ],
  "cursor_headers": {
    "X-Has-More": "true | false",
    "X-Next-Cursor": "{\"before_started_at\":\"<ISO-8601>\",\"before_id\":<integer>} | empty"
  }
}

curl

# Grab your session cookie from browser devtools
# (Application → Cookies → https://watchdog.app → connect.sid) and save it:
export WD_COOKIE='connect.sid=s%3A…'

curl -G "https://watchdog.app/api/monitors/201/incidents/export.csv" \
  --data-urlencode "limit=25" \
  -b "$WD_COOKIE" -OJ

The response sets `X-Has-More` and `X-Next-Cursor` headers for successive downloads; the latter is JSON with `before_started_at` and `before_id`. Event types are `down`, `recovery`, `sustained_outage`, `repeated_down`, `escalation`, and `configuration`. The current history view has no date-range filter: server-side scope is cursor pagination, while its event-type selector is client-side. Export rows never include channel targets, custom headers, webhook payloads, or raw configuration JSON. Free users receive the same Pro upgrade fence as the JSON history row list.

POST /api/monitors/:id/incidents/acknowledge

Auth: session cookie. Acknowledge the active incident on a monitor. Pro-gated. Data-layer idempotent — re-posts return the existing ack fields unchanged.

Response

{
  "incident": {
    "id": 4711,
    "acknowledged_at": "2026-08-08T14:32:11.000Z",
    "acknowledged_by_name": "Jordan Alekseev"
  }
}

curl

# Grab your session cookie from browser devtools
# (Application → Cookies → https://watchdog.app → connect.sid) and save it:
export WD_COOKIE='connect.sid=s%3A…'

curl -X POST https://watchdog.app/api/monitors/201/incidents/acknowledge -b "$WD_COOKIE"

Returns 403 with `upgrade_required` on free accounts and 404 if no incident is currently active. Re-posting after the first ack is safe — the data layer only writes when acknowledged_at IS NULL, so side effects (e.g. signed `incident.acknowledged` webhooks) fire at most once per incident.

GET /status/:slug

Auth: public — no auth. Public status page, owned by the user with the matching slug. Renders the HTML status page (anonymized, grouped by tag, with 90-day uptime).

Response

This route returns HTML rendered from views/status.ejs, not JSON. The per-monitor JSON shape used by the page is the same as the entry in GET /api/monitors.

curl

# Status pages are public — no session cookie required.
curl -X GET "https://watchdog.app/status/your-slug"

Slugs are user-scoped (see users.slug). Owners flip `is_public = true` per monitor in the dashboard; private monitors are hidden from the public page. For the JSON view of a single monitor, use GET /api/monitors/:id instead.

3. Pagination

Most list endpoints today return every row owned by the calling user; the history and incident endpoints expose explicit caps, and both read a ?limit query param from the URL.

  • GET /api/monitors — no pagination yet, returns every monitor owned by the calling user.
  • GET /api/monitors/:id/history?limit=N — defaults to 20, hard-capped at 100 by routes/monitors.js (Math.min(parseInt(req.query.limit) || 20, 100)). There is no ?offset; pass a lower limit and re-call rather than paging.
  • GET /api/monitors/:id/incidents — an unparameterized request remains capped at 50. The monitor detail page requests ?limit=25 and receives { incidents: [...], has_more: true, next_cursor: { before_started_at, before_id } }; pass both cursor fields back with the next request to load strictly older rows. limit is capped at 50. The response envelope differs by plan: Pro users see the row envelope, while free users still see only { count: N } with no row list (the upgrade fence is enforced on the same request).

The default list-limit string baked into the dispatcher is 50 — if you are wiring up monitoring clients against this API, treat that as a soft hint, not a guarantee; the hard caps above are what the live route enforces today.

4. Errors

The standard envelope is a JSON object with a single human-readable error string. Beyond that, the app emits three real shapes today — branch on the HTTP status code first, then on the presence of upgrade_required:

400 — validation failure

{ "error": "<human readable message>" }

Returned for missing required fields, malformed URLs, invalid tags, or out-of-range integers (e.g. routes/monitors.js:72, 75, 79, 113, 168, 181, 251, 299, 491, 551).

401 — missing or expired session

{ "error": "unauthorized" }

Returned when no connect.sid cookie is present, the session is gone, or the underlying user row no longer exists (see middleware/auth.js).

403 — Pro-only feature on a free account

{
  "error": "",
  upgrade_required: true
}

Every Pro gate in routes/monitors.js emits this exact shape — second monitor, alert channels, incident acknowledge, maintenance windows, paid test alerts. Treat upgrade_required as the canonical "show the upgrade banner" signal.

404 — resource not found / not yours

{ "error": "monitor not found" }

Same envelope as 400; the only difference is the status code. "Not found" is also returned when a row exists but belongs to a different user — the API never leaks which case applies.

500 — unexpected error

{ "error": "" }

The raw error string from the underlying pg driver or runtime. Don't parse it on the client; if you see one, file a support ticket with the monitor id.