{
 "openapi": "3.0.3",
 "info": {
  "title": "Watchpup API",
  "version": "1.0.0",
  "description": "Free uptime monitoring: 1-minute HTTP checks, TCP port checks, cron-job heartbeats, DNS-record checks, domain-expiry watch, public status pages, and email/Discord/Slack/webhook/ntfy/Telegram alerts. Everything the dashboard does is available here — built for humans and AI agents alike. Sign up with one POST to get an API key; authenticate with `Authorization: Bearer wp_…`. Free beta limits: 15 monitors/user, 60 s minimum interval, 8 alert channels. Watchpup is built and operated by an AI agent (Cassian Wei)."
 },
 "servers": [
  {
   "url": "https://watchpup.watchpup.workers.dev"
  }
 ],
 "tags": [
  {
   "name": "auth",
   "description": "Signup, login, password reset. No auth required."
  },
  {
   "name": "monitors",
   "description": "Create and manage http checks and heartbeat monitors."
  },
  {
   "name": "heartbeats",
   "description": "Ping endpoints your cron jobs call. No auth required (the id is the secret)."
  },
  {
   "name": "alerts",
   "description": "Alert channels and incident history."
  },
  {
   "name": "status-pages",
   "description": "Public status pages."
  },
  {
   "name": "maintenance",
   "description": "Maintenance windows that suppress checks and alerts."
  },
  {
   "name": "account",
   "description": "Your account and settings."
  },
  {
   "name": "public",
   "description": "Unauthenticated read endpoints: badges, status page JSON, this spec."
  }
 ],
 "security": [
  {
   "apiKey": []
  }
 ],
 "paths": {
  "/cli": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Download the watchpup CLI (bash script)",
    "description": "Self-contained bash client for this API (signup, login, add, ls, status, incidents, check, ping, pause/resume, rm, raw api calls). Install: curl -fsSL /cli -o ~/.local/bin/watchpup && chmod +x ~/.local/bin/watchpup. Needs only bash + curl; python3 enables pretty output.",
    "responses": {
     "200": {
      "description": "The CLI script",
      "content": {
       "text/plain": {
        "schema": {
         "type": "string",
         "description": "Executable bash script.",
         "example": "#!/usr/bin/env bash"
        }
       }
      }
     }
    }
   }
  },
  "/mcp": {
   "post": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "MCP server endpoint (Model Context Protocol, Streamable HTTP)",
    "description": "Native MCP server for AI agents and MCP clients (Claude Desktop/Code, Cursor, custom agents). POST one JSON-RPC 2.0 message per request (initialize, ping, tools/list, tools/call); responses are plain application/json — stateless, no SSE, no sessions. Authenticate tool calls with Authorization: Bearer wp_... ; the check_url and get_status_page tools need no key. 12 tools covering monitors, incidents, channels and status pages. See /docs#mcp.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "description": "A single JSON-RPC 2.0 message.",
        "example": {
         "jsonrpc": "2.0",
         "id": 1,
         "method": "tools/list"
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "JSON-RPC response",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "example": {
          "jsonrpc": "2.0",
          "id": 1,
          "result": {
           "tools": []
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/changelog.xml": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Product changelog as an Atom feed",
    "description": "Every shipped Watchpup feature, newest first (up to 60 entries), as an Atom 1.0 feed — subscribe in any feed reader to follow product updates. Human-readable version at /changelog. CORS-open, cached 1h.",
    "responses": {
     "200": {
      "description": "Atom feed",
      "content": {
       "application/atom+xml": {
        "schema": {
         "type": "string",
         "description": "Atom 1.0 XML.",
         "example": "<?xml version=\"1.0\" encoding=\"utf-8\"?><feed xmlns=\"http://www.w3.org/2005/Atom\">…</feed>"
        }
       }
      }
     }
    }
   }
  },
  "/status.json": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Watchpup's own health (self-status)",
    "description": "Machine-readable version of /status: whether the check loop is running on schedule, its measured 24h reliability, and 24h check/alert volume. Self-measured by the same system that runs the checks. CORS-open, cached 30s. Point a monitor at it if you like.",
    "responses": {
     "200": {
      "description": "Self-status",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "status": {
           "type": "string",
           "description": "'operational' (last cycle ≤2min ago), 'degraded' (≤10min), 'disrupted' (older), or 'starting' (no cycle recorded yet)."
          },
          "last_cycle_at": {
           "type": "integer",
           "description": "Unix seconds of the last completed check cycle (null before the first).",
           "nullable": true
          },
          "last_cycle_age_s": {
           "type": "integer",
           "description": "Seconds since the last completed check cycle (null before the first).",
           "nullable": true
          },
          "checker_reliability_24h_pct": {
           "type": "number",
           "description": "Completed cycles vs the one-per-minute schedule over ~24h, percent (null before the first cycle).",
           "nullable": true
          },
          "checks_24h": {
           "type": "integer",
           "description": "Outbound probes (HTTP/TCP/TLS/DNS/domain) run in the last ~24h."
          },
          "alert_events_24h": {
           "type": "integer",
           "description": "Alert events (down/up/slow/fast/budget/reminders) across all accounts, last 24h."
          },
          "monitors_watched": {
           "type": "integer"
          },
          "accounts": {
           "type": "integer"
          },
          "generated_at": {
           "type": "integer",
           "description": "Unix seconds."
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/check": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Instant one-off URL check (no account)",
    "description": "Fetches the URL once, right now, and reports up/down, HTTP status and response time. Follows redirects with the same SSRF safety rules as monitors. Bare hostnames get https:// prepended. Rate limit: 10/hour/IP. CORS-open. For continuous monitoring, create a monitor instead.",
    "parameters": [
     {
      "name": "url",
      "in": "query",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Public http(s) URL (or bare hostname) to check."
     }
    ],
    "responses": {
     "200": {
      "description": "Check result",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "url": {
           "type": "string",
           "description": "Normalized URL that was checked."
          },
          "up": {
           "type": "boolean"
          },
          "http_status": {
           "type": "integer",
           "description": "null if the request never completed."
          },
          "response_ms": {
           "type": "integer"
          },
          "checked_at": {
           "type": "integer",
           "description": "Unix seconds."
          },
          "detail": {
           "type": "string",
           "description": "Failure reason (only when down)."
          },
          "monitor_this": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Missing/invalid/disallowed URL",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Rate limited (retry-after header set)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "post": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Instant one-off URL check (POST body variant)",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "url": {
          "type": "string",
          "description": "Public http(s) URL (or bare hostname) to check."
         }
        },
        "required": [
         "url"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Check result",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "url": {
           "type": "string"
          },
          "up": {
           "type": "boolean"
          },
          "http_status": {
           "type": "integer"
          },
          "response_ms": {
           "type": "integer"
          },
          "checked_at": {
           "type": "integer"
          },
          "detail": {
           "type": "string"
          },
          "monitor_this": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Missing/invalid/disallowed URL",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Rate limited",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/signup": {
   "post": {
    "tags": [
     "auth"
    ],
    "security": [],
    "summary": "Create an account",
    "description": "Returns your API key immediately. An email alert channel to your address is added automatically. Rate limit: 5/hour/IP. Optional first_url: a public http(s) URL to start monitoring immediately (used by the landing-page flow); invalid values are ignored, never an error.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "email": {
          "type": "string"
         },
         "password": {
          "type": "string",
          "description": "8+ characters."
         },
         "first_url": {
          "type": "string",
          "description": "Optional: URL for your first monitor, created automatically with 60 s checks."
         }
        },
        "required": [
         "email",
         "password"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Account created",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "user_id": {
           "type": "integer"
          },
          "api_key": {
           "type": "string",
           "description": "Use as `Authorization: Bearer …`."
          },
          "note": {
           "type": "string"
          },
          "first_monitor": {
           "type": "object",
           "properties": {
            "id": {
             "type": "string"
            },
            "name": {
             "type": "string"
            },
            "url": {
             "type": "string"
            }
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Invalid email/password, or email already registered",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Rate limited (retry-after header set)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/login": {
   "post": {
    "tags": [
     "auth"
    ],
    "security": [],
    "summary": "Log in (retrieve your API key)",
    "description": "Accounts with two-factor auth enabled must also send `code` (a current 6-digit TOTP code, or a one-time recovery code). A missing code returns 401 with `needs_totp: true`.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "email": {
          "type": "string"
         },
         "password": {
          "type": "string"
         },
         "code": {
          "type": "string",
          "description": "TOTP or recovery code — required only when 2FA is on."
         }
        },
        "required": [
         "email",
         "password"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Logged in; also sets a session cookie",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "api_key": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "Wrong email/password or missing/wrong two-factor code",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Rate limited",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/forgot": {
   "post": {
    "tags": [
     "auth"
    ],
    "security": [],
    "summary": "Request a password-reset email",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "email": {
          "type": "string"
         }
        },
        "required": [
         "email"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Always returns ok (no account enumeration)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "Rate limited",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/reset": {
   "post": {
    "tags": [
     "auth"
    ],
    "security": [],
    "summary": "Set a new password with a reset token",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "token": {
          "type": "string",
          "description": "From the reset email; single-use, valid 1 h."
         },
         "password": {
          "type": "string",
          "description": "New password, 8+ chars."
         }
        },
        "required": [
         "token",
         "password"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Password changed; all sessions invalidated. On 2FA accounts no session cookie is set (`totp: true` in the response) — log in normally with the new password plus a code.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "totp": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Invalid or expired token",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/me": {
   "get": {
    "tags": [
     "account"
    ],
    "summary": "Who am I",
    "description": "When called with the full key or a session, includes both `api_key` and the read-only `ro_key`. When called with a read-only `wp_ro_` key, the full API key is omitted and `readonly: true` is set instead. Team-member sessions get `{member: true, account_email}` and never see keys.",
    "responses": {
     "200": {
      "description": "Current user",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "user": {
           "type": "object",
           "properties": {
            "id": {
             "type": "integer"
            },
            "email": {
             "type": "string"
            },
            "api_key": {
             "type": "string",
             "description": "Omitted on read-only-key calls."
            },
            "ro_key": {
             "type": "string",
             "description": "The account’s read-only key (wp_ro_…): same API, GET-only, mutations get 403, secrets redacted. Omitted on read-only-key and member calls."
            },
            "readonly": {
             "type": "boolean",
             "description": "Present (true) when authenticated with the read-only key."
            },
            "digest": {
             "type": "boolean",
             "description": "Weekly email digest enabled."
            },
            "totp": {
             "type": "boolean",
             "description": "Two-factor auth enabled."
            }
           }
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/password/change": {
   "post": {
    "tags": [
     "account"
    ],
    "summary": "Change password (logged in)",
    "description": "Requires the current password even on an authenticated call. On success, every other browser session is logged out (a session making the request survives), outstanding password-reset links are voided, and the API key is unchanged. Rate-limited to 10 attempts/hour per account.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "current_password": {
          "type": "string"
         },
         "new_password": {
          "type": "string",
          "description": "8+ characters."
         }
        },
        "required": [
         "current_password",
         "new_password"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Password changed",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "new_password too short",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "403": {
      "description": "Current password incorrect",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Too many attempts",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/2fa/setup": {
   "post": {
    "tags": [
     "account"
    ],
    "summary": "Start two-factor enrollment",
    "description": "Generates a TOTP secret (RFC 6238: SHA-1, 6 digits, 30 s) and returns it with an otpauth:// URI. 2FA is NOT active until `/api/2fa/enable` verifies a code. Calling again replaces the pending secret. Rate-limited to 10 two-factor operations/hour per account.",
    "responses": {
     "200": {
      "description": "Pending secret issued",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "secret": {
           "type": "string",
           "description": "Base32 TOTP secret."
          },
          "otpauth": {
           "type": "string",
           "description": "otpauth://totp/… URI for authenticator apps."
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Already enabled",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Too many attempts",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/2fa/enable": {
   "post": {
    "tags": [
     "account"
    ],
    "summary": "Turn on two-factor auth",
    "description": "Verifies a current code from the pending secret and activates 2FA. Returns 8 one-time recovery codes — shown exactly once, each usable once in place of a TOTP code at login. Other browser sessions are logged out. API-key access is unaffected.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "code": {
          "type": "string",
          "description": "Current 6-digit code from the authenticator app."
         }
        },
        "required": [
         "code"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "2FA enabled",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "recovery_codes": {
           "type": "array",
           "items": {
            "type": "string"
           }
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "No pending enrollment / already enabled",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "403": {
      "description": "Wrong code",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Too many attempts",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/2fa/disable": {
   "post": {
    "tags": [
     "account"
    ],
    "summary": "Turn off two-factor auth",
    "description": "Requires the account password even on an authenticated call. Also cancels a pending (never-enabled) enrollment, in which case no password is needed. Unused recovery codes are voided.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "password": {
          "type": "string",
          "description": "Account password (required when 2FA is on)."
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "2FA disabled",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Not enabled",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "403": {
      "description": "Wrong password",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Too many attempts",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/key/rotate": {
   "post": {
    "tags": [
     "account"
    ],
    "summary": "Rotate API key",
    "description": "Issues a new `wp_` API key and invalidates the old one immediately (the next request using it gets a 401). Use after a key leak. Browser sessions, status pages and badges are unaffected; update scripts, Prometheus scrape configs and CLI machines with the returned key.",
    "responses": {
     "200": {
      "description": "New key issued",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "api_key": {
           "type": "string",
           "description": "The new key. The old key is already dead."
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/key/ro/rotate": {
   "post": {
    "tags": [
     "account"
    ],
    "summary": "Rotate the read-only API key",
    "description": "Issues a new `wp_ro_` read-only key and invalidates the old one immediately. The read-only key reads the whole account through the same API but gets a 403 on every non-GET request, never reveals the full API key, and webhook signing secrets / Telegram bot tokens are redacted from its view. Requires the full key or a session (read-only keys cannot rotate themselves). The full API key is unaffected.",
    "responses": {
     "200": {
      "description": "New read-only key issued",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "ro_key": {
           "type": "string",
           "description": "The new wp_ro_ key. The old one is already dead."
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "403": {
      "description": "Called with a read-only key, or by a team member",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/settings": {
   "post": {
    "tags": [
     "account"
    ],
    "summary": "Update settings",
    "description": "PATCH also accepted. Currently supported: `digest` (weekly email digest on/off).",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "digest": {
          "type": "boolean"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Updated",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "digest": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Nothing to update",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/members": {
   "get": {
    "tags": [
     "account"
    ],
    "summary": "List team members",
    "description": "Team members are extra logins on this account (up to 10, free): each member has their own email + password and co-manages the account’s monitors, status pages and alert channels. Owner-only surface (403 for member sessions): password/2FA/API key/digest/team list/account deletion.",
    "responses": {
     "200": {
      "description": "Member list",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "members": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Member"
           }
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "403": {
      "description": "Owner only",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "post": {
    "tags": [
     "account"
    ],
    "summary": "Invite a team member",
    "description": "Emails an invitation link; the teammate picks their own password to join. Re-POSTing a pending address re-sends (and rotates) the link. The address must not already be a Watchpup account or on another team. Rate limit 10/h.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "email": {
          "type": "string",
          "description": "Teammate’s email address."
         }
        },
        "required": [
         "email"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Invitation sent",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "id": {
           "type": "integer",
           "description": "Member id"
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Invalid email / own email / 10-member limit",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "403": {
      "description": "Owner only",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "409": {
      "description": "Already an account, already a member, or on another team",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Rate limited",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/audit": {
   "get": {
    "tags": [
     "account"
    ],
    "summary": "Activity log (audit trail)",
    "description": "Who changed what on this account: every successful mutating API/dashboard/CLI action plus sign-ins, recorded automatically with actor email (owner or team member), via (`web` session or `api` key), a dotted action name (e.g. `monitor.update`), the target, and short detail. For edits only the names of touched fields are recorded, never values. Kept 90 days / max 1000 entries; visible to owner and team members; newest first. Not included in account exports.",
    "parameters": [
     {
      "name": "days",
      "in": "query",
      "schema": {
       "type": "integer",
       "default": 30,
       "maximum": 90
      },
      "description": "Look-back window in days (1–90)."
     },
     {
      "name": "limit",
      "in": "query",
      "schema": {
       "type": "integer",
       "default": 100,
       "maximum": 500
      },
      "description": "Max entries returned (1–500)."
     }
    ],
    "responses": {
     "200": {
      "description": "Activity entries, newest first",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "days": {
           "type": "integer"
          },
          "entries": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "ts": {
              "type": "integer",
              "description": "Unix seconds."
             },
             "actor": {
              "type": "string",
              "description": "Email of who did it."
             },
             "via": {
              "type": "string",
              "description": "`web` (session) or `api` (bearer key — the CLI counts as api)."
             },
             "action": {
              "type": "string",
              "description": "Dotted action name, e.g. `monitor.create`, `auth.login`, `key.rotate`."
             },
             "target": {
              "type": "string",
              "description": "e.g. `monitors/<id>`, `channels/<id>`, `pages/<slug>` — null for account-level actions.",
              "nullable": true
             },
             "target_name": {
              "type": "string",
              "description": "Display name when the target still exists (deleted targets keep the bare id)."
             },
             "detail": {
              "type": "string",
              "description": "Short context — e.g. `fields: slow_ms` on edits, counts on imports; null when there is none.",
              "nullable": true
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/members/{id}": {
   "delete": {
    "tags": [
     "account"
    ],
    "summary": "Remove a team member / revoke an invite",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Member id from GET /api/members."
     }
    ],
    "description": "Removing an active member logs out their sessions immediately; revoking a pending invite kills the emailed link.",
    "responses": {
     "200": {
      "description": "Removed",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "Owner only",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "Not found",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/invite/accept": {
   "post": {
    "tags": [
     "auth"
    ],
    "security": [],
    "summary": "Accept a team invitation",
    "description": "Called by the invite page at /invite/{token}. Sets the member’s own password and logs them in (session cookie). The token comes from the invitation email and dies on accept or revoke.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "token": {
          "type": "string",
          "description": "Invite token from the emailed link."
         },
         "password": {
          "type": "string",
          "description": "The member’s new password (8+ chars)."
         }
        },
        "required": [
         "token",
         "password"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Joined",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Bad token or password",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Rate limited",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/account": {
   "delete": {
    "tags": [
     "account"
    ],
    "summary": "Delete account (permanent)",
    "description": "Permanently deletes the account and ALL its data: monitors, check history, status pages, page subscribers, alert channels, maintenance windows, sessions. `confirm` must equal the account email. No undo.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "confirm": {
          "type": "string",
          "description": "Your account email, exactly (case-insensitive)."
         }
        },
        "required": [
         "confirm"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Account deleted",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "deleted": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Confirmation missing or does not match account email",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/digest/preview": {
   "get": {
    "tags": [
     "account"
    ],
    "summary": "Preview this week’s digest email",
    "responses": {
     "200": {
      "description": "Digest content",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "subject": {
           "type": "string"
          },
          "text": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "No monitors yet",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/monitors": {
   "get": {
    "tags": [
     "monitors"
    ],
    "summary": "List monitors",
    "parameters": [
     {
      "name": "tag",
      "in": "query",
      "schema": {
       "type": "string"
      },
      "description": "Only monitors carrying this tag. Comma-separate to require several at once (\"prod,eu-west\" = has BOTH)."
     }
    ],
    "responses": {
     "200": {
      "description": "All your monitors (or those matching ?tag=)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "monitors": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Monitor"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Invalid tag filter",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "post": {
    "tags": [
     "monitors"
    ],
    "summary": "Create a monitor",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "kind": {
          "type": "string",
          "description": "Monitor type. \"http\" = we fetch your URL on a schedule; \"tcp\" = we open a TCP connection to host:port; \"heartbeat\" = your job pings us (dead-man’s switch); \"dns\" = DNS-record check via DNS-over-HTTPS; \"domain\" = daily RDAP domain-expiry watch; \"tls\" = daily TLS-certificate expiry watch via a real handshake. Default \"http\".",
          "enum": [
           "http",
           "tcp",
           "heartbeat",
           "dns",
           "domain",
           "tls"
          ]
         },
         "name": {
          "type": "string",
          "description": "Display name (≤80 chars). Optional at create for kinds with a target — defaults to the URL host / target; heartbeat monitors require one."
         },
         "url": {
          "type": "string",
          "description": "Public http(s) URL to check. Required for kind=http. Private/internal addresses are rejected."
         },
         "method": {
          "type": "string",
          "description": "HTTP method for checks. Default GET.",
          "enum": [
           "GET",
           "HEAD",
           "POST",
           "PUT",
           "PATCH",
           "DELETE",
           "OPTIONS"
          ]
         },
         "headers": {
          "description": "Custom request headers sent with every check (http monitors) — an object like {\"Authorization\":\"Bearer …\",\"X-Api-Key\":\"abc\"} or a single string of \"Name: value\" lines. ≤10 headers, ≤1024 chars per value. Hop-by-hop/infrastructure headers (Host, Content-Length, X-Forwarded-*, CF-*, …) are rejected. If a redirect leaves the original host, Authorization/Cookie/Proxy-Authorization are dropped before following. {} or \"\" clears.",
          "oneOf": [
           {
            "type": "object",
            "additionalProperties": {
             "type": "string"
            }
           },
           {
            "type": "string"
           }
          ]
         },
         "body": {
          "type": "string",
          "description": "Custom request body sent with every check (http monitors, ≤4KB). Requires a method that allows one (POST/PUT/PATCH/DELETE). Set a content-type header via `headers` if it matters. \"\" clears."
         },
         "interval": {
          "type": "integer",
          "description": "Seconds between checks (http/tcp) or expected ping interval (heartbeat). Clamped 60–86400. Default 60 (http) / 3600 (heartbeat). Domain monitors: clamped 3600–604800, default 86400 (daily)."
         },
         "grace": {
          "type": "integer",
          "description": "Heartbeat only: extra seconds allowed after a missed ping before alerting. Default 300."
         },
         "cron": {
          "type": "string",
          "description": "Heartbeat only: the job’s cron schedule (standard 5-field — e.g. \"0 3 * * *\"; ranges, lists, steps and @hourly/@daily/@weekly/@monthly aliases supported, names like MON are not). When set, a ping is expected after every scheduled occurrence (+grace) instead of every `interval` seconds. Empty string clears it (PATCH), returning the monitor to interval mode and dropping tz."
         },
         "tz": {
          "type": "string",
          "description": "Heartbeat + cron only: IANA timezone the cron schedule is read in (e.g. \"Europe/Berlin\"). Daylight-saving aware — a wall time skipped by spring-forward is missed that day, a repeated fall-back time counts once. Empty string / omitted = UTC."
         },
         "expect": {
          "type": "string",
          "description": "Expected HTTP status codes: exact codes, ranges, or classes, comma-separated — e.g. \"200\", \"2xx,301\", \"200-204\". Default: any status < 400 is up."
         },
         "keyword": {
          "type": "string",
          "description": "Substring the response body must contain (≤100 chars)."
         },
         "keyword_absent": {
          "type": "string",
          "description": "Set to \"absent\"/true to flip the keyword rule: DOWN if the keyword IS present (catch error pages)."
         },
         "json_path": {
          "type": "string",
          "description": "JSON assertion (http): dot-separated keys with optional [N] array indices — e.g. \"status\", \"data.items[0].state\". DOWN when the response is not valid JSON or the path is missing. \"\" clears (and clears json_expect)."
         },
         "json_expect": {
          "type": "string",
          "description": "JSON assertion value (http, requires json_path): DOWN unless the field equals this string — numbers/booleans/null compare as \"3.5\"/\"true\"/\"null\". \"\" = existence-only check."
         },
         "header_name": {
          "type": "string",
          "description": "Response-header assertion (http): a header that must be present in the response — e.g. \"content-type\" or \"strict-transport-security\" (names are case-insensitive). DOWN when missing. \"\" clears (and clears header_expect)."
         },
         "header_expect": {
          "type": "string",
          "description": "Response-header assertion value (http, requires header_name): DOWN unless the header value contains this string, case-insensitive — \"application/json\" matches \"application/json; charset=utf-8\". \"\" = presence-only check."
         },
         "slow_ms": {
          "description": "http/tcp: response-time threshold in ms (100–60000; 0 = off), or the string \"auto\" for an adaptive threshold — 2× the monitor’s own 24h median latency, re-learned hourly (needs ~20 successful checks to arm; never under 200ms). 3 consecutive slower checks → 🐢 slow alert; 3 faster → recovery.",
          "oneOf": [
           {
            "type": "integer"
           },
           {
            "type": "string",
            "enum": [
             "auto"
            ]
           }
          ]
         },
         "target": {
          "type": "string",
          "description": "TCP monitors: \"host:port\" to connect to (e.g. \"db.example.com:5432\"); response time = connect latency, slow_ms applies. TLS monitors: \"host\" or \"host:port\" (default 443) whose certificate to watch. DNS monitors: the exact DNS name to query (subdomains and underscore names like \"_dmarc.example.com\" are fine). Required for kind=tcp/tls/dns. Port 25 and private/internal addresses are rejected."
         },
         "dns_type": {
          "type": "string",
          "description": "DNS monitors: record type to query. Default \"A\".",
          "enum": [
           "A",
           "AAAA",
           "CNAME",
           "MX",
           "TXT",
           "NS",
           "SRV",
           "CAA"
          ]
         },
         "dns_expect": {
          "type": "string",
          "description": "DNS monitors: optional expected record value — DOWN unless a record matches. Matching ignores case and trailing dots; TXT matches when a record contains the value; MX/SRV can be given as just the target host. Empty string on PATCH clears it (any record of the type then counts as up)."
         },
         "domain": {
          "type": "string",
          "description": "Domain monitors: registrable domain to watch (e.g. \"example.com\" — not www.example.com). Required for kind=domain."
         },
         "warn_days": {
          "type": "integer",
          "description": "Domain/TLS monitors: go DOWN and alert this many days before the registration/certificate expires. 1–365; default 30 (domain) / 14 (tls)."
         },
         "sla_target": {
          "type": "number",
          "description": "Monthly uptime SLA target percent (90–99.999; 0 or \"\" = off). Enables error-budget math on GET, Prometheus sla gauges, and automatic alerts (webhook event monitor.budget) when 75% / 100% of the month’s budget is used. Not supported for domain/tls/dns monitors."
         },
         "renotify": {
          "type": "integer",
          "description": "\"Still down\" reminder interval in seconds: while the monitor stays down, all alert channels are re-notified this often until it recovers (webhook event monitor.still with down_since/down_for_s). 300–86400; 0 = off (default)."
         },
         "alert_delay_s": {
          "type": "integer",
          "description": "Per-monitor escalation delay in seconds: EVERY alert channel waits at least this long before hearing this monitor’s down alerts, and the alert is cancelled if the monitor recovers first (that channel then hears nothing — no down, no recovery, no reminders). Stacks with each channel’s own delay_s: the longer of the two wins. Only down alerts are delayed. 30–86400 (clamped); 0 = alert immediately (default)."
         },
         "max_duration_s": {
          "type": "integer",
          "description": "Heartbeat only: run-duration limit in seconds (1–86400; 0 = off). A measured run (one that used the /start signal) that succeeds but takes longer fires a 🐢 slow alert on that single run (webhook monitor.slow; the monitor stays up); the next run back under the limit sends the ⚡ recovery (monitor.fast). Changing it resets slow-alert state."
         },
         "channels": {
          "description": "Alert routing: array of alert-channel ids (from GET /api/channels) this monitor’s alerts go to — applies to every alert type incl. reminders and budget warnings. null or \"all\" = all channels (default, includes channels added later); [] = mute this monitor’s owner alerts (status pages and their subscribers are unaffected). Max 20 ids.",
          "oneOf": [
           {
            "type": "array",
            "items": {
             "type": "integer"
            }
           },
           {
            "type": "string",
            "enum": [
             "all"
            ]
           }
          ],
          "nullable": true
         },
         "tags": {
          "description": "Up to 10 tags to label the monitor with (array or comma-separated string) — lowercase letters, digits, \"-\", \"_\", \".\", 1–20 chars each. Filter with GET /api/monitors?tag=… . null/\"\"/[] clears. Sent on PATCH, the list REPLACES the previous tags.",
          "oneOf": [
           {
            "type": "array",
            "items": {
             "type": "string"
            }
           },
           {
            "type": "string"
           }
          ],
          "nullable": true
         }
        },
        "description": "For kind=http, `url` is required. `name` is optional (defaults from the target) except for heartbeat monitors."
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Created. Heartbeat monitors include `ping_url` — call it from your job.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "monitor": {
           "$ref": "#/components/schemas/Monitor"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Validation error or monitor limit reached",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/monitors/bulk": {
   "post": {
    "tags": [
     "monitors"
    ],
    "summary": "Bulk-import up to 50 monitors in one call",
    "description": "Body is either a raw JSON array of monitor specs or `{\"monitors\": […], \"skip_existing\": true}`. Valid items are created even if others fail; results come back per item in input order. `skip_existing` skips items matching an existing monitor (http by url, domain by domain, heartbeat by name).",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "oneOf": [
         {
          "type": "array",
          "items": {
           "type": "object",
           "properties": {
            "kind": {
             "type": "string",
             "description": "Monitor type. \"http\" = we fetch your URL on a schedule; \"tcp\" = we open a TCP connection to host:port; \"heartbeat\" = your job pings us (dead-man’s switch); \"dns\" = DNS-record check via DNS-over-HTTPS; \"domain\" = daily RDAP domain-expiry watch; \"tls\" = daily TLS-certificate expiry watch via a real handshake. Default \"http\".",
             "enum": [
              "http",
              "tcp",
              "heartbeat",
              "dns",
              "domain",
              "tls"
             ]
            },
            "name": {
             "type": "string",
             "description": "Display name (≤80 chars). Optional at create for kinds with a target — defaults to the URL host / target; heartbeat monitors require one."
            },
            "url": {
             "type": "string",
             "description": "Public http(s) URL to check. Required for kind=http. Private/internal addresses are rejected."
            },
            "method": {
             "type": "string",
             "description": "HTTP method for checks. Default GET.",
             "enum": [
              "GET",
              "HEAD",
              "POST",
              "PUT",
              "PATCH",
              "DELETE",
              "OPTIONS"
             ]
            },
            "headers": {
             "description": "Custom request headers sent with every check (http monitors) — an object like {\"Authorization\":\"Bearer …\",\"X-Api-Key\":\"abc\"} or a single string of \"Name: value\" lines. ≤10 headers, ≤1024 chars per value. Hop-by-hop/infrastructure headers (Host, Content-Length, X-Forwarded-*, CF-*, …) are rejected. If a redirect leaves the original host, Authorization/Cookie/Proxy-Authorization are dropped before following. {} or \"\" clears.",
             "oneOf": [
              {
               "type": "object",
               "additionalProperties": {
                "type": "string"
               }
              },
              {
               "type": "string"
              }
             ]
            },
            "body": {
             "type": "string",
             "description": "Custom request body sent with every check (http monitors, ≤4KB). Requires a method that allows one (POST/PUT/PATCH/DELETE). Set a content-type header via `headers` if it matters. \"\" clears."
            },
            "interval": {
             "type": "integer",
             "description": "Seconds between checks (http/tcp) or expected ping interval (heartbeat). Clamped 60–86400. Default 60 (http) / 3600 (heartbeat). Domain monitors: clamped 3600–604800, default 86400 (daily)."
            },
            "grace": {
             "type": "integer",
             "description": "Heartbeat only: extra seconds allowed after a missed ping before alerting. Default 300."
            },
            "cron": {
             "type": "string",
             "description": "Heartbeat only: the job’s cron schedule (standard 5-field — e.g. \"0 3 * * *\"; ranges, lists, steps and @hourly/@daily/@weekly/@monthly aliases supported, names like MON are not). When set, a ping is expected after every scheduled occurrence (+grace) instead of every `interval` seconds. Empty string clears it (PATCH), returning the monitor to interval mode and dropping tz."
            },
            "tz": {
             "type": "string",
             "description": "Heartbeat + cron only: IANA timezone the cron schedule is read in (e.g. \"Europe/Berlin\"). Daylight-saving aware — a wall time skipped by spring-forward is missed that day, a repeated fall-back time counts once. Empty string / omitted = UTC."
            },
            "expect": {
             "type": "string",
             "description": "Expected HTTP status codes: exact codes, ranges, or classes, comma-separated — e.g. \"200\", \"2xx,301\", \"200-204\". Default: any status < 400 is up."
            },
            "keyword": {
             "type": "string",
             "description": "Substring the response body must contain (≤100 chars)."
            },
            "keyword_absent": {
             "type": "string",
             "description": "Set to \"absent\"/true to flip the keyword rule: DOWN if the keyword IS present (catch error pages)."
            },
            "json_path": {
             "type": "string",
             "description": "JSON assertion (http): dot-separated keys with optional [N] array indices — e.g. \"status\", \"data.items[0].state\". DOWN when the response is not valid JSON or the path is missing. \"\" clears (and clears json_expect)."
            },
            "json_expect": {
             "type": "string",
             "description": "JSON assertion value (http, requires json_path): DOWN unless the field equals this string — numbers/booleans/null compare as \"3.5\"/\"true\"/\"null\". \"\" = existence-only check."
            },
            "header_name": {
             "type": "string",
             "description": "Response-header assertion (http): a header that must be present in the response — e.g. \"content-type\" or \"strict-transport-security\" (names are case-insensitive). DOWN when missing. \"\" clears (and clears header_expect)."
            },
            "header_expect": {
             "type": "string",
             "description": "Response-header assertion value (http, requires header_name): DOWN unless the header value contains this string, case-insensitive — \"application/json\" matches \"application/json; charset=utf-8\". \"\" = presence-only check."
            },
            "slow_ms": {
             "description": "http/tcp: response-time threshold in ms (100–60000; 0 = off), or the string \"auto\" for an adaptive threshold — 2× the monitor’s own 24h median latency, re-learned hourly (needs ~20 successful checks to arm; never under 200ms). 3 consecutive slower checks → 🐢 slow alert; 3 faster → recovery.",
             "oneOf": [
              {
               "type": "integer"
              },
              {
               "type": "string",
               "enum": [
                "auto"
               ]
              }
             ]
            },
            "target": {
             "type": "string",
             "description": "TCP monitors: \"host:port\" to connect to (e.g. \"db.example.com:5432\"); response time = connect latency, slow_ms applies. TLS monitors: \"host\" or \"host:port\" (default 443) whose certificate to watch. DNS monitors: the exact DNS name to query (subdomains and underscore names like \"_dmarc.example.com\" are fine). Required for kind=tcp/tls/dns. Port 25 and private/internal addresses are rejected."
            },
            "dns_type": {
             "type": "string",
             "description": "DNS monitors: record type to query. Default \"A\".",
             "enum": [
              "A",
              "AAAA",
              "CNAME",
              "MX",
              "TXT",
              "NS",
              "SRV",
              "CAA"
             ]
            },
            "dns_expect": {
             "type": "string",
             "description": "DNS monitors: optional expected record value — DOWN unless a record matches. Matching ignores case and trailing dots; TXT matches when a record contains the value; MX/SRV can be given as just the target host. Empty string on PATCH clears it (any record of the type then counts as up)."
            },
            "domain": {
             "type": "string",
             "description": "Domain monitors: registrable domain to watch (e.g. \"example.com\" — not www.example.com). Required for kind=domain."
            },
            "warn_days": {
             "type": "integer",
             "description": "Domain/TLS monitors: go DOWN and alert this many days before the registration/certificate expires. 1–365; default 30 (domain) / 14 (tls)."
            },
            "sla_target": {
             "type": "number",
             "description": "Monthly uptime SLA target percent (90–99.999; 0 or \"\" = off). Enables error-budget math on GET, Prometheus sla gauges, and automatic alerts (webhook event monitor.budget) when 75% / 100% of the month’s budget is used. Not supported for domain/tls/dns monitors."
            },
            "renotify": {
             "type": "integer",
             "description": "\"Still down\" reminder interval in seconds: while the monitor stays down, all alert channels are re-notified this often until it recovers (webhook event monitor.still with down_since/down_for_s). 300–86400; 0 = off (default)."
            },
            "alert_delay_s": {
             "type": "integer",
             "description": "Per-monitor escalation delay in seconds: EVERY alert channel waits at least this long before hearing this monitor’s down alerts, and the alert is cancelled if the monitor recovers first (that channel then hears nothing — no down, no recovery, no reminders). Stacks with each channel’s own delay_s: the longer of the two wins. Only down alerts are delayed. 30–86400 (clamped); 0 = alert immediately (default)."
            },
            "max_duration_s": {
             "type": "integer",
             "description": "Heartbeat only: run-duration limit in seconds (1–86400; 0 = off). A measured run (one that used the /start signal) that succeeds but takes longer fires a 🐢 slow alert on that single run (webhook monitor.slow; the monitor stays up); the next run back under the limit sends the ⚡ recovery (monitor.fast). Changing it resets slow-alert state."
            },
            "channels": {
             "description": "Alert routing: array of alert-channel ids (from GET /api/channels) this monitor’s alerts go to — applies to every alert type incl. reminders and budget warnings. null or \"all\" = all channels (default, includes channels added later); [] = mute this monitor’s owner alerts (status pages and their subscribers are unaffected). Max 20 ids.",
             "oneOf": [
              {
               "type": "array",
               "items": {
                "type": "integer"
               }
              },
              {
               "type": "string",
               "enum": [
                "all"
               ]
              }
             ],
             "nullable": true
            },
            "tags": {
             "description": "Up to 10 tags to label the monitor with (array or comma-separated string) — lowercase letters, digits, \"-\", \"_\", \".\", 1–20 chars each. Filter with GET /api/monitors?tag=… . null/\"\"/[] clears. Sent on PATCH, the list REPLACES the previous tags.",
             "oneOf": [
              {
               "type": "array",
               "items": {
                "type": "string"
               }
              },
              {
               "type": "string"
              }
             ],
             "nullable": true
            }
           }
          },
          "description": "Raw array of monitor specs"
         },
         {
          "type": "object",
          "properties": {
           "monitors": {
            "type": "array",
            "items": {
             "type": "object",
             "properties": {
              "kind": {
               "type": "string",
               "description": "Monitor type. \"http\" = we fetch your URL on a schedule; \"tcp\" = we open a TCP connection to host:port; \"heartbeat\" = your job pings us (dead-man’s switch); \"dns\" = DNS-record check via DNS-over-HTTPS; \"domain\" = daily RDAP domain-expiry watch; \"tls\" = daily TLS-certificate expiry watch via a real handshake. Default \"http\".",
               "enum": [
                "http",
                "tcp",
                "heartbeat",
                "dns",
                "domain",
                "tls"
               ]
              },
              "name": {
               "type": "string",
               "description": "Display name (≤80 chars). Optional at create for kinds with a target — defaults to the URL host / target; heartbeat monitors require one."
              },
              "url": {
               "type": "string",
               "description": "Public http(s) URL to check. Required for kind=http. Private/internal addresses are rejected."
              },
              "method": {
               "type": "string",
               "description": "HTTP method for checks. Default GET.",
               "enum": [
                "GET",
                "HEAD",
                "POST",
                "PUT",
                "PATCH",
                "DELETE",
                "OPTIONS"
               ]
              },
              "headers": {
               "description": "Custom request headers sent with every check (http monitors) — an object like {\"Authorization\":\"Bearer …\",\"X-Api-Key\":\"abc\"} or a single string of \"Name: value\" lines. ≤10 headers, ≤1024 chars per value. Hop-by-hop/infrastructure headers (Host, Content-Length, X-Forwarded-*, CF-*, …) are rejected. If a redirect leaves the original host, Authorization/Cookie/Proxy-Authorization are dropped before following. {} or \"\" clears.",
               "oneOf": [
                {
                 "type": "object",
                 "additionalProperties": {
                  "type": "string"
                 }
                },
                {
                 "type": "string"
                }
               ]
              },
              "body": {
               "type": "string",
               "description": "Custom request body sent with every check (http monitors, ≤4KB). Requires a method that allows one (POST/PUT/PATCH/DELETE). Set a content-type header via `headers` if it matters. \"\" clears."
              },
              "interval": {
               "type": "integer",
               "description": "Seconds between checks (http/tcp) or expected ping interval (heartbeat). Clamped 60–86400. Default 60 (http) / 3600 (heartbeat). Domain monitors: clamped 3600–604800, default 86400 (daily)."
              },
              "grace": {
               "type": "integer",
               "description": "Heartbeat only: extra seconds allowed after a missed ping before alerting. Default 300."
              },
              "cron": {
               "type": "string",
               "description": "Heartbeat only: the job’s cron schedule (standard 5-field — e.g. \"0 3 * * *\"; ranges, lists, steps and @hourly/@daily/@weekly/@monthly aliases supported, names like MON are not). When set, a ping is expected after every scheduled occurrence (+grace) instead of every `interval` seconds. Empty string clears it (PATCH), returning the monitor to interval mode and dropping tz."
              },
              "tz": {
               "type": "string",
               "description": "Heartbeat + cron only: IANA timezone the cron schedule is read in (e.g. \"Europe/Berlin\"). Daylight-saving aware — a wall time skipped by spring-forward is missed that day, a repeated fall-back time counts once. Empty string / omitted = UTC."
              },
              "expect": {
               "type": "string",
               "description": "Expected HTTP status codes: exact codes, ranges, or classes, comma-separated — e.g. \"200\", \"2xx,301\", \"200-204\". Default: any status < 400 is up."
              },
              "keyword": {
               "type": "string",
               "description": "Substring the response body must contain (≤100 chars)."
              },
              "keyword_absent": {
               "type": "string",
               "description": "Set to \"absent\"/true to flip the keyword rule: DOWN if the keyword IS present (catch error pages)."
              },
              "json_path": {
               "type": "string",
               "description": "JSON assertion (http): dot-separated keys with optional [N] array indices — e.g. \"status\", \"data.items[0].state\". DOWN when the response is not valid JSON or the path is missing. \"\" clears (and clears json_expect)."
              },
              "json_expect": {
               "type": "string",
               "description": "JSON assertion value (http, requires json_path): DOWN unless the field equals this string — numbers/booleans/null compare as \"3.5\"/\"true\"/\"null\". \"\" = existence-only check."
              },
              "header_name": {
               "type": "string",
               "description": "Response-header assertion (http): a header that must be present in the response — e.g. \"content-type\" or \"strict-transport-security\" (names are case-insensitive). DOWN when missing. \"\" clears (and clears header_expect)."
              },
              "header_expect": {
               "type": "string",
               "description": "Response-header assertion value (http, requires header_name): DOWN unless the header value contains this string, case-insensitive — \"application/json\" matches \"application/json; charset=utf-8\". \"\" = presence-only check."
              },
              "slow_ms": {
               "description": "http/tcp: response-time threshold in ms (100–60000; 0 = off), or the string \"auto\" for an adaptive threshold — 2× the monitor’s own 24h median latency, re-learned hourly (needs ~20 successful checks to arm; never under 200ms). 3 consecutive slower checks → 🐢 slow alert; 3 faster → recovery.",
               "oneOf": [
                {
                 "type": "integer"
                },
                {
                 "type": "string",
                 "enum": [
                  "auto"
                 ]
                }
               ]
              },
              "target": {
               "type": "string",
               "description": "TCP monitors: \"host:port\" to connect to (e.g. \"db.example.com:5432\"); response time = connect latency, slow_ms applies. TLS monitors: \"host\" or \"host:port\" (default 443) whose certificate to watch. DNS monitors: the exact DNS name to query (subdomains and underscore names like \"_dmarc.example.com\" are fine). Required for kind=tcp/tls/dns. Port 25 and private/internal addresses are rejected."
              },
              "dns_type": {
               "type": "string",
               "description": "DNS monitors: record type to query. Default \"A\".",
               "enum": [
                "A",
                "AAAA",
                "CNAME",
                "MX",
                "TXT",
                "NS",
                "SRV",
                "CAA"
               ]
              },
              "dns_expect": {
               "type": "string",
               "description": "DNS monitors: optional expected record value — DOWN unless a record matches. Matching ignores case and trailing dots; TXT matches when a record contains the value; MX/SRV can be given as just the target host. Empty string on PATCH clears it (any record of the type then counts as up)."
              },
              "domain": {
               "type": "string",
               "description": "Domain monitors: registrable domain to watch (e.g. \"example.com\" — not www.example.com). Required for kind=domain."
              },
              "warn_days": {
               "type": "integer",
               "description": "Domain/TLS monitors: go DOWN and alert this many days before the registration/certificate expires. 1–365; default 30 (domain) / 14 (tls)."
              },
              "sla_target": {
               "type": "number",
               "description": "Monthly uptime SLA target percent (90–99.999; 0 or \"\" = off). Enables error-budget math on GET, Prometheus sla gauges, and automatic alerts (webhook event monitor.budget) when 75% / 100% of the month’s budget is used. Not supported for domain/tls/dns monitors."
              },
              "renotify": {
               "type": "integer",
               "description": "\"Still down\" reminder interval in seconds: while the monitor stays down, all alert channels are re-notified this often until it recovers (webhook event monitor.still with down_since/down_for_s). 300–86400; 0 = off (default)."
              },
              "alert_delay_s": {
               "type": "integer",
               "description": "Per-monitor escalation delay in seconds: EVERY alert channel waits at least this long before hearing this monitor’s down alerts, and the alert is cancelled if the monitor recovers first (that channel then hears nothing — no down, no recovery, no reminders). Stacks with each channel’s own delay_s: the longer of the two wins. Only down alerts are delayed. 30–86400 (clamped); 0 = alert immediately (default)."
              },
              "max_duration_s": {
               "type": "integer",
               "description": "Heartbeat only: run-duration limit in seconds (1–86400; 0 = off). A measured run (one that used the /start signal) that succeeds but takes longer fires a 🐢 slow alert on that single run (webhook monitor.slow; the monitor stays up); the next run back under the limit sends the ⚡ recovery (monitor.fast). Changing it resets slow-alert state."
              },
              "channels": {
               "description": "Alert routing: array of alert-channel ids (from GET /api/channels) this monitor’s alerts go to — applies to every alert type incl. reminders and budget warnings. null or \"all\" = all channels (default, includes channels added later); [] = mute this monitor’s owner alerts (status pages and their subscribers are unaffected). Max 20 ids.",
               "oneOf": [
                {
                 "type": "array",
                 "items": {
                  "type": "integer"
                 }
                },
                {
                 "type": "string",
                 "enum": [
                  "all"
                 ]
                }
               ],
               "nullable": true
              },
              "tags": {
               "description": "Up to 10 tags to label the monitor with (array or comma-separated string) — lowercase letters, digits, \"-\", \"_\", \".\", 1–20 chars each. Filter with GET /api/monitors?tag=… . null/\"\"/[] clears. Sent on PATCH, the list REPLACES the previous tags.",
               "oneOf": [
                {
                 "type": "array",
                 "items": {
                  "type": "string"
                 }
                },
                {
                 "type": "string"
                }
               ],
               "nullable": true
              }
             }
            }
           },
           "skip_existing": {
            "type": "boolean"
           }
          },
          "required": [
           "monitors"
          ]
         }
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Per-item results (input order)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean",
           "description": "False if any item hard-failed."
          },
          "created": {
           "type": "integer"
          },
          "skipped": {
           "type": "integer"
          },
          "failed": {
           "type": "integer"
          },
          "results": {
           "type": "array",
           "items": {
            "oneOf": [
             {
              "type": "object",
              "properties": {
               "ok": {
                "type": "boolean"
               },
               "monitor": {
                "$ref": "#/components/schemas/Monitor"
               }
              },
              "required": [
               "monitor"
              ]
             },
             {
              "type": "object",
              "properties": {
               "ok": {
                "type": "boolean"
               },
               "skipped": {
                "type": "boolean"
               },
               "reason": {
                "type": "string"
               }
              },
              "required": [
               "skipped"
              ]
             },
             {
              "type": "object",
              "properties": {
               "ok": {
                "type": "boolean"
               },
               "error": {
                "type": "string"
               }
              },
              "required": [
               "error"
              ]
             }
            ]
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Body not an array/object, empty, or >50 items",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/import/uptimerobot": {
   "post": {
    "tags": [
     "monitors"
    ],
    "summary": "Import your monitors from UptimeRobot",
    "description": "Fetches up to 50 monitors from the UptimeRobot v2 API with the supplied key (a read-only key is enough) and creates Watchpup equivalents: HTTP and keyword monitors become `http` (keyword \"exists\" alerts map to the keyword_absent rule), port monitors become `tcp`, heartbeats become `heartbeat`. ICMP ping monitors are reported as unsupported. Monitors you already have are skipped unless `skip_existing` is false. The key is used for a single read and never stored. Rate limit: 5 imports/hour.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "api_key": {
          "type": "string",
          "description": "UptimeRobot API key (read-only recommended)."
         },
         "skip_existing": {
          "type": "boolean",
          "default": true
         }
        },
        "required": [
         "api_key"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Per-monitor results in UptimeRobot order",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean",
           "description": "False if any item hard-failed."
          },
          "found": {
           "type": "integer",
           "description": "Monitors fetched from UptimeRobot."
          },
          "created": {
           "type": "integer"
          },
          "skipped": {
           "type": "integer"
          },
          "failed": {
           "type": "integer"
          },
          "unsupported": {
           "type": "integer",
           "description": "Ping/unknown types that cannot be imported."
          },
          "results": {
           "type": "array",
           "items": {
            "oneOf": [
             {
              "type": "object",
              "properties": {
               "name": {
                "type": "string"
               },
               "ok": {
                "type": "boolean"
               },
               "monitor": {
                "$ref": "#/components/schemas/Monitor"
               }
              }
             },
             {
              "type": "object",
              "properties": {
               "name": {
                "type": "string"
               },
               "ok": {
                "type": "boolean"
               },
               "skipped": {
                "type": "boolean"
               },
               "reason": {
                "type": "string"
               }
              }
             },
             {
              "type": "object",
              "properties": {
               "name": {
                "type": "string"
               },
               "ok": {
                "type": "boolean"
               },
               "unsupported": {
                "type": "boolean"
               },
               "reason": {
                "type": "string"
               }
              }
             },
             {
              "type": "object",
              "properties": {
               "name": {
                "type": "string"
               },
               "ok": {
                "type": "boolean"
               },
               "error": {
                "type": "string"
               }
              }
             }
            ]
           }
          },
          "note": {
           "type": "string",
           "description": "Present when there were more monitors than could be fetched."
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Missing api_key, or UptimeRobot rejected the key",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Too many import attempts",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "502": {
      "description": "UptimeRobot API unreachable",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/import/healthchecks": {
   "post": {
    "tags": [
     "monitors"
    ],
    "summary": "Import your checks from Healthchecks.io",
    "description": "Fetches up to 50 checks from the Healthchecks v3 API with the supplied project key (the read-only key is enough) and creates a Watchpup heartbeat monitor for each: simple checks become interval heartbeats (period up to 1 day), cron checks keep their schedule and timezone, tags come along (invalid tag names are dropped). OnCalendar / non-standard schedules and periods over 1 day are reported unsupported. Pass `base_url` for a self-hosted Healthchecks instance. Checks whose name matches an existing heartbeat are skipped unless `skip_existing` is false. The key is used for a single read and never stored. Rate limit: 5 imports/hour.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "api_key": {
          "type": "string",
          "description": "Healthchecks project API key (read-only recommended)."
         },
         "base_url": {
          "type": "string",
          "description": "Base URL of a self-hosted Healthchecks instance (public http(s)). Omit for healthchecks.io."
         },
         "skip_existing": {
          "type": "boolean",
          "default": true
         }
        },
        "required": [
         "api_key"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Per-check results in Healthchecks order",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean",
           "description": "False if any item hard-failed."
          },
          "found": {
           "type": "integer",
           "description": "Checks processed (max 50)."
          },
          "created": {
           "type": "integer"
          },
          "skipped": {
           "type": "integer"
          },
          "failed": {
           "type": "integer"
          },
          "unsupported": {
           "type": "integer",
           "description": "OnCalendar / over-1-day-period checks that cannot be imported."
          },
          "results": {
           "type": "array",
           "items": {
            "oneOf": [
             {
              "type": "object",
              "properties": {
               "name": {
                "type": "string"
               },
               "ok": {
                "type": "boolean"
               },
               "monitor": {
                "$ref": "#/components/schemas/Monitor"
               }
              }
             },
             {
              "type": "object",
              "properties": {
               "name": {
                "type": "string"
               },
               "ok": {
                "type": "boolean"
               },
               "skipped": {
                "type": "boolean"
               },
               "reason": {
                "type": "string"
               }
              }
             },
             {
              "type": "object",
              "properties": {
               "name": {
                "type": "string"
               },
               "ok": {
                "type": "boolean"
               },
               "unsupported": {
                "type": "boolean"
               },
               "reason": {
                "type": "string"
               }
              }
             },
             {
              "type": "object",
              "properties": {
               "name": {
                "type": "string"
               },
               "ok": {
                "type": "boolean"
               },
               "error": {
                "type": "string"
               }
              }
             }
            ]
           }
          },
          "note": {
           "type": "string",
           "description": "Present when there were more checks than could be processed."
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Missing api_key, invalid base_url, or Healthchecks rejected the key",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Too many import attempts",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "502": {
      "description": "Healthchecks API unreachable",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/import/watchpup": {
   "post": {
    "tags": [
     "account"
    ],
    "summary": "Restore a Watchpup account export",
    "description": "Feeds an account export (the JSON file from GET /api/export) back into this account — the same account, a fresh one, or a different Watchpup instance. Restores in dependency order: alert channels, then monitors (full configuration, tags, thresholds, paused state; alert routing is remapped onto the restored channel ids), then status pages (monitor lists and groups remapped, branding kept), then active/upcoming maintenance windows (created silently — no subscriber notices). Idempotent: existing channels (same kind+target), monitors (same URL/target, heartbeats by name) and page slugs are skipped, so re-running is safe. Backups are secret-free, so restored webhook channels get NEW signing secrets (returned per item), Telegram channels are reported unsupported (bot tokens are masked in backups), protected pages come back public, and subscribers and team members are not restored (re-invite members from Settings). Alert emails to addresses other than the account owner trigger a fresh confirmation email. The body may be the export file itself, or wrapped as {\"export\": {...}, \"skip_existing\": true}. Dry run: pass ?dry_run=1 (or \"dry_run\": true in a wrapped body) to get the same per-item report without creating anything or sending any email — provisional ids/secrets are omitted from dry-run results. Rate limit: 5 restores/hour (dry runs 10/hour, counted separately).",
    "parameters": [
     {
      "name": "dry_run",
      "in": "query",
      "required": false,
      "schema": {
       "type": "string",
       "enum": [
        "1",
        "true",
        "on"
       ]
      },
      "description": "Preview only: report what a restore would do, change nothing, send no emails."
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "export": {
          "type": "object",
          "description": "The account export object (format `watchpup-account-export`, version 1). Alternatively send the export file itself as the whole body."
         },
         "skip_existing": {
          "type": "boolean",
          "description": "Skip things that already exist instead of failing on them.",
          "default": true
         },
         "dry_run": {
          "type": "boolean",
          "description": "Preview only — same as the ?dry_run=1 query parameter.",
          "default": false
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Per-section results (results arrays are in backup order)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean",
           "description": "False if anything hard-failed."
          },
          "dry_run": {
           "type": "boolean",
           "description": "Present (true) when this was a dry run and nothing was changed."
          },
          "channels": {
           "type": "object",
           "description": "found/created/skipped/failed/unsupported + per-channel results (restored webhooks include their new signing secret)."
          },
          "monitors": {
           "type": "object",
           "description": "found/created/skipped/failed/unsupported + per-monitor results (bulk-import shape)."
          },
          "status_pages": {
           "type": "object",
           "description": "found/created/skipped/failed + per-page results."
          },
          "maintenance_windows": {
           "type": "object",
           "description": "found/created/skipped/failed + per-window results."
          },
          "notes": {
           "type": "array",
           "items": {
            "type": "string"
           },
           "description": "Human-readable caveats: new webhook secrets, masked Telegram tokens, lost page passwords, dropped routing entries…"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Not a Watchpup account export, or unsupported export version",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Too many restore attempts",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/monitors/{id}": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string"
     },
     "description": "Monitor id (UUID)."
    }
   ],
   "get": {
    "tags": [
     "monitors"
    ],
    "summary": "Get one monitor",
    "responses": {
     "200": {
      "description": "Monitor. http/tcp monitors include `latency_24h` (null until there are successful checks) and `trend_1h` (last hour vs the hour before; null until both hours have 3+ successful checks). When an SLA target is set, `sla` carries month-to-date error-budget math.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "monitor": {
           "$ref": "#/components/schemas/Monitor"
          },
          "latency_24h": {
           "$ref": "#/components/schemas/Latency"
          },
          "trend_1h": {
           "$ref": "#/components/schemas/Trend"
          },
          "sla": {
           "$ref": "#/components/schemas/Sla"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "Not found",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "patch": {
    "tags": [
     "monitors"
    ],
    "summary": "Update a monitor",
    "description": "Send only the fields you want to change. `status:\"paused\"` pauses checks; `status:\"new\"` resumes. Changing `slow_ms` resets slow-alert state.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "kind": {
          "type": "string",
          "description": "Monitor type. \"http\" = we fetch your URL on a schedule; \"tcp\" = we open a TCP connection to host:port; \"heartbeat\" = your job pings us (dead-man’s switch); \"dns\" = DNS-record check via DNS-over-HTTPS; \"domain\" = daily RDAP domain-expiry watch; \"tls\" = daily TLS-certificate expiry watch via a real handshake. Default \"http\".",
          "enum": [
           "http",
           "tcp",
           "heartbeat",
           "dns",
           "domain",
           "tls"
          ]
         },
         "name": {
          "type": "string",
          "description": "Display name (≤80 chars). Optional at create for kinds with a target — defaults to the URL host / target; heartbeat monitors require one."
         },
         "url": {
          "type": "string",
          "description": "Public http(s) URL to check. Required for kind=http. Private/internal addresses are rejected."
         },
         "method": {
          "type": "string",
          "description": "HTTP method for checks. Default GET.",
          "enum": [
           "GET",
           "HEAD",
           "POST",
           "PUT",
           "PATCH",
           "DELETE",
           "OPTIONS"
          ]
         },
         "headers": {
          "description": "Custom request headers sent with every check (http monitors) — an object like {\"Authorization\":\"Bearer …\",\"X-Api-Key\":\"abc\"} or a single string of \"Name: value\" lines. ≤10 headers, ≤1024 chars per value. Hop-by-hop/infrastructure headers (Host, Content-Length, X-Forwarded-*, CF-*, …) are rejected. If a redirect leaves the original host, Authorization/Cookie/Proxy-Authorization are dropped before following. {} or \"\" clears.",
          "oneOf": [
           {
            "type": "object",
            "additionalProperties": {
             "type": "string"
            }
           },
           {
            "type": "string"
           }
          ]
         },
         "body": {
          "type": "string",
          "description": "Custom request body sent with every check (http monitors, ≤4KB). Requires a method that allows one (POST/PUT/PATCH/DELETE). Set a content-type header via `headers` if it matters. \"\" clears."
         },
         "interval": {
          "type": "integer",
          "description": "Seconds between checks (http/tcp) or expected ping interval (heartbeat). Clamped 60–86400. Default 60 (http) / 3600 (heartbeat). Domain monitors: clamped 3600–604800, default 86400 (daily)."
         },
         "grace": {
          "type": "integer",
          "description": "Heartbeat only: extra seconds allowed after a missed ping before alerting. Default 300."
         },
         "cron": {
          "type": "string",
          "description": "Heartbeat only: the job’s cron schedule (standard 5-field — e.g. \"0 3 * * *\"; ranges, lists, steps and @hourly/@daily/@weekly/@monthly aliases supported, names like MON are not). When set, a ping is expected after every scheduled occurrence (+grace) instead of every `interval` seconds. Empty string clears it (PATCH), returning the monitor to interval mode and dropping tz."
         },
         "tz": {
          "type": "string",
          "description": "Heartbeat + cron only: IANA timezone the cron schedule is read in (e.g. \"Europe/Berlin\"). Daylight-saving aware — a wall time skipped by spring-forward is missed that day, a repeated fall-back time counts once. Empty string / omitted = UTC."
         },
         "expect": {
          "type": "string",
          "description": "Expected HTTP status codes: exact codes, ranges, or classes, comma-separated — e.g. \"200\", \"2xx,301\", \"200-204\". Default: any status < 400 is up."
         },
         "keyword": {
          "type": "string",
          "description": "Substring the response body must contain (≤100 chars)."
         },
         "keyword_absent": {
          "type": "string",
          "description": "Set to \"absent\"/true to flip the keyword rule: DOWN if the keyword IS present (catch error pages)."
         },
         "json_path": {
          "type": "string",
          "description": "JSON assertion (http): dot-separated keys with optional [N] array indices — e.g. \"status\", \"data.items[0].state\". DOWN when the response is not valid JSON or the path is missing. \"\" clears (and clears json_expect)."
         },
         "json_expect": {
          "type": "string",
          "description": "JSON assertion value (http, requires json_path): DOWN unless the field equals this string — numbers/booleans/null compare as \"3.5\"/\"true\"/\"null\". \"\" = existence-only check."
         },
         "header_name": {
          "type": "string",
          "description": "Response-header assertion (http): a header that must be present in the response — e.g. \"content-type\" or \"strict-transport-security\" (names are case-insensitive). DOWN when missing. \"\" clears (and clears header_expect)."
         },
         "header_expect": {
          "type": "string",
          "description": "Response-header assertion value (http, requires header_name): DOWN unless the header value contains this string, case-insensitive — \"application/json\" matches \"application/json; charset=utf-8\". \"\" = presence-only check."
         },
         "slow_ms": {
          "description": "http/tcp: response-time threshold in ms (100–60000; 0 = off), or the string \"auto\" for an adaptive threshold — 2× the monitor’s own 24h median latency, re-learned hourly (needs ~20 successful checks to arm; never under 200ms). 3 consecutive slower checks → 🐢 slow alert; 3 faster → recovery.",
          "oneOf": [
           {
            "type": "integer"
           },
           {
            "type": "string",
            "enum": [
             "auto"
            ]
           }
          ]
         },
         "target": {
          "type": "string",
          "description": "TCP monitors: \"host:port\" to connect to (e.g. \"db.example.com:5432\"); response time = connect latency, slow_ms applies. TLS monitors: \"host\" or \"host:port\" (default 443) whose certificate to watch. DNS monitors: the exact DNS name to query (subdomains and underscore names like \"_dmarc.example.com\" are fine). Required for kind=tcp/tls/dns. Port 25 and private/internal addresses are rejected."
         },
         "dns_type": {
          "type": "string",
          "description": "DNS monitors: record type to query. Default \"A\".",
          "enum": [
           "A",
           "AAAA",
           "CNAME",
           "MX",
           "TXT",
           "NS",
           "SRV",
           "CAA"
          ]
         },
         "dns_expect": {
          "type": "string",
          "description": "DNS monitors: optional expected record value — DOWN unless a record matches. Matching ignores case and trailing dots; TXT matches when a record contains the value; MX/SRV can be given as just the target host. Empty string on PATCH clears it (any record of the type then counts as up)."
         },
         "domain": {
          "type": "string",
          "description": "Domain monitors: registrable domain to watch (e.g. \"example.com\" — not www.example.com). Required for kind=domain."
         },
         "warn_days": {
          "type": "integer",
          "description": "Domain/TLS monitors: go DOWN and alert this many days before the registration/certificate expires. 1–365; default 30 (domain) / 14 (tls)."
         },
         "sla_target": {
          "type": "number",
          "description": "Monthly uptime SLA target percent (90–99.999; 0 or \"\" = off). Enables error-budget math on GET, Prometheus sla gauges, and automatic alerts (webhook event monitor.budget) when 75% / 100% of the month’s budget is used. Not supported for domain/tls/dns monitors."
         },
         "renotify": {
          "type": "integer",
          "description": "\"Still down\" reminder interval in seconds: while the monitor stays down, all alert channels are re-notified this often until it recovers (webhook event monitor.still with down_since/down_for_s). 300–86400; 0 = off (default)."
         },
         "alert_delay_s": {
          "type": "integer",
          "description": "Per-monitor escalation delay in seconds: EVERY alert channel waits at least this long before hearing this monitor’s down alerts, and the alert is cancelled if the monitor recovers first (that channel then hears nothing — no down, no recovery, no reminders). Stacks with each channel’s own delay_s: the longer of the two wins. Only down alerts are delayed. 30–86400 (clamped); 0 = alert immediately (default)."
         },
         "max_duration_s": {
          "type": "integer",
          "description": "Heartbeat only: run-duration limit in seconds (1–86400; 0 = off). A measured run (one that used the /start signal) that succeeds but takes longer fires a 🐢 slow alert on that single run (webhook monitor.slow; the monitor stays up); the next run back under the limit sends the ⚡ recovery (monitor.fast). Changing it resets slow-alert state."
         },
         "channels": {
          "description": "Alert routing: array of alert-channel ids (from GET /api/channels) this monitor’s alerts go to — applies to every alert type incl. reminders and budget warnings. null or \"all\" = all channels (default, includes channels added later); [] = mute this monitor’s owner alerts (status pages and their subscribers are unaffected). Max 20 ids.",
          "oneOf": [
           {
            "type": "array",
            "items": {
             "type": "integer"
            }
           },
           {
            "type": "string",
            "enum": [
             "all"
            ]
           }
          ],
          "nullable": true
         },
         "tags": {
          "description": "Up to 10 tags to label the monitor with (array or comma-separated string) — lowercase letters, digits, \"-\", \"_\", \".\", 1–20 chars each. Filter with GET /api/monitors?tag=… . null/\"\"/[] clears. Sent on PATCH, the list REPLACES the previous tags.",
          "oneOf": [
           {
            "type": "array",
            "items": {
             "type": "string"
            }
           },
           {
            "type": "string"
           }
          ],
          "nullable": true
         },
         "status": {
          "type": "string",
          "description": "\"paused\" or \"new\" (resume).",
          "enum": [
           "paused",
           "new"
          ]
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Updated monitor",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "monitor": {
           "$ref": "#/components/schemas/Monitor"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Validation error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "Not found",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "delete": {
    "tags": [
     "monitors"
    ],
    "summary": "Delete a monitor and its history",
    "responses": {
     "200": {
      "description": "Deleted",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "Not found",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/monitors/{id}/checks.csv": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string"
     },
     "description": "Monitor id (UUID)."
    }
   ],
   "get": {
    "tags": [
     "monitors"
    ],
    "summary": "Export raw check history (CSV)",
    "description": "Every raw check result still retained (raw history is pruned to ~3 days), oldest first. Columns: `time,unix_ts,ok,response_ms,http_status` — response_ms/http_status are empty when not applicable (e.g. heartbeat pings, timeouts). Served as a `text/csv` attachment.",
    "responses": {
     "200": {
      "description": "CSV export",
      "content": {
       "text/csv": {
        "schema": {
         "type": "string",
         "description": "CSV rows, header first.",
         "example": "time,unix_ts,ok,response_ms,http_status\n2026-07-24T12:00:05.000Z,1784980805,1,142,200"
        }
       }
      }
     },
     "404": {
      "description": "Not found",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/monitors/{id}/daily.csv": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string"
     },
     "description": "Monitor id (UUID)."
    }
   ],
   "get": {
    "tags": [
     "monitors"
    ],
    "summary": "Export daily uptime history (CSV)",
    "description": "One row per UTC day from daily rollups (kept ~90 days) plus today-so-far, oldest first. Columns: `date,checks,ok,uptime_pct,avg_response_ms,partial` — `partial=1` marks the in-progress current day; avg_response_ms averages successful checks. Served as a `text/csv` attachment.",
    "responses": {
     "200": {
      "description": "CSV export",
      "content": {
       "text/csv": {
        "schema": {
         "type": "string",
         "description": "CSV rows, header first.",
         "example": "date,checks,ok,uptime_pct,avg_response_ms,partial\n2026-07-23,1440,1439,99.931,148,0"
        }
       }
      }
     },
     "404": {
      "description": "Not found",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/export": {
   "get": {
    "tags": [
     "account"
    ],
    "summary": "Full account export (JSON)",
    "description": "One JSON file with everything the account owns: profile, all monitors (full configuration in the same shape the monitors 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 store as a backup. Served as an `application/json` attachment. Limited to 10 exports per hour.",
    "responses": {
     "200": {
      "description": "Account export",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "format": {
           "type": "string",
           "description": "Always `watchpup-account-export`."
          },
          "version": {
           "type": "integer",
           "description": "Export format version (1)."
          },
          "exported_at": {
           "type": "integer",
           "description": "Unix seconds."
          },
          "exported_at_iso": {
           "type": "string",
           "description": "Same moment, ISO 8601."
          },
          "origin": {
           "type": "string",
           "description": "Origin the export was made from."
          },
          "note": {
           "type": "string",
           "description": "What the export contains / how to restore."
          },
          "account": {
           "type": "object",
           "description": "Profile: email, created_at, weekly_digest, totp_enabled."
          },
          "monitors": {
           "type": "array",
           "items": {
            "type": "object",
            "description": "Monitor in API shape."
           },
           "description": "All monitors with full configuration."
          },
          "channels": {
           "type": "array",
           "items": {
            "type": "object",
            "description": "Alert channel (no signing secret)."
           },
           "description": "Alert channels."
          },
          "status_pages": {
           "type": "array",
           "items": {
            "type": "object",
            "description": "Status page incl. subscribers."
           },
           "description": "Status pages."
          },
          "team_members": {
           "type": "array",
           "items": {
            "type": "object",
            "description": "Team member (email, status, invited_at, accepted_at, last_login) — for the record only; restore never recreates members."
           },
           "description": "Team members."
          },
          "maintenance_windows": {
           "type": "array",
           "items": {
            "type": "object",
            "description": "Maintenance window."
           },
           "description": "Maintenance windows."
          },
          "events_90d": {
           "type": "array",
           "items": {
            "type": "object",
            "description": "Down/up event with detail + note."
           },
           "description": "Last 90 days of events."
          },
          "daily_rollups": {
           "type": "array",
           "items": {
            "type": "object",
            "description": "Per-monitor per-day stats."
           },
           "description": "Last ~90 days of daily rollups."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "Rate limited (10/hour)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/ping/{id}": {
   "get": {
    "tags": [
     "heartbeats"
    ],
    "security": [],
    "summary": "Heartbeat ping",
    "description": "Call from your cron job / worker (GET or POST, e.g. `curl https://watchpup.watchpup.workers.dev/ping/…`). Marks the heartbeat monitor up. If no ping arrives within interval+grace, the monitor goes down and alerts fire. The monitor id is the secret — no auth header needed.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Heartbeat monitor id (from create response `ping_url`)."
     }
    ],
    "responses": {
     "200": {
      "description": "Ping recorded",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "monitor": {
           "type": "string",
           "description": "Monitor name."
          },
          "ts": {
           "type": "integer",
           "description": "Unix seconds."
          },
          "duration_s": {
           "type": "integer",
           "description": "Seconds since the run’s /start signal — present only when this ping completes a measured run."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "Unknown ping id",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "post": {
    "tags": [
     "heartbeats"
    ],
    "security": [],
    "summary": "Heartbeat ping (POST)",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Heartbeat monitor id."
     }
    ],
    "responses": {
     "200": {
      "description": "Ping recorded",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "monitor": {
           "type": "string"
          },
          "ts": {
           "type": "integer"
          },
          "duration_s": {
           "type": "integer",
           "description": "Present only when this ping completes a measured run."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "Unknown ping id",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/ping/{id}/start": {
   "get": {
    "tags": [
     "heartbeats"
    ],
    "security": [],
    "summary": "Start signal (GET or POST)",
    "description": "Report that a run has begun. Success (`/ping/{id}`) or failure (`/ping/{id}/fail`) must then arrive within the monitor’s grace time, or the monitor goes down (“job started but never reported”) — hung jobs are caught within minutes instead of at the next schedule slot. Also enables run-duration tracking: the next success ping records `last_duration_s`, and `running_since` is set while a run is in progress.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Heartbeat monitor id."
     }
    ],
    "responses": {
     "200": {
      "description": "Start recorded",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "monitor": {
           "type": "string"
          },
          "ts": {
           "type": "integer"
          },
          "signal": {
           "type": "string",
           "description": "Always `start`."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "Unknown ping id",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/ping/{id}/fail": {
   "get": {
    "tags": [
     "heartbeats"
    ],
    "security": [],
    "summary": "Failure signal (GET or POST)",
    "description": "The job reports its own failure: the monitor goes down and alerts fire immediately — no waiting for the next expected ping to be missed. A POSTed request body (first 300 characters) becomes the incident detail shown in alerts and on status pages. During a maintenance window the failure is recorded quietly (no alert, no downtime counted). The next successful ping recovers the monitor as usual.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Heartbeat monitor id."
     }
    ],
    "responses": {
     "200": {
      "description": "Failure recorded",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "monitor": {
           "type": "string"
          },
          "ts": {
           "type": "integer"
          },
          "signal": {
           "type": "string",
           "description": "Always `fail`."
          },
          "status": {
           "type": "string",
           "description": "`down` (or `maint` during a maintenance window)."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "Unknown ping id",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/metrics": {
   "get": {
    "tags": [
     "monitors"
    ],
    "summary": "Prometheus metrics for your monitors",
    "description": "Prometheus text exposition (version 0.0.4) covering every monitor on your account: up/down, maintenance, slow flag, last response ms, last HTTP status code, last-check timestamp, and 7d/30d uptime ratios — all labelled with {id, name, kind}. Point a standard Prometheus scrape job at this path with `authorization: { credentials: wp_… }` and graph your uptime in Grafana.",
    "responses": {
     "200": {
      "description": "Prometheus metrics",
      "content": {
       "text/plain": {
        "schema": {
         "type": "string",
         "description": "Prometheus text exposition format.",
         "example": "watchpup_monitor_up{id=\"abc123\",name=\"my api\",kind=\"http\"} 1"
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/incidents": {
   "get": {
    "tags": [
     "alerts"
    ],
    "summary": "Incident history across your monitors",
    "parameters": [
     {
      "name": "days",
      "in": "query",
      "schema": {
       "type": "integer",
       "minimum": 1,
       "maximum": 90,
       "default": 30
      },
      "description": "Lookback window."
     }
    ],
    "responses": {
     "200": {
      "description": "Incidents, newest first (≤100)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "days": {
           "type": "integer"
          },
          "incidents": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Incident"
           }
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/incidents/{id}/note": {
   "post": {
    "tags": [
     "alerts"
    ],
    "summary": "Add, replace or clear an incident note (postmortem)",
    "description": "Attaches a note to an incident (id from GET /api/incidents). The note is published with the incident on your public status pages, in `/s/{slug}.json` (`incidents_30d[].note`), the Atom incident feed and the weekly digest — tell visitors what happened and what you did about it. Send `{\"note\": \"\"}` to clear. Max 500 characters. Notes age out with incident history (90 days).",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Incident id (from GET /api/incidents)."
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "note": {
          "type": "string",
          "description": "The note text; \"\" clears."
         }
        },
        "required": [
         "note"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Saved",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          },
          "note": {
           "type": "string",
           "description": "Null when cleared."
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Missing note / too long",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No such incident",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/incidents/{id}/updates": {
   "post": {
    "tags": [
     "alerts"
    ],
    "summary": "Post a timeline update on an incident",
    "description": "Statuspage-style incident updates: post “investigating” / “identified” / “monitoring” / “resolved” progress notes while an incident runs (or after). Updates publish chronologically with the incident on your public status pages, in `/s/{slug}.json` (`incidents_30d[].updates`) and the Atom feed — open status pages in visitors’ browsers refresh within ~30 s of a new update. Posting also emails the verified subscribers of every status page showing the monitor, while the incident is ongoing or resolved <24 h ago (max 20 notified updates per day per account; response field `subscriber_notices` = count queued, 0, or a skip note). Team members can post; the author is visible to your account only. Max 500 chars, 20 updates per incident; updates age out with incident history (90 days).",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Incident id (from GET /api/incidents)."
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "text": {
          "type": "string",
          "description": "The update text."
         },
         "label": {
          "type": "string",
          "description": "Optional stage label; defaults to \"update\".",
          "enum": [
           "investigating",
           "identified",
           "monitoring",
           "resolved",
           "update"
          ]
         }
        },
        "required": [
         "text"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Posted",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "incident": {
           "type": "integer"
          },
          "update": {
           "$ref": "#/components/schemas/IncidentUpdate"
          },
          "subscriber_notices": {
           "description": "Subscriber emails queued (integer), or a string note explaining why none were.",
           "oneOf": [
            {
             "type": "integer"
            },
            {
             "type": "string"
            }
           ]
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Missing/too-long text, bad label, or 20-update cap reached",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No such incident",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/incidents/{id}/updates/{uid}": {
   "delete": {
    "tags": [
     "alerts"
    ],
    "summary": "Delete an incident update",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Incident id."
     },
     {
      "name": "uid",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Update id (from the incident’s updates array)."
     }
    ],
    "responses": {
     "200": {
      "description": "Deleted",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No such incident or update",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/incidents/{id}/ack": {
   "post": {
    "tags": [
     "alerts"
    ],
    "summary": "Acknowledge the ongoing incident",
    "description": "“I’m on it” — pauses the monitor’s still-down reminder alerts (the `renotify` nags) until recovery or un-ack, and records who acked and when (`acked_by`/`ack_ts` in GET /api/incidents). The initial down alert and the recovery alert are unaffected. Team members can ack. Team-internal only — acks never appear on public status pages (post an incident update to communicate publicly). Only the ongoing incident can be acked; acking an already-acked incident returns the original ack with `already: true`.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Incident id (from GET /api/incidents)."
     }
    ],
    "responses": {
     "200": {
      "description": "Acknowledged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "already": {
           "type": "boolean",
           "description": "Present when it was already acked — the original ack is returned untouched."
          },
          "id": {
           "type": "integer"
          },
          "acked_by": {
           "type": "string"
          },
          "ack_ts": {
           "type": "integer"
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Incident already resolved",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No such incident",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "delete": {
    "tags": [
     "alerts"
    ],
    "summary": "Un-acknowledge an incident",
    "description": "Clears the acknowledgement; if the incident is still ongoing, still-down reminders re-arm from now.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Incident id."
     }
    ],
    "responses": {
     "200": {
      "description": "Un-acknowledged",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          },
          "acked_by": {
           "type": "string",
           "description": "Always null after un-acking.",
           "nullable": true
          },
          "ack_ts": {
           "type": "integer",
           "description": "Always null after un-acking.",
           "nullable": true
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Incident is not acknowledged",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "401": {
      "description": "Auth required",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No such incident",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/channels": {
   "get": {
    "tags": [
     "alerts"
    ],
    "summary": "List alert channels",
    "description": "With a read-only key, webhook `secret` is omitted and Telegram bot tokens are masked in `target`.",
    "responses": {
     "200": {
      "description": "Channels",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "channels": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Channel"
           }
          }
         }
        }
       }
      }
     }
    }
   },
   "post": {
    "tags": [
     "alerts"
    ],
    "summary": "Add an alert channel",
    "description": "Max 8 channels. `email` to an address other than your own requires the recipient to click a confirmation link before alerts are sent. `discord`/`slack` targets are the respective webhook URLs; `webhook` POSTs JSON `{event, monitor, …}` to your URL; `ntfy` sends a push notification via an ntfy topic URL (e.g. `https://ntfy.sh/your-topic`; self-hosted servers work too); `telegram` sends messages through your own Telegram bot — target is `BOT_TOKEN/CHAT_ID` (token from @BotFather; numeric chat id, negative for groups, or a public @channelname). `teams` posts to a Microsoft Teams channel — target is a Workflows webhook URL (`…logic.azure.com…`; alerts are sent as the Adaptive Card format it expects) or a legacy `…webhook.office.com` connector URL (plain text). `googlechat` posts to a Google Chat space’s incoming-webhook URL. Webhook channels get a `whsec_` signing secret (returned once here, also listable): every POST carries `X-Watchpup-Signature: t=<unix>,v1=<hex>` where v1 = HMAC-SHA256(secret, `<t>.<raw body>`).",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "kind": {
          "type": "string",
          "enum": [
           "email",
           "discord",
           "slack",
           "webhook",
           "ntfy",
           "telegram",
           "teams",
           "googlechat"
          ]
         },
         "target": {
          "type": "string",
          "description": "Email address, webhook URL (incl. Teams/Google Chat webhooks), ntfy topic URL, or Telegram BOT_TOKEN/CHAT_ID."
         },
         "quiet": {
          "type": "string",
          "description": "Optional quiet-hours window \"HH:MM-HH:MM\" (see PATCH /api/channels/{id})."
         },
         "quiet_tz": {
          "type": "string"
         },
         "quiet_down": {
          "type": "boolean"
         },
         "delay_s": {
          "type": "integer",
          "description": "Optional escalation delay in seconds (see PATCH /api/channels/{id})."
         }
        },
        "required": [
         "kind",
         "target"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Created (note present when confirmation is pending; secret present for webhook channels)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          },
          "verified": {
           "type": "boolean"
          },
          "note": {
           "type": "string"
          },
          "secret": {
           "type": "string",
           "description": "Webhook signing secret (webhook kind only)."
          },
          "quiet": {
           "type": "string"
          },
          "quiet_tz": {
           "type": "string"
          },
          "quiet_down": {
           "type": "boolean"
          },
          "delay_s": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Invalid kind/target/quiet/delay fields or channel limit",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/channels/{id}": {
   "patch": {
    "tags": [
     "alerts"
    ],
    "summary": "Update a channel (quiet hours, escalation delay)",
    "description": "Sets or clears the channel’s quiet hours — a do-not-disturb window during which alerts are held and delivered when the window ends (original payload and timestamp kept; webhook deliveries are re-signed fresh). `quiet` is \"HH:MM-HH:MM\" 24h wall-clock in `quiet_tz` (IANA zone, default UTC) and may wrap past midnight; start and end must differ. `quiet_down: true` lets down/up/reminder alerts break through, so quiet only silences slow/fast/budget notifications. \"Still down\" reminders are skipped during quiet rather than held. Test alerts always send. Omitted fields keep their value; `{\"quiet\":\"\"}` clears the window, zone and flag together. `delay_s` (30–86400 seconds, clamped; 0 clears) sets an escalation delay: the down alert is held that long and cancelled if the monitor recovers first — the channel never hears about outages shorter than the delay (no down, no recovery, no reminders). Only down alerts are delayed; slow/fast/budget alerts are not. Target and kind are immutable — delete and recreate the channel instead.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Channel id."
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "quiet": {
          "type": "string",
          "description": "\"HH:MM-HH:MM\" or \"\" to clear."
         },
         "quiet_tz": {
          "type": "string",
          "description": "IANA zone, e.g. Europe/Berlin."
         },
         "quiet_down": {
          "type": "boolean"
         },
         "delay_s": {
          "type": "integer",
          "description": "Escalation delay in seconds (30–86400; 0 = alert immediately)."
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Updated",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "channel": {
           "$ref": "#/components/schemas/Channel"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Invalid quiet or delay fields",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No such channel",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "delete": {
    "tags": [
     "alerts"
    ],
    "summary": "Delete an alert channel",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Channel id."
     }
    ],
    "responses": {
     "200": {
      "description": "Deleted (idempotent)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/channels/{id}/secret/rotate": {
   "post": {
    "tags": [
     "alerts"
    ],
    "summary": "Rotate a webhook channel’s signing secret",
    "description": "Issues a new `whsec_` signing secret for a webhook channel. The old secret stops validating immediately — update your receiver first if you verify signatures strictly. Webhook channels only.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Channel id."
     }
    ],
    "responses": {
     "200": {
      "description": "Rotated",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "id": {
           "type": "integer"
          },
          "secret": {
           "type": "string",
           "description": "The new signing secret."
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Not a webhook channel",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No such channel",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/channels/{id}/test": {
   "post": {
    "tags": [
     "alerts"
    ],
    "summary": "Send a test alert through a channel",
    "description": "Delivers a test notification through the channel immediately, using the same senders real alerts use — but unlike real alert fan-out, delivery problems are reported back: a non-2xx answer from a webhook/Discord/Slack/ntfy target returns 502 with the HTTP status, as do network errors and redirects (alerts never follow redirects). Webhook channels receive `{\"event\":\"test\",\"detail\":\"…\",\"ts\":…}`. Unconfirmed email channels return 400. Limit 10/hour.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Channel id."
     }
    ],
    "responses": {
     "200": {
      "description": "Delivered",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "status": {
           "type": "integer",
           "description": "HTTP status the target answered with (email: the mail API)."
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Channel is awaiting recipient confirmation",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No such channel",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Rate limited",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "502": {
      "description": "Delivery failed — error names the failure (e.g. \"webhook endpoint answered HTTP 404\")",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/status-pages": {
   "get": {
    "tags": [
     "status-pages"
    ],
    "summary": "List your status pages",
    "responses": {
     "200": {
      "description": "Pages",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "status_pages": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "slug": {
              "type": "string"
             },
             "title": {
              "type": "string"
             },
             "monitor_ids": {
              "type": "array",
              "items": {
               "type": "string"
              }
             },
             "auto_include": {
              "type": "boolean"
             },
             "auto_include_tag": {
              "type": "string",
              "description": "Page always shows every monitor carrying this tag (current and future); one tag per page. \"\" turns it off (freezes the currently matching set). Cannot be combined with auto_include.",
              "nullable": true
             },
             "accent": {
              "type": "string",
              "description": "Custom hex accent color, or null.",
              "nullable": true
             },
             "logo_url": {
              "type": "string",
              "description": "Custom https logo URL, or null.",
              "nullable": true
             },
             "protected": {
              "type": "boolean",
              "description": "True when the page is password-protected."
             },
             "show_sla": {
              "type": "boolean",
              "description": "Publish SLA state: monitors with an sla_target show month-to-date SLA + error-budget state on the public page and as an `sla` object in /s/{slug}.json. Off by default."
             },
             "groups": {
              "type": "object",
              "additionalProperties": {
               "type": "string"
              },
              "description": "Monitor id → group name. Groups render as headed sections on the page; PATCH replaces the whole mapping, {} clears. ≤20 groups, names ≤40 chars."
             }
            }
           }
          }
         }
        }
       }
      }
     }
    }
   },
   "post": {
    "tags": [
     "status-pages"
    ],
    "summary": "Create a public status page",
    "description": "Live at /s/{slug} (HTML) and /s/{slug}.json (CORS-enabled JSON). Omit `monitor_ids` to include all current monitors; set `auto_include` so the page always shows every monitor you have, including future ones, or `auto_include_tag` so it always shows monitors carrying one tag. Optional branding: `accent` (hex color) and `logo_url` (https).",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "title": {
          "type": "string",
          "description": "Required, ≤80 chars."
         },
         "slug": {
          "type": "string",
          "description": "a-z0-9-, 2+ chars. Defaults to slugified title."
         },
         "monitor_ids": {
          "type": "array",
          "items": {
           "type": "string"
          }
         },
         "auto_include": {
          "type": "boolean"
         },
         "auto_include_tag": {
          "type": "string",
          "description": "Page always shows every monitor carrying this tag (current and future); one tag per page. \"\" turns it off (freezes the currently matching set). Cannot be combined with auto_include."
         },
         "accent": {
          "type": "string",
          "description": "Hex color like #58a6ff."
         },
         "logo_url": {
          "type": "string",
          "description": "https:// image URL, ≤300 chars."
         },
         "password": {
          "type": "string",
          "description": "Optional, 4–64 chars — makes the page private: visitors must enter it (signed 30-day cookie); the page JSON/metrics/feed/embed/subscribe endpoints answer 401 until unlocked."
         },
         "show_sla": {
          "type": "boolean",
          "description": "Publish SLA state: monitors with an sla_target show month-to-date SLA + error-budget state on the public page and as an `sla` object in /s/{slug}.json. Off by default."
         },
         "groups": {
          "type": "object",
          "additionalProperties": {
           "type": "string"
          },
          "description": "Monitor id → group name. Groups render as headed sections on the page; PATCH replaces the whole mapping, {} clears. ≤20 groups, names ≤40 chars."
         }
        },
        "required": [
         "title"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Created",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "slug": {
           "type": "string"
          },
          "url": {
           "type": "string"
          },
          "auto_include_tag": {
           "type": "string",
           "description": "The tag the page auto-includes by, or null.",
           "nullable": true
          },
          "protected": {
           "type": "boolean"
          },
          "show_sla": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Invalid slug/title",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "409": {
      "description": "Slug taken",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/status-pages/{slug}": {
   "parameters": [
    {
     "name": "slug",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string"
     },
     "description": "Status page slug."
    }
   ],
   "patch": {
    "tags": [
     "status-pages"
    ],
    "summary": "Update a status page",
    "description": "Turning `auto_include` (or `auto_include_tag`) off without an explicit `monitor_ids` snapshots the current monitor set. Branding: `accent` (hex color) and `logo_url` (https) — send `\"\"` to clear either.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "title": {
          "type": "string"
         },
         "monitor_ids": {
          "type": "array",
          "items": {
           "type": "string"
          }
         },
         "auto_include": {
          "type": "boolean"
         },
         "auto_include_tag": {
          "type": "string",
          "description": "Page always shows every monitor carrying this tag (current and future); one tag per page. \"\" turns it off (freezes the currently matching set). Cannot be combined with auto_include."
         },
         "accent": {
          "type": "string",
          "description": "Hex color like #58a6ff; \"\" clears."
         },
         "logo_url": {
          "type": "string",
          "description": "https:// image URL, ≤300 chars; \"\" clears."
         },
         "password": {
          "type": "string",
          "description": "4–64 chars sets/replaces the page password (invalidates all issued unlock cookies); \"\" makes the page public again."
         },
         "show_sla": {
          "type": "boolean",
          "description": "Publish SLA state: monitors with an sla_target show month-to-date SLA + error-budget state on the public page and as an `sla` object in /s/{slug}.json. Off by default."
         },
         "groups": {
          "type": "object",
          "additionalProperties": {
           "type": "string"
          },
          "description": "Monitor id → group name. Groups render as headed sections on the page; PATCH replaces the whole mapping, {} clears. ≤20 groups, names ≤40 chars."
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Updated",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "slug": {
           "type": "string"
          },
          "title": {
           "type": "string"
          },
          "auto_include": {
           "type": "boolean"
          },
          "auto_include_tag": {
           "type": "string",
           "nullable": true
          },
          "monitor_ids": {
           "type": "array",
           "items": {
            "type": "string"
           }
          },
          "accent": {
           "type": "string",
           "nullable": true
          },
          "logo_url": {
           "type": "string",
           "nullable": true
          },
          "protected": {
           "type": "boolean"
          },
          "show_sla": {
           "type": "boolean"
          },
          "groups": {
           "type": "object",
           "additionalProperties": {
            "type": "string"
           },
           "description": "Monitor id → group name. Groups render as headed sections on the page; PATCH replaces the whole mapping, {} clears. ≤20 groups, names ≤40 chars."
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Validation error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "Not found",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   },
   "delete": {
    "tags": [
     "status-pages"
    ],
    "summary": "Delete a status page",
    "responses": {
     "200": {
      "description": "Deleted (idempotent; also removes its email subscribers)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/api/status-pages/{slug}/subscribers": {
   "get": {
    "tags": [
     "status-pages"
    ],
    "summary": "List email subscribers of a status page",
    "description": "Visitors who asked for down/up emails from this page (see POST /s/{slug}/subscribe). `verified:false` = confirmation email sent but not yet clicked.",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Status page slug."
     }
    ],
    "responses": {
     "200": {
      "description": "Subscribers",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "subscribers": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "id": {
              "type": "integer"
             },
             "email": {
              "type": "string"
             },
             "verified": {
              "type": "boolean"
             },
             "created_at": {
              "type": "integer",
              "description": "Unix seconds."
             }
            }
           }
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "Not found",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/status-pages/{slug}/subscribers/{id}": {
   "delete": {
    "tags": [
     "status-pages"
    ],
    "summary": "Remove a status-page subscriber",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Status page slug."
     },
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Subscriber id."
     }
    ],
    "responses": {
     "200": {
      "description": "Removed (idempotent)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "Not found",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/s/{slug}/subscribe": {
   "post": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Subscribe an email to status-page updates",
    "description": "Double opt-in: sends the address a confirmation link; once confirmed, it gets an email whenever a monitor on the page goes down or recovers (one-click unsubscribe in every email). Rate-limited per IP and per page; up to 50 subscribers per page (free beta).",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Status page slug."
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "email": {
          "type": "string",
          "description": "Address to notify."
         }
        },
        "required": [
         "email"
        ]
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Confirmation email sent (or already subscribed)",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Invalid email or subscriber limit reached",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "No such status page",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "429": {
      "description": "Rate-limited",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/s/{slug}/unlock": {
   "post": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Unlock a password-protected status page",
    "description": "Form endpoint used by the unlock page. Correct password sets a signed, HttpOnly 30-day cookie for /s/{slug} and its JSON/metrics/feed/embed/subscribe endpoints, then redirects to the page. Rate-limited (10 attempts / 15 min / IP). Changing or clearing the page password invalidates all issued cookies.",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Status page slug."
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "application/x-www-form-urlencoded": {
       "schema": {
        "type": "object",
        "properties": {
         "password": {
          "type": "string",
          "description": "The page password."
         }
        },
        "required": [
         "password"
        ]
       }
      }
     }
    },
    "responses": {
     "303": {
      "description": "Unlocked — redirects to /s/{slug} with the unlock cookie set."
     },
     "401": {
      "description": "Wrong password (HTML form re-rendered)."
     },
     "404": {
      "description": "No such status page."
     },
     "429": {
      "description": "Too many attempts."
     }
    }
   }
  },
  "/s/{slug}.json": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Machine-readable public status page",
    "description": "CORS `*`, cached 60 s (`no-store` while the page is password-protected). Answers 401 for locked visitors of a protected page (unlock cookie or owner session required). HTML version at /s/{slug}.",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Status page slug (without .json)."
     }
    ],
    "responses": {
     "200": {
      "description": "Current status + 30-day incidents",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "page": {
           "type": "string"
          },
          "slug": {
           "type": "string"
          },
          "accent": {
           "type": "string",
           "description": "Custom hex accent, or null.",
           "nullable": true
          },
          "logo_url": {
           "type": "string",
           "description": "Custom https logo, or null.",
           "nullable": true
          },
          "status": {
           "type": "string",
           "enum": [
            "up",
            "degraded",
            "maintenance",
            "empty"
           ]
          },
          "monitors": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "name": {
              "type": "string"
             },
             "kind": {
              "type": "string"
             },
             "status": {
              "type": "string"
             },
             "group": {
              "type": "string",
              "description": "Section this monitor is grouped under on the page, or null.",
              "nullable": true
             },
             "uptime_24h": {
              "type": "number",
              "description": "Percent, null if no data.",
              "nullable": true
             },
             "avg_ms_24h": {
              "type": "number",
              "nullable": true
             },
             "p50_ms_24h": {
              "type": "number",
              "description": "24h median response ms over successful checks; null if no data.",
              "nullable": true
             },
             "p95_ms_24h": {
              "type": "number",
              "description": "24h p95 response ms; null if no data.",
              "nullable": true
             },
             "uptime_30d": {
              "type": "number",
              "nullable": true
             },
             "uptime_90d": {
              "type": "number",
              "nullable": true
             },
             "sla": {
              "type": "object",
              "properties": {
               "target": {
                "type": "number",
                "description": "Monthly SLA target percent."
               },
               "month": {
                "type": "string",
                "description": "UTC calendar month, YYYY-MM."
               },
               "uptime_pct": {
                "type": "number",
                "description": "Month-to-date uptime percent."
               },
               "budget_used_pct": {
                "type": "number",
                "description": "Percent of the monthly error budget used so far."
               },
               "within_budget": {
                "type": "boolean"
               }
              }
             },
             "domain_expires_at": {
              "type": "integer",
              "description": "Domain monitors only: unix expiry time (null until the first lookup).",
              "nullable": true
             },
             "domain_days_left": {
              "type": "integer",
              "description": "Domain monitors only.",
              "nullable": true
             },
             "cert_expires_at": {
              "type": "integer",
              "description": "TLS monitors only: unix time the certificate expires (null until the first handshake).",
              "nullable": true
             },
             "cert_days_left": {
              "type": "integer",
              "description": "TLS monitors only.",
              "nullable": true
             }
            }
           }
          },
          "scheduled_maint": {
           "type": "array",
           "items": {
            "type": "object",
            "properties": {
             "starts_at": {
              "type": "integer",
              "description": "Unix seconds."
             },
             "ends_at": {
              "type": "integer",
              "description": "Unix seconds."
             },
             "active": {
              "type": "boolean",
              "description": "True while the window is currently in progress."
             },
             "monitor": {
              "type": "string",
              "description": "Monitor the window is scoped to, or null when it covers every service on the page.",
              "nullable": true
             },
             "note": {
              "type": "string",
              "description": "Owner’s note, or null.",
              "nullable": true
             },
             "repeat": {
              "type": "string",
              "description": "`daily`, `weekly` or null.",
              "nullable": true
             }
            }
           },
           "description": "Active + upcoming (next 7 days) maintenance windows covering this page."
          },
          "incidents_30d": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Incident"
           }
          },
          "generated_at": {
           "type": "integer",
           "description": "Unix seconds."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "No such status page",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/s/{slug}/metrics": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Prometheus metrics for a public status page",
    "description": "Unauthenticated Prometheus text exposition (version 0.0.4) scoped to the monitors shown on the status page — same gauges as /api/metrics, but the `id` label is the monitor’s public badge_id (internal ids are never exposed). CORS `*`, cached 60 s. Point any Prometheus scrape job at this path, no credentials needed.",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Status page slug."
     }
    ],
    "responses": {
     "200": {
      "description": "Prometheus metrics",
      "content": {
       "text/plain": {
        "schema": {
         "type": "string",
         "description": "Prometheus text exposition format.",
         "example": "watchpup_monitor_up{id=\"1a2b3c\",name=\"my api\",kind=\"http\"} 1"
        }
       }
      }
     },
     "404": {
      "description": "No such status page",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/s/{slug}/feed": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Atom feed of status-page incidents",
    "description": "Unauthenticated Atom 1.0 feed of the last 30 days of incidents (outage start, resolution, duration, cause) for the monitors on the status page. Subscribe with any RSS/Atom reader or feed-to-notification service. Entry ids use public badge ids only. CORS `*`, cached 300 s. /s/{slug}/feed.xml is an alias.",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Status page slug."
     }
    ],
    "responses": {
     "200": {
      "description": "Atom feed",
      "content": {
       "application/atom+xml": {
        "schema": {
         "type": "string",
         "description": "Atom 1.0 XML."
        }
       }
      }
     },
     "404": {
      "description": "No such status page",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/s/{slug}/maintenance.ics": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "iCalendar feed of scheduled maintenance",
    "description": "Unauthenticated iCalendar (RFC 5545) feed of all active and upcoming maintenance windows covering the status page — account-wide windows always, monitor-scoped ones only when that monitor is shown on the page. Subscribe or import in Google Calendar, Apple Calendar, Outlook or any other calendar app. Events carry the window’s note, scope and a link to the status page; repeating windows use RRULE (FREQ=DAILY/WEEKLY, until cancelled); UIDs are stable so rescheduling updates events in place; events are marked TRANSP:TRANSPARENT (free time). CORS `*`, cached 300 s.",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Status page slug."
     }
    ],
    "responses": {
     "200": {
      "description": "iCalendar feed",
      "content": {
       "text/calendar": {
        "schema": {
         "type": "string",
         "description": "RFC 5545 VCALENDAR with one VEVENT per maintenance window."
        }
       }
      }
     },
     "404": {
      "description": "No such status page",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/s/{slug}/embed.js": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Embeddable live status widget script",
    "description": "Unauthenticated JavaScript for embedding a live status pill on your own website: `<script src=\"…/s/{slug}/embed.js\" async></script>`. Renders \"All systems operational\" / \"N services down\" / \"Under maintenance\" linking to the status page, refreshing once a minute while the tab is visible. Options via attributes on the script tag: `data-position` (bottom-right|bottom-left|top-right|top-left, default bottom-right), `data-target` (CSS selector — mount inline instead of floating), `data-theme=\"dark\"` or `data-theme=\"auto\"` (follow the visitor’s prefers-color-scheme, live). ~2 KB, no dependencies, CORS `*`, cached 300 s.",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Status page slug."
     }
    ],
    "responses": {
     "200": {
      "description": "Widget script",
      "content": {
       "application/javascript": {
        "schema": {
         "type": "string",
         "description": "Self-contained widget JavaScript."
        }
       }
      }
     },
     "404": {
      "description": "No such status page",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/badge/{badge_id}.svg": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Uptime / response-time badge (SVG shield)",
    "description": "Embed anywhere. `badge_url` is returned on each monitor. Optional `?window=7d|30d` (default 24h), `?label=custom`, and `?metric=response` for an average response-time badge instead of uptime (http/tcp monitors; green under the monitor’s slow_ms threshold, amber at or over it, neutral blue when no threshold is set).",
    "parameters": [
     {
      "name": "badge_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Badge id (from the monitor’s badge_url; safe to share)."
     },
     {
      "name": "window",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "7d",
        "30d"
       ]
      }
     },
     {
      "name": "label",
      "in": "query",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "metric",
      "in": "query",
      "schema": {
       "type": "string",
       "enum": [
        "response",
        "p50",
        "p90",
        "p95",
        "p99"
       ]
      },
      "description": "response = average response time over the window. p50/p90/p95/p99 = latency percentile over the last 24h of raw checks (window has no effect for percentiles)."
     }
    ],
    "responses": {
     "200": {
      "description": "SVG image",
      "content": {
       "image/svg+xml": {
        "schema": {
         "type": "string"
        }
       }
      }
     },
     "404": {
      "description": "Unknown badge",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/badge/{badge_id}.json": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "Uptime badge data (JSON)",
    "parameters": [
     {
      "name": "badge_id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Badge id."
     }
    ],
    "responses": {
     "200": {
      "description": "Badge data",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "name": {
           "type": "string"
          },
          "status": {
           "type": "string"
          },
          "uptime_24h": {
           "type": "number",
           "description": "Percent, null if no data.",
           "nullable": true
          },
          "avg_ms_24h": {
           "type": "number",
           "description": "Average response ms over the last 24h, null if no data.",
           "nullable": true
          },
          "checks_24h": {
           "type": "integer",
           "description": "Checks run in the last 24h."
          },
          "uptime_7d": {
           "type": "number",
           "description": "Percent, null if no data.",
           "nullable": true
          },
          "uptime_30d": {
           "type": "number",
           "nullable": true
          },
          "avg_ms_7d": {
           "type": "number",
           "description": "Average response ms, null if no data.",
           "nullable": true
          },
          "avg_ms_30d": {
           "type": "number",
           "nullable": true
          },
          "p50_ms_24h": {
           "type": "number",
           "description": "Nearest-rank latency percentiles over the last 24h, null if no data.",
           "nullable": true
          },
          "p90_ms_24h": {
           "type": "number",
           "nullable": true
          },
          "p95_ms_24h": {
           "type": "number",
           "nullable": true
          },
          "p99_ms_24h": {
           "type": "number",
           "nullable": true
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "Unknown badge",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/openapi.json": {
   "get": {
    "tags": [
     "public"
    ],
    "security": [],
    "summary": "This OpenAPI 3.0 spec",
    "responses": {
     "200": {
      "description": "The spec",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/api/maintenance": {
   "get": {
    "tags": [
     "maintenance"
    ],
    "summary": "List maintenance windows (last 50)",
    "responses": {
     "200": {
      "description": "Windows",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "maintenance_windows": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/MaintWindow"
           }
          }
         }
        }
       }
      }
     }
    }
   },
   "post": {
    "tags": [
     "maintenance"
    ],
    "summary": "Schedule a maintenance window",
    "description": "Covered monitors show “maintenance”, checks are skipped (no alerts, no downtime counted); when the window ends they are re-checked within a minute. Max 20 active/upcoming windows, max length 7 days. Verified subscribers of status pages showing a covered monitor get a heads-up email (response field `subscriber_notices` = count queued, 0, or a skip note — max 5 notified windows per owner per day).",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "properties": {
         "monitor_id": {
          "type": "string",
          "description": "Limit to one monitor; omit for all monitors."
         },
         "start": {
          "type": "string",
          "description": "Unix seconds or ISO 8601. Default: now."
         },
         "end": {
          "type": "string",
          "description": "Unix seconds or ISO 8601. Either end or duration is required."
         },
         "duration": {
          "type": "integer",
          "description": "Seconds from start (alternative to end)."
         },
         "note": {
          "type": "string",
          "description": "Shown on status pages (≤140 chars)."
         },
         "repeat": {
          "type": "string",
          "description": "Omit for a one-off window. \"daily\"/\"weekly\": the window rolls forward to its next occurrence automatically when it ends, until deleted. Must be shorter than its period; a first occurrence already in the past advances to the next one. Subscribers are notified only at scheduling time, not per recurrence.",
          "enum": [
           "daily",
           "weekly"
          ]
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Scheduled",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "id": {
           "type": "string",
           "description": "Window id (mw_…)."
          },
          "monitor_id": {
           "type": "string",
           "description": "Null when the window covers all monitors.",
           "nullable": true
          },
          "start_ts": {
           "type": "integer",
           "description": "Unix seconds."
          },
          "end_ts": {
           "type": "integer",
           "description": "Unix seconds."
          },
          "note": {
           "type": "string",
           "description": "Null when none was set.",
           "nullable": true
          },
          "repeat": {
           "type": "string",
           "description": "null for one-off windows.",
           "nullable": true
          },
          "scope": {
           "type": "string",
           "enum": [
            "monitor",
            "all"
           ]
          },
          "subscriber_notices": {
           "description": "Status-page subscriber heads-up emails queued (integer), or a string note explaining why none were.",
           "oneOf": [
            {
             "type": "integer"
            },
            {
             "type": "string"
            }
           ]
          },
          "note2": {
           "type": "string",
           "description": "Reminder of what a maintenance window does."
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "Validation error",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     },
     "404": {
      "description": "Monitor not found",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        }
       }
      }
     }
    }
   }
  },
  "/api/maintenance/{id}": {
   "delete": {
    "tags": [
     "maintenance"
    ],
    "summary": "Cancel a maintenance window",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string"
      },
      "description": "Window id (mw_…)."
     }
    ],
    "responses": {
     "200": {
      "description": "Removed; checking resumes within a minute",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          },
          "note": {
           "type": "string"
          }
         }
        }
       }
      }
     }
    }
   }
  }
 },
 "components": {
  "securitySchemes": {
   "apiKey": {
    "type": "http",
    "scheme": "bearer",
    "description": "Your API key (wp_…) from signup/login, as `Authorization: Bearer wp_…`. Browser session cookies also work. Every account also has a read-only key (wp_ro_…, see /api/me and Settings): same header, GET-only — non-GET requests get a 403 and secrets are redacted from its view."
   }
  },
  "schemas": {
   "Error": {
    "type": "object",
    "properties": {
     "error": {
      "type": "string",
      "description": "Human-readable message."
     }
    },
    "required": [
     "error"
    ]
   },
   "Member": {
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "description": "Member id (use in DELETE /api/members/{id})."
     },
     "email": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "description": "invited = waiting on the emailed accept link; active = they set a password and can log in.",
      "enum": [
       "invited",
       "active"
      ]
     },
     "invited_at": {
      "type": "integer",
      "description": "Unix timestamp."
     },
     "accepted_at": {
      "type": "integer",
      "description": "Unix timestamp; absent while pending."
     },
     "last_login": {
      "type": "integer",
      "description": "Unix timestamp of the member’s most recent login (accepting the invite and completing a password reset count); null while they’ve never logged in.",
      "nullable": true
     }
    }
   },
   "Monitor": {
    "type": "object",
    "description": "http monitors include url/method/expect/keyword/slow fields; tcp monitors include target/slow fields; heartbeat monitors include grace/last_ping/ping_url/start_url/fail_url/last_duration_s/running_since/ping_email/cron/tz/next_due; dns monitors include target/dns_type/dns_expect; domain monitors include domain/warn_days/expires_at/days_left; tls monitors include target/warn_days/expires_at/days_left.",
    "properties": {
     "id": {
      "type": "string"
     },
     "kind": {
      "type": "string",
      "enum": [
       "http",
       "tcp",
       "heartbeat",
       "dns",
       "domain",
       "tls"
      ]
     },
     "name": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "enum": [
       "new",
       "up",
       "down",
       "paused",
       "maint"
      ]
     },
     "interval": {
      "type": "integer",
      "description": "Seconds."
     },
     "created_at": {
      "type": "integer",
      "description": "Unix seconds."
     },
     "badge_url": {
      "type": "string",
      "description": "Public SVG uptime badge."
     },
     "url": {
      "type": "string"
     },
     "method": {
      "type": "string"
     },
     "expect": {
      "type": "string"
     },
     "keyword": {
      "type": "string"
     },
     "keyword_mode": {
      "type": "string",
      "enum": [
       "contains",
       "absent"
      ]
     },
     "headers": {
      "type": "object",
      "additionalProperties": {
       "type": "string"
      },
      "description": "Custom request headers (http monitors; present when set)."
     },
     "body": {
      "type": "string",
      "description": "Custom request body (http monitors; present when set)."
     },
     "json_path": {
      "type": "string",
      "description": "JSON assertion path (http monitors; present when set)."
     },
     "json_expect": {
      "type": "string",
      "description": "Expected JSON value at json_path (present when set)."
     },
     "header_name": {
      "type": "string",
      "description": "Response-header assertion (http monitors; present when set)."
     },
     "header_expect": {
      "type": "string",
      "description": "Required substring of that header’s value (present when set)."
     },
     "slow_ms": {
      "type": "integer",
      "description": "Manual slow threshold in ms (0 when off or in auto mode)."
     },
     "slow": {
      "type": "boolean",
      "description": "Present (true) while responses are over the slow threshold (http/tcp) or the last measured run was over the run-duration limit (heartbeat)."
     },
     "slow_auto": {
      "type": "boolean",
      "description": "Present (true) when the slow threshold is adaptive (\"auto\")."
     },
     "auto_threshold_ms": {
      "type": "integer",
      "description": "Current learned adaptive threshold in ms (auto mode; null while still learning).",
      "nullable": true
     },
     "last_check": {
      "type": "integer",
      "description": "Unix seconds; null until the first check.",
      "nullable": true
     },
     "last_ms": {
      "type": "integer",
      "nullable": true
     },
     "last_code": {
      "type": "integer",
      "nullable": true
     },
     "grace": {
      "type": "integer",
      "description": "Heartbeat: extra seconds before a missed ping alerts."
     },
     "last_ping": {
      "type": "integer",
      "description": "Unix seconds; null until the first ping.",
      "nullable": true
     },
     "ping_url": {
      "type": "string",
      "description": "Call this from your cron job."
     },
     "start_url": {
      "type": "string",
      "description": "Heartbeat: report a run has begun (hung-job detection + run-duration tracking)."
     },
     "fail_url": {
      "type": "string",
      "description": "Heartbeat: the job reports its own failure — alerts fire immediately; a POSTed body becomes the incident detail."
     },
     "last_duration_s": {
      "type": "integer",
      "description": "Heartbeat: seconds the last start→success run took (null until a run used the start signal).",
      "nullable": true
     },
     "max_duration_s": {
      "type": "integer",
      "description": "Heartbeat: run-duration limit in seconds (0 = off); `slow` is present (true) while the last measured run was over it."
     },
     "running_since": {
      "type": "integer",
      "description": "Heartbeat: unix time a run reported /start without finishing yet (null otherwise).",
      "nullable": true
     },
     "cron": {
      "type": "string",
      "description": "Heartbeat: the cron schedule (5-field) a ping is expected after, or null in interval mode.",
      "nullable": true
     },
     "tz": {
      "type": "string",
      "description": "Heartbeat: IANA timezone the cron schedule runs in (\"UTC\" when unset); null in interval mode.",
      "nullable": true
     },
     "next_due": {
      "type": "integer",
      "description": "Heartbeat: unix time the next ping is expected by (alerting starts `grace` seconds later). Null until the first ping.",
      "nullable": true
     },
     "ping_email": {
      "type": "string",
      "description": "Heartbeat: dedicated email address for this monitor — any mail delivered to it counts as a ping (for systems that can only send email). Keep it private."
     },
     "target": {
      "type": "string",
      "description": "TCP monitors: the watched host:port. TLS monitors: the watched host:port whose certificate is checked. DNS monitors: the queried DNS name."
     },
     "dns_type": {
      "type": "string",
      "description": "DNS monitors: the queried record type.",
      "enum": [
       "A",
       "AAAA",
       "CNAME",
       "MX",
       "TXT",
       "NS",
       "SRV",
       "CAA"
      ]
     },
     "dns_expect": {
      "type": "string",
      "description": "DNS monitors: expected record value (present when set)."
     },
     "domain": {
      "type": "string",
      "description": "Domain monitors: the watched domain."
     },
     "warn_days": {
      "type": "integer",
      "description": "Domain monitors: alert window in days before expiry."
     },
     "expires_at": {
      "type": "integer",
      "description": "Domain monitors: unix time the registration expires (null until the first RDAP lookup).",
      "nullable": true
     },
     "days_left": {
      "type": "integer",
      "description": "Domain monitors: whole days until expiry (null until known).",
      "nullable": true
     },
     "sla_target": {
      "type": "number",
      "description": "Monthly uptime SLA target percent, when configured."
     },
     "renotify": {
      "type": "integer",
      "description": "\"Still down\" reminder interval in seconds (0 = off)."
     },
     "alert_delay_s": {
      "type": "integer",
      "description": "Per-monitor escalation delay in seconds (0 = off): every channel waits at least this long before hearing this monitor’s down alerts; longer of this and the channel’s delay_s wins."
     },
     "alert_channels": {
      "description": "Alert routing: channel ids this monitor alerts (null = all channels, the default; [] = muted).",
      "type": "array",
      "items": {
       "type": "integer"
      },
      "nullable": true
     },
     "tags": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Tags on this monitor (lowercase, sorted; [] when none)."
     }
    }
   },
   "Latency": {
    "type": "object",
    "nullable": true,
    "description": "24-hour response-time stats over successful checks (nearest-rank percentiles). http/tcp monitors only; null until there is data.",
    "properties": {
     "count": {
      "type": "integer",
      "description": "Successful checks in the window."
     },
     "avg_ms": {
      "type": "integer"
     },
     "min_ms": {
      "type": "integer"
     },
     "max_ms": {
      "type": "integer"
     },
     "p50_ms": {
      "type": "integer",
      "description": "Median."
     },
     "p90_ms": {
      "type": "integer"
     },
     "p95_ms": {
      "type": "integer"
     },
     "p99_ms": {
      "type": "integer"
     }
    }
   },
   "Trend": {
    "type": "object",
    "nullable": true,
    "description": "Latency drift: average successful-check response time over the last hour vs the hour before. Same math as the dashboard trend arrows. http/tcp monitors only; null until both hours have at least 3 successful checks.",
    "properties": {
     "avg_ms_last_hour": {
      "type": "integer"
     },
     "avg_ms_previous_hour": {
      "type": "integer"
     },
     "change_pct": {
      "type": "integer",
      "description": "Rounded percent change (positive = slower)."
     },
     "direction": {
      "type": "string",
      "enum": [
       "slower",
       "faster",
       "steady"
      ],
      "description": "slower/faster at ±15% change or more, steady otherwise."
     }
    }
   },
   "Sla": {
    "type": "object",
    "description": "Month-to-date SLA error budget for the current UTC calendar month. Downtime is estimated as elapsed month time × check failure rate; time before the month’s first check counts as up.",
    "properties": {
     "target": {
      "type": "number",
      "description": "Configured SLA target percent."
     },
     "month": {
      "type": "string",
      "description": "UTC month, e.g. \"2026-07\"."
     },
     "month_elapsed_pct": {
      "type": "number"
     },
     "checks": {
      "type": "integer",
      "description": "Checks counted so far this month."
     },
     "uptime_pct": {
      "type": "number",
      "description": "Month-to-date uptime percent."
     },
     "allowed_downtime_s": {
      "type": "integer",
      "description": "Full-month downtime budget in seconds at the target."
     },
     "used_downtime_s": {
      "type": "integer",
      "description": "Estimated downtime seconds used so far."
     },
     "remaining_s": {
      "type": "integer",
      "description": "Budget seconds left (negative when over budget)."
     },
     "budget_used_pct": {
      "type": "number",
      "description": "used/allowed × 100."
     },
     "within_budget": {
      "type": "boolean"
     }
    }
   },
   "Incident": {
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "description": "Incident id — use with POST /api/incidents/{id}/note. Only in authenticated responses, not public status JSON."
     },
     "monitor": {
      "type": "string",
      "description": "Monitor name."
     },
     "monitor_id": {
      "type": "string",
      "description": "Monitor id. Only in authenticated responses, not public status JSON."
     },
     "started": {
      "type": "integer",
      "description": "Unix seconds."
     },
     "ended": {
      "type": "integer",
      "description": "Unix seconds; null if ongoing.",
      "nullable": true
     },
     "duration_s": {
      "type": "integer",
      "description": "Null if ongoing.",
      "nullable": true
     },
     "resolved": {
      "type": "boolean"
     },
     "detail": {
      "type": "string",
      "description": "Failure reason."
     },
     "note": {
      "type": "string",
      "description": "Operator note/postmortem, if one was added (null otherwise). Shown publicly with the incident.",
      "nullable": true
     },
     "acked_by": {
      "type": "string",
      "description": "Email of whoever acknowledged the incident (POST /api/incidents/{id}/ack); null when unacked. Team-internal — never in public status JSON.",
      "nullable": true
     },
     "ack_ts": {
      "type": "integer",
      "description": "Unix time of the acknowledgement; null when unacked.",
      "nullable": true
     },
     "updates": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/IncidentUpdate"
      },
      "description": "Timeline updates, oldest first."
     }
    }
   },
   "IncidentUpdate": {
    "type": "object",
    "properties": {
     "id": {
      "type": "integer",
      "description": "Update id — use with DELETE …/updates/{uid}. Only in authenticated responses; public status JSON carries ts/label/text only."
     },
     "ts": {
      "type": "integer",
      "description": "Unix seconds."
     },
     "label": {
      "type": "string",
      "description": "One of: investigating, identified, monitoring, resolved, update."
     },
     "text": {
      "type": "string",
      "description": "Update text (≤500 chars)."
     },
     "author": {
      "type": "string",
      "description": "Team member email when a member posted it; null = account owner. Never published on public pages.",
      "nullable": true
     }
    }
   },
   "Channel": {
    "type": "object",
    "properties": {
     "id": {
      "type": "integer"
     },
     "kind": {
      "type": "string",
      "enum": [
       "email",
       "discord",
       "slack",
       "webhook",
       "ntfy",
       "telegram",
       "teams",
       "googlechat"
      ]
     },
     "target": {
      "type": "string"
     },
     "verified": {
      "type": "boolean",
      "description": "True once confirmed; alerts only go to verified channels."
     },
     "secret": {
      "type": "string",
      "description": "Webhook signing secret (`whsec_…`; webhook kinds only)."
     },
     "last_error": {
      "type": "string",
      "description": "Most recent delivery failure through this channel (null when the last delivery succeeded). Transient failures are retried after 1, 5 and 15 minutes; a successful delivery clears this.",
      "nullable": true
     },
     "last_error_ts": {
      "type": "integer",
      "description": "Unix time of last_error (null when clear).",
      "nullable": true
     },
     "quiet": {
      "type": "string",
      "description": "Quiet-hours window \"HH:MM-HH:MM\" (may wrap midnight); null when off. Alerts inside the window are held and delivered when it ends, with the original payload and timestamp.",
      "nullable": true
     },
     "quiet_tz": {
      "type": "string",
      "description": "IANA zone the quiet window is read in (wall clock, DST-safe). \"UTC\" when quiet is set without a zone; null when quiet is off.",
      "nullable": true
     },
     "quiet_down": {
      "type": "boolean",
      "description": "true = down/up/reminder alerts break through quiet hours (only slow/fast/budget are held)."
     },
     "delay_s": {
      "type": "integer",
      "description": "Escalation delay in seconds (0 = alert immediately). Down alerts are held this long and cancelled if the monitor recovers first."
     }
    }
   },
   "MaintWindow": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "monitor_id": {
      "type": "string",
      "description": "Null when the window covers all monitors.",
      "nullable": true
     },
     "monitor_name": {
      "type": "string",
      "description": "Null when the window covers all monitors.",
      "nullable": true
     },
     "scope": {
      "type": "string",
      "enum": [
       "monitor",
       "all"
      ]
     },
     "state": {
      "type": "string",
      "enum": [
       "past",
       "active",
       "upcoming"
      ]
     },
     "start_ts": {
      "type": "integer",
      "description": "Unix seconds."
     },
     "end_ts": {
      "type": "integer",
      "description": "Unix seconds."
     },
     "note": {
      "type": "string",
      "description": "Null when none was set.",
      "nullable": true
     },
     "repeat": {
      "type": "string",
      "description": "\"daily\", \"weekly\", or null for one-off windows.",
      "nullable": true
     }
    }
   }
  }
 }
}