openapi: 3.1.0
info:
  title: UptimeMonitoring.com API
  version: 0.1.0
  description: |
    API-first uptime monitoring for developers, deploy pipelines, and AI agents.

    ## Authentication

    All `/api/v1` endpoints require a Bearer token — either an API key (`umk_live_*`)
    or a session token obtained via GitHub OAuth. The `/mcp` endpoint additionally
    accepts OAuth 2.1 JWT access tokens (`eyJ...`) issued via the self-hosted
    authorization server documented under the `OAuth` tag.

    ```
    Authorization: Bearer umk_live_abc123...
    ```

    ## Rate limits

    200 requests/minute per account. Exceeding the limit returns `429` with a
    `Retry-After` header.

    Responses that pass through the per-account rate-limiter carry the
    `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`
    headers (decimal integers; `Reset` is a Unix epoch second at which
    the bucket would be full again). In practice this is every
    authenticated response under `/api/v1/*` whose request reached the
    handler — earlier failures from the auth middleware (e.g. 401 missing
    credentials, 403 suspended account) return before the limiter runs
    and do NOT carry the trio. Routes mounted outside `/api/v1`
    (notably `GET /api/v1/events/stream`, which has its own SSE
    middleware stack) are not subject to this limiter and likewise do
    not emit these headers. Successful responses on
    `POST /api/v1/monitors` reflect the **global** 200/min bucket; the
    per-route 20/min creation limit only surfaces its own headers when
    it 429s. The trio is documented once here rather than referenced
    into every individual response — see `components.headers` for the
    schemas.

    One exception: once a request presenting an API key enters an
    authenticated route group through a registered route or mounted
    subtree, while that key remains in the bounded pre-auth cache
    (entries expire after 5 minutes, but may be evicted sooner once the
    cache is at capacity), the global bucket is charged BEFORE
    authentication and authorization run (issue #47) — so for that
    cached key only, a `401` (key revoked while still cached), `403`,
    `500`, or a `404` for an unmatched path inside a mounted subtree
    also carries the `X-RateLimit-*` trio and spends one token. `429`
    still comes first whenever the bucket is already empty. An uncached
    key (first seen, expired, or evicted early at capacity), a session
    token, an OAuth JWT, and any path outside every authenticated group
    are all unaffected.

    ## Browser push notifications

    Subscriptions registered via `POST /api/v1/push/subscribe` receive
    fire-and-forget Web Push notifications on monitor state transitions
    (down/up/flapping); per-monitor 5-minute throttle. No public API change
    in this release — dispatch is internal to `internal/push.Dispatcher`.
  contact:
    name: UptimeMonitoring.com
    url: https://uptimemonitoring.com
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary

servers:
  - url: https://api.uptimemonitoring.com
    description: Production

paths:
  /:
    get:
      operationId: getServiceInfo
      summary: Service info
      description: Returns the service name and docs URL.
      tags: [Public]
      security: []
      responses:
        "200":
          description: Service info.
          content:
            application/json:
              schema:
                type: object
                properties:
                  service:
                    type: string
                    example: uptimemonitoring-dispatcher
                  docs:
                    type: string
                    example: https://uptimemonitoring.com/docs
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/InternalError"

  /healthz:
    get:
      operationId: healthCheck
      summary: Health check
      description: >-
        Returns 200 if the service is running. Used by load balancers and by
        the post-deploy smoke check, which also asserts `commit` against the
        commit it just deployed.
      tags: [Public]
      security: []
      responses:
        "200":
          description: Healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
                  commit:
                    type: string
                    description: >-
                      The 40-char git commit this binary was built from, or
                      "dev" for a build with no -X main.commitSHA stamp.
                    example: 8bade47d6d9db61a607781f04aefb9349f01d28d
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "500":
          $ref: "#/components/responses/InternalError"

  /oauth/github/login:
    get:
      operationId: oauthGitHubLogin
      summary: Start GitHub OAuth flow
      description: |
        Redirects the user to GitHub's authorization page. Sets an `oauth_state`
        cookie for CSRF protection. After authorization, GitHub redirects to
        `/oauth/github/callback`.

        Requests scope `read:user user:email` (issue #390) — `user:email`
        lets the callback read `GET /user/emails` to discover GitHub's
        verified primary address for email adoption and sign-in merging
        (see `/oauth/github/callback`). Every account sees GitHub's
        consent screen once more on their next sign-in or link.
      tags: [OAuth]
      security: []
      responses:
        "302":
          description: Redirect to GitHub authorization page.
        "500":
          $ref: "#/components/responses/InternalError"

  /oauth/github/callback:
    get:
      operationId: oauthGitHubCallback
      summary: GitHub OAuth callback (sign-in or identity link)
      description: |
        Handles the redirect from GitHub after authorization. In sign-in
        mode, verifies the state parameter, exchanges the code for an
        access token, upserts the account, and redirects to the frontend
        with a one-time auth code.

        Issue #390: in sign-in mode (never link mode), also fetches
        GitHub's verified primary email address best-effort via
        `GET /user/emails`. On a genuinely first sign-in for this GitHub
        account, that address is consulted:

          - No address available, or nobody holds it yet: sign-in proceeds
            as before; an unclaimed address is saved onto the new user in
            the same transaction.
          - The address is already a verified email identity on an
            EXISTING user who has no GitHub identity of their own: the
            sign-in is merged into that user's existing account instead of
            creating a new one — the caller lands on the matched user's
            account, with the GitHub identity now attached to it. If that
            account is suspended, the callback redirects with
            `error=account_suspended`, the same as an email sign-in into a
            suspended account, and nothing is linked.
          - The address already belongs to a DIFFERENT user who already
            has their own GitHub identity: no merge; the sign-in creates an
            ordinary new user and account.

        A merged or newly-adopted address enrolls the member in the
        account's default-enabled weekly and monthly uptime reports and
        becomes visible to the account's owner(s), the same as adding an
        email through `POST /api/v1/me/identities/email/start`+`/verify`.

        In identity-link mode (issue #325 — an `oauth_github_link` cookie
        set by a prior `POST /oauth/github/link`), redeems the ticket
        instead — every outcome, success or failure, redirects to
        `{FRONTEND_URL}/settings?linked=github` or
        `{FRONTEND_URL}/settings?link_error=<code>`, and sign-in is never
        attempted. Mirrors `/oauth/google/callback`'s link-mode shape
        exactly.
      tags: [OAuth]
      security: []
      parameters:
        - name: code
          in: query
          required: true
          schema:
            type: string
          description: Authorization code from GitHub.
        - name: state
          in: query
          required: true
          schema:
            type: string
          description: CSRF state parameter (must match the `oauth_state` cookie).
      responses:
        "302":
          description: Redirect to frontend with auth code or error, or (link mode) to the settings page.
        "400":
          description: Missing or invalid state/code parameter.

  /oauth/github/link:
    post:
      operationId: oauthGitHubLink
      summary: Start a GitHub identity link
      description: |
        The sole entry point for starting a GitHub identity link (issue
        #325), mirroring `/oauth/google/link` exactly. The SPA submits, as
        a top-level form POST (never a GET query string), the
        `link_ticket` returned by `POST /api/v1/me/identities/github`.

        Validates the ticket read-only (no redemption yet — that happens
        at `/oauth/github/callback`). A malformed, wrong-length, unknown,
        expired, already-consumed, or wrong-provider (e.g. a Google-minted
        ticket) ticket clears any stale `oauth_github_link` cookie and
        redirects straight to
        `{FRONTEND_URL}/settings?link_error=link_invalid` without
        contacting GitHub. A valid ticket sets an `oauth_github_link`
        cookie (`Path=/oauth/github`, 10 minutes) and redirects to
        GitHub's authorization page exactly like `/oauth/github/login`.
      tags: [OAuth]
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [link_ticket]
              properties:
                link_ticket:
                  type: string
      responses:
        "302":
          description: Redirect to GitHub's authorization page, or (invalid ticket) straight to the settings page.

  /oauth/exchange:
    post:
      operationId: oauthExchange
      summary: Exchange auth code for session token
      description: |
        Exchanges a one-time auth code (from the OAuth callback redirect) for a
        session token. Auth codes expire after 60 seconds and can only be used once.
      tags: [OAuth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  description: One-time auth code from the OAuth callback redirect.
      responses:
        "200":
          description: Session token and account info.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: Session token for Bearer authentication.
                  account:
                    $ref: "#/components/schemas/Account"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid or expired auth code.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /oauth/email/start:
    post:
      operationId: oauthEmailStart
      summary: Start email sign-in (link or code)
      description: |
        Sends a sign-in email containing both a link (`{FRONTEND_URL}/signin/email#token=<tok>`,
        the token lives only in the URL fragment, which keeps it out of the
        landing-page request URL and ordinary access logs — verification
        itself sends it to this API in a POST body, which the request
        logger does not log) and a six-digit code, both valid for 10
        minutes. Returns `202` with
        an identical body for a known or unknown address — this endpoint
        never reveals whether an address has signed in before.

        Disabled unless `EMAIL_SIGNIN_ENABLED` is set at boot (`503` when
        disabled). Rate limited per address, per caller IP, and with a
        process-wide backstop; see `internal/api/DECISIONS.md` for the
        exact values. `return_to` is honored only when it matches the
        configured OAuth-bridge finalize prefix, mirroring
        `/oauth/github/login`'s `return_to` handling.
      tags: [OAuth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                return_to:
                  type: string
                  description: Only honored when it matches the configured OAuth-bridge finalize prefix.
      responses:
        "202":
          description: Byte-identical for a known or unknown address.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: sent
        "400":
          $ref: "#/components/responses/BadRequest"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          description: Rate limited (per address, per IP, or the process-wide backstop).
        "503":
          description: Email sign-in disabled, or the transport (SendGrid) failed to send.

  /oauth/email/verify:
    post:
      operationId: oauthEmailVerify
      summary: Verify an email sign-in link or code
      description: |
        Accepts exactly ONE of two bodies: `{"token": "..."}` (the link's
        fragment token) or `{"email", "code"}` (the six-digit code). Every
        failure case — unknown, expired, wrong code, already consumed,
        locked out after 5 wrong attempts, or malformed — returns the
        identical `401` body, so a caller cannot distinguish them. There is
        no GET route for this endpoint — a link scanner or prefetcher that
        merely opens the emailed link cannot burn the challenge.

        On success returns `redirect_to`, the same shape
        `/oauth/github/callback` produces: either the dashboard's
        `/callback?code=...` or, if the original `start` call's `return_to`
        is still valid against the OAuth-bridge finalize prefix, that URL
        with `?code=...` appended.
      tags: [OAuth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token:
                  type: string
                  description: The link's fragment token. Mutually exclusive with email/code.
                email:
                  type: string
                  format: email
                code:
                  type: string
                  description: Six-digit code. Requires email; mutually exclusive with token.
      responses:
        "200":
          description: Sign-in succeeded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  redirect_to:
                    type: string
                    format: uri
        "401":
          description: Invalid, expired, wrong, reused, locked, or malformed — identical body for every case.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: The resolved account is suspended.
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          description: Rate limited (per caller IP, or the process-wide backstop).
        "503":
          description: Email sign-in disabled.

  /oauth/google/login:
    get:
      operationId: oauthGoogleLogin
      summary: Start Google OAuth flow (sign-in)
      description: |
        Redirects to Google's authorization page (`scope=openid email`,
        `prompt=select_account`). Sets an `oauth_google_state` cookie
        (`Path=/oauth/google`) for CSRF protection, distinct from GitHub's
        `oauth_state` cookie.

        Not mounted at all unless `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`/
        `GOOGLE_REDIRECT_URI` are all configured — `404` otherwise.

        This route is sign-in only. A stray `link_ticket` query parameter is
        ignored outright and any stale `oauth_google_link` cookie is
        cleared — identity-link starts only via `POST /oauth/google/link`,
        never a GET query string (a bearer ticket in a URL would stay live
        in server/proxy access logs for the full 10-minute window it
        remains valid).
      tags: [OAuth]
      security: []
      parameters:
        - name: return_to
          in: query
          required: false
          schema:
            type: string
          description: Only honored when it matches the configured OAuth-bridge finalize prefix.
      responses:
        "302":
          description: Redirect to Google's authorization page.
        "404":
          description: Google sign-in not configured.

  /oauth/google/link:
    post:
      operationId: oauthGoogleLink
      summary: Start a Google identity link
      description: |
        The sole entry point for starting an identity link (issue #316 fold
        finding 53dbd2c17572ecf3). The SPA submits, as a top-level form POST
        (never a GET query string, so the bearer ticket never appears in the
        request-URI-keyed request logger), the `link_ticket` returned by
        `POST /api/v1/me/identities/google`.

        Validates the ticket read-only (no redemption yet — that happens at
        `/oauth/google/callback`). A malformed, wrong-length, unknown,
        expired, or already-consumed ticket clears any stale
        `oauth_google_link` cookie and redirects straight to
        `{FRONTEND_URL}/settings?link_error=link_invalid` without contacting
        Google. A valid ticket sets an `oauth_google_link` cookie
        (`Path=/oauth/google`, 10 minutes) and redirects to Google's
        authorization page exactly like `/oauth/google/login`.

        Not mounted unless Google sign-in is configured — `404` otherwise.
      tags: [OAuth]
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [link_ticket]
              properties:
                link_ticket:
                  type: string
      responses:
        "302":
          description: Redirect to Google's authorization page, or (invalid ticket) straight to the settings page.
        "404":
          description: Google sign-in not configured.

  /oauth/google/callback:
    get:
      operationId: oauthGoogleCallback
      summary: Google OAuth callback (sign-in or identity link)
      description: |
        Handles the redirect from Google. In sign-in mode, behaves like
        `/oauth/github/callback`: exchanges the code, fetches the Google
        `sub`/`email`/`email_verified`/`hd` claims, resolves or creates the
        account, and redirects to the frontend with a one-time auth code.
        A genuinely first sign-in whose `email_verified` claim is the
        literal `true` and whose address is `gmail.com`/`googlemail.com`,
        or matches a Google Workspace/Cloud `hd` claim case-insensitively,
        links onto whichever existing user already holds that address as a
        verified email identity, but only when that holder has no Google
        identity of their own (issue #392) — every other verified address
        still creates a new user, exactly as an unverified one does, and so
        does a holder who already has a DIFFERENT Google identity attached:
        that case creates a separate Google-only user rather than linking.
        Raw claims are never stored; only the narrowed, normalized address
        is, and only on the row it attaches to.

        In identity-link mode (an `oauth_google_link` cookie set by a prior
        `POST /oauth/google/link`), redeems the ticket instead — every
        outcome, success or failure, redirects to
        `{FRONTEND_URL}/settings?linked=google` or
        `{FRONTEND_URL}/settings?link_error=<code>`, and `SignInWithGoogle`
        is never called. Every failure that happens before the ticket is
        redeemed (invalid state, a missing authorization code, or a Google
        token-exchange/userinfo failure) is normalized to the documented
        link-mode codes — `link_invalid` or `internal_error` — rather than
        leaking the raw sign-in error code.
      tags: [OAuth]
      security: []
      parameters:
        - name: code
          in: query
          required: true
          schema:
            type: string
        - name: state
          in: query
          required: true
          schema:
            type: string
      responses:
        "302":
          description: Redirect to the frontend (sign-in) or settings page (link), success or error either way.
        "404":
          description: Google sign-in not configured.

  /.well-known/oauth-protected-resource/mcp:
    get:
      operationId: oauthPRM
      summary: Protected resource metadata for /mcp (RFC 9728)
      description: |
        Returns the protected-resource metadata document for the `/mcp`
        endpoint. MCP clients fetch this to discover the authorization
        server URL.
      tags: [OAuth]
      security: []
      responses:
        "200":
          description: Protected resource metadata document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  resource:
                    type: string
                    format: uri
                  authorization_servers:
                    type: array
                    items:
                      type: string
                      format: uri
                  bearer_methods_supported:
                    type: array
                    items:
                      type: string
        "404":
          description: OAuth AS not configured (OAUTH_SIGNING_KEY_PEM_FILE unset).

  /.well-known/oauth-authorization-server:
    get:
      operationId: oauthASMetadata
      summary: Authorization server metadata (RFC 8414)
      description: |
        Returns the authorization-server metadata document. Advertises all
        OAuth 2.1 endpoints (registration, authorize, token, keys, revoke),
        supported grants, response types, code-challenge methods, and
        token-endpoint auth methods.
      tags: [OAuth]
      security: []
      responses:
        "200":
          description: Authorization server metadata document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  issuer:
                    type: string
                    format: uri
                  authorization_endpoint:
                    type: string
                    format: uri
                  token_endpoint:
                    type: string
                    format: uri
                  registration_endpoint:
                    type: string
                    format: uri
                  revocation_endpoint:
                    type: string
                    format: uri
                  jwks_uri:
                    type: string
                    format: uri
                  response_types_supported:
                    type: array
                    items: { type: string }
                  grant_types_supported:
                    type: array
                    items: { type: string }
                  code_challenge_methods_supported:
                    type: array
                    items: { type: string }
        "404":
          description: OAuth AS not configured (OAUTH_SIGNING_KEY_PEM_FILE unset).

  /oauth2/register:
    post:
      operationId: oauthRegister
      summary: Dynamic client registration (RFC 7591)
      description: |
        Register a new OAuth client. Returns a `client_id` (and `client_secret`
        for confidential clients). Public clients (PKCE-only) set
        `token_endpoint_auth_method: "none"` and receive no secret.

        Anyone may register a client. Holding a `client_id` confers no
        privilege without a successful authorize flow.

        Rate limited per caller IP (10/hour), counting every JSON attempt
        presented to it, not just successes, plus a 100/hour process-wide
        backstop that counts only attempts the per-IP limiter admits — a
        request the per-IP limiter rejects does not consume backstop
        capacity. `Content-Type` must be `application/json` — checked before
        either limiter is consulted, so a non-JSON request spends neither
        limiter's capacity. Clients that never complete a token grant are
        deleted after 30 days.
      tags: [OAuth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                All string fields must be valid UTF-8 with no null bytes.
                URL fields (`client_uri`, `redirect_uris[]`) must additionally
                be free of ASCII control characters (U+0000..U+001F, U+007F).
                Violations return HTTP 400 with the RFC 7591 envelope
                `{"error":"invalid_client_metadata","error_description":"…"}`.
              required: [redirect_uris]
              properties:
                client_name:
                  type: string
                client_uri:
                  type: string
                  format: uri
                redirect_uris:
                  type: array
                  items: { type: string, format: uri }
                grant_types:
                  type: array
                  items: { type: string }
                  default: [authorization_code, refresh_token]
                response_types:
                  type: array
                  items: { type: string }
                  default: [code]
                token_endpoint_auth_method:
                  type: string
                  enum: [none, client_secret_post, client_secret_basic]
                  default: none
                scope:
                  type: string
                  default: mcp
      responses:
        "201":
          description: Client registered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  client_id:
                    type: string
                  client_secret:
                    type: string
                    description: Returned only for confidential clients; shown once.
                  client_id_issued_at:
                    type: integer
                  redirect_uris:
                    type: array
                    items: { type: string }
                  grant_types:
                    type: array
                    items: { type: string }
                  response_types:
                    type: array
                    items: { type: string }
                  token_endpoint_auth_method:
                    type: string
                  scope:
                    type: string
        "400":
          description: Invalid client metadata.
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          description: |
            Rate limit exceeded — either the per-IP cap (10/hour) or the
            process-wide backstop (100/hour), whichever rejected first (the
            backstop is checked before the per-IP limiter). `Retry-After` is
            the ceiling of the remaining window in seconds (never truncated,
            so a 2.8s wait renders as `3`, not `2` — unlike `/feed/{token}`'s
            handler, which truncates).
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the rejecting limiter's window resets, rounded up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                  error_description:
                    type: string
              example:
                error: too_many_requests
                error_description: too many client registrations from this address; retry later

  /oauth2/authorize:
    get:
      operationId: oauthAuthorize
      summary: OAuth 2.1 authorization endpoint
      description: |
        Initiates the authorization-code flow (PKCE S256 mandatory). When
        the user has no active dispatcher session, redirects to the sign-in
        page — `OAUTH_LOGIN_URL` when the operator has set it (with
        `return_to=<finalize>` appended to that URL's own query), else the
        pre-#315 GitHub-only bridge (`/oauth/github/login?return_to=...`) —
        and resumes via `/oauth2/authorize/finalize` after the bridge
        completes (by GitHub callback or, when `OAUTH_LOGIN_URL` points at
        a page offering it, email sign-in). On success, redirects the
        browser to `redirect_uri` with `code` and echoed `state`.
      tags: [OAuth]
      security: []
      parameters:
        - in: query
          name: client_id
          required: true
          schema: { type: string }
        - in: query
          name: redirect_uri
          required: true
          schema: { type: string, format: uri }
        - in: query
          name: response_type
          required: true
          schema: { type: string, enum: [code] }
        - in: query
          name: code_challenge
          required: true
          schema: { type: string }
        - in: query
          name: code_challenge_method
          required: true
          schema: { type: string, enum: [S256] }
        - in: query
          name: scope
          schema: { type: string, default: mcp }
        - in: query
          name: state
          schema: { type: string }
      responses:
        "200":
          description: |
            Reserved. The endpoint always redirects (302) or errors (400);
            no 200 body is returned in normal operation.
        "302":
          description: Redirect to GitHub login or to `redirect_uri` with code/error.
        "400":
          description: Invalid request (missing or invalid params).

  /oauth2/token:
    post:
      operationId: oauthToken
      summary: OAuth 2.1 token endpoint
      description: |
        Exchanges an authorization code (with PKCE verifier) or a refresh
        token for a JWT access token + refresh token. Refresh tokens
        rotate: the presented token is revoked and a new one is returned.
        JWT claims: `iss`, `sub` (= account ID), `aud=mcp`, `exp` (~1 h),
        `iat`, `jti`, `client_id`, `scope`.
      tags: [OAuth]
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type]
              properties:
                grant_type:
                  type: string
                  enum: [authorization_code, refresh_token]
                code:
                  type: string
                redirect_uri:
                  type: string
                  format: uri
                code_verifier:
                  type: string
                refresh_token:
                  type: string
                client_id:
                  type: string
                client_secret:
                  type: string
      responses:
        "200":
          description: Token response.
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token:
                    type: string
                  token_type:
                    type: string
                    enum: [Bearer]
                  expires_in:
                    type: integer
                  refresh_token:
                    type: string
                  scope:
                    type: string
        "400":
          description: invalid_grant / invalid_request.
        "401":
          description: invalid_client.
        "500":
          description: |
            Two distinct `server_error` shapes share this status,
            distinguishable by the `error_description` body:

            1. **Generic pre-persistence failure** — token minting (JWT
               signing, random token generation) failed before any commit
               was attempted. `error_description: "could not mint tokens"`.
               No authorization code was consumed and no refresh token was
               revoked; safe to retry with the same grant.
            2. **Commit outcome unknown** (e.g. a connection drop
               mid-commit) — the presented authorization code or refresh
               token may already be consumed/revoked even though this
               request failed. `error_description` explicitly says the
               outcome is unknown and tells the client to restart the
               authorization flow. This is an advisory distinction, not an
               RFC 6749 retry guarantee (RFC 6749 does not classify
               `server_error` as non-retryable, and OAuth client libraries
               commonly auto-retry any 5xx regardless of the `error`
               field) — resending the same grant after this response can
               fail with `invalid_grant` if the ambiguous commit actually
               landed, but this API cannot force a well-behaved client to
               avoid that resend.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    enum: [server_error]
                  error_description:
                    type: string
              examples:
                genericFailure:
                  summary: Generic pre-persistence failure (no grant consumed)
                  value:
                    error: server_error
                    error_description: could not mint tokens
                commitOutcomeUnknown:
                  summary: Commit outcome unknown (grant may already be consumed)
                  value:
                    error: server_error
                    error_description: >-
                      token issuance outcome unknown; the authorization code
                      may already be consumed; restart the authorization
                      flow
        "503":
          description: |
            `temporarily_unavailable` — a transient backend error left the
            presented code/refresh token untouched (the transaction rolled
            back). Safe to retry.

  /oauth2/revoke:
    post:
      operationId: oauthRevoke
      summary: OAuth 2.1 token revocation (RFC 7009)
      description: |
        Revokes an access or refresh token belonging to the authenticated
        client. Always responds 200 for tokens owned by the caller, even
        when the token is already gone (RFC 7009 §2.2 anti-probing). Token
        type is advisory via `token_type_hint`; the server falls back to
        JWT shape sniffing and tries the other type if the hint misses.

        Client authentication is required (per RFC 7009): pass `client_id`
        with optional `client_secret` via HTTP Basic, `client_secret_post`
        form fields, or just `client_id` for public clients registered with
        `token_endpoint_auth_method: "none"`. Tokens belonging to a
        different client are silently ignored (still 200, anti-probing) but
        not revoked.
      tags: [OAuth]
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token, client_id]
              properties:
                token:
                  type: string
                token_type_hint:
                  type: string
                  enum: [access_token, refresh_token]
                client_id:
                  type: string
                client_secret:
                  type: string
      responses:
        "200":
          description: Revocation request acknowledged (also returned for tokens not owned by the caller, per anti-probing).
        "400":
          description: Invalid request — missing token, malformed body.
        "401":
          description: Client authentication failed — missing/wrong client credentials.
        "503":
          description: Backend unavailable — the revocation may not have completed; the client should retry.

  /oauth2/keys:
    get:
      operationId: oauthJWKS
      summary: JSON Web Key Set
      description: |
        Returns the public RSA key used to sign JWT access tokens, in JWKS
        format. v1 publishes a single key; rotation is a planned follow-up.
      tags: [OAuth]
      security: []
      responses:
        "200":
          description: JWKS document.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        kty: { type: string, enum: [RSA] }
                        alg: { type: string, enum: [RS256] }
                        use: { type: string, enum: [sig] }
                        kid: { type: string }
                        n: { type: string }
                        e: { type: string }
        "404":
          description: OAuth AS not configured (OAUTH_SIGNING_KEY_PEM_FILE unset).

  /api/v1/keys:
    get:
      operationId: listKeys
      summary: List API keys
      description: |
        Returns all API keys for the authenticated account, ordered by creation
        date (newest first). Key hashes and plaintexts are never exposed.
      tags: [Keys]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: List of API keys.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/APIKey"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      operationId: createKey
      summary: Create API key
      description: |
        Creates a new API key. The plaintext key is returned **once** in the `key`
        field — it cannot be retrieved again. Max 10 keys per account.
      tags: [Keys]
      security:
        - bearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  minLength: 3
                  maxLength: 100
                  description: |
                    Optional human-readable label for the key. Trimmed of
                    surrounding whitespace before persistence. Must be 3..100
                    characters after trimming, or omitted entirely.
                  example: production
      responses:
        "201":
          description: Key created. The `key` field contains the plaintext (shown once).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIKeyCreated"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "409":
          description: Account has reached the 10-key limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: key limit reached
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: |
            Two distinct failure shapes share this status, distinguishable
            by the `error` body:

            1. **Generic pre-persistence failure** — key material generation
               or an unclassified store error before any commit was
               attempted. Body: `{"error": "internal error"}`. No key was
               written; safe to retry.
            2. **Commit outcome unknown** (e.g. a connection drop
               mid-commit) — the message names the candidate key's ID and
               prefix; a key with that ID/prefix may or may not have been
               created. A `GET /api/v1/keys` that doesn't show it is not
               proof it was never created (a commit can become visible
               moments after a read on another connection), and this stays
               true even after waiting and listing again: negative reads
               remain inconclusive. Do not automatically create another
               key. If the candidate does appear and another usable key
               already exists, revoke the candidate directly; if it is the
               account's only key, authenticate via a dashboard session
               (not the withheld candidate) to create and verify a
               replacement first — `DELETE /api/v1/keys/{keyID}` refuses to
               remove an account's last key regardless of whether its
               plaintext was ever received.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                genericFailure:
                  summary: Generic pre-persistence failure (no key written)
                  value:
                    error: internal error
                commitOutcomeUnknown:
                  summary: Commit outcome unknown (candidate may exist)
                  value:
                    error: >-
                      key creation outcome unknown: a key with id 42 and
                      prefix umk_live_ab12cd may or may not have been
                      created. Listing keys and not seeing it does not
                      prove it was not created, even after waiting and
                      listing again — negative reads remain inconclusive.
                      Do not automatically create another key. If it does
                      appear and you already have another usable key,
                      revoke the candidate directly. If it is your only
                      key, deleting it will fail with 409 cannot delete
                      last key: first sign in to the dashboard (GitHub
                      OAuth session, not this key) and create a verified
                      replacement key there, then revoke the candidate.
                      Otherwise contact an operator to confirm the outcome
                      before creating a replacement.

  /api/v1/keys/{keyID}:
    delete:
      operationId: deleteKey
      summary: Delete API key
      description: |
        Deletes an API key. Deletion is rejected while the account has only
        one `api_keys` row, but that guard counts raw rows, not usable
        (non-revoked) keys — a key freshly rotated out and still in its
        grace window still counts. Confirm a working replacement key before
        revoking a candidate key.
      tags: [Keys]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/keyID"
      responses:
        "204":
          description: Key deleted.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "404":
          description: Key not found or belongs to another account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Cannot delete the last key on the account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: cannot delete last key
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/keys/{keyID}/rotate:
    post:
      operationId: rotateKey
      summary: Rotate API key
      description: |
        Creates a new key and schedules the old key for revocation after a 5-minute
        grace window. During the grace window both keys are valid. After the window,
        the old key stops working. The rotated key counts toward the 10-key limit
        during the grace period.
      tags: [Keys]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/keyID"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  minLength: 3
                  maxLength: 100
                  description: |
                    Optional label for the new key. Trimmed of surrounding
                    whitespace before persistence. Must be 3..100 characters
                    after trimming, or omitted entirely.
      responses:
        "201":
          description: New key created. The `key` field contains the plaintext (shown once).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIKeyCreated"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "404":
          description: Key not found, belongs to another account, or already revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Account has reached the 10-key limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: key limit reached
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: |
            Two distinct failure shapes share this status, distinguishable
            by the `error` body:

            1. **Generic pre-persistence failure** — key material generation
               or an unclassified store error before any commit was
               attempted. Body: `{"error": "internal error"}`. No rotation
               was committed — the old key is untouched; safe to retry.
            2. **Commit outcome unknown** (e.g. a connection drop
               mid-commit) — the message names the old key's ID and the
               candidate replacement's ID/prefix — the old key may already
               be revoked (entering its 5-minute grace window) and the new
               key may or may not have been created. A `GET /api/v1/keys`
               that doesn't show the new key is not proof it was never
               created, even after waiting and listing again: negative
               reads remain inconclusive. Retrying this same rotation (same
               old key ID) is safe and cannot mint a second replacement —
               it either mints exactly one new key (the ambiguous attempt
               rolled back) or fails with 404 (the ambiguous attempt
               already landed, in which case the ID/prefix named in this
               message is the real replacement). Before revoking the
               candidate, confirm you have a working replacement key —
               deletion is only rejected while the account has a single
               `api_keys` row, and that count includes an old key still in
               its post-rotation grace window, so the guard does not always
               prevent ending up without a usable key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                genericFailure:
                  summary: Generic pre-persistence failure (no rotation committed)
                  value:
                    error: internal error
                commitOutcomeUnknown:
                  summary: Commit outcome unknown (candidate replacement may exist)
                  value:
                    error: >-
                      key rotation outcome unknown: the old key (id 41) may
                      already be revoked and entering its 5-minute grace
                      window, and a new key with id 42 and prefix
                      umk_live_ab12cd may or may not have been created.
                      Listing keys and not seeing the new key does not
                      prove it was not created, even after waiting and
                      listing again — negative reads remain inconclusive.
                      Retrying this same rotation (same old key ID) is safe
                      and will not create a second replacement: it will
                      either succeed with exactly one new key (the first
                      attempt rolled back) or fail with 404 key not found
                      (the first attempt already landed — in that case the
                      key with id 42 and prefix umk_live_ab12cd above is
                      your real replacement; if you still need its
                      plaintext, rotate again using that key's ID before
                      the old key's 5-minute grace window expires). Before
                      revoking the new key, confirm you have a working
                      replacement: the last-key guard only blocks deletion
                      when the account has a single api_keys row, which
                      still counts an old key inside its grace window, so
                      it will not always stop you from ending up without a
                      usable key.

  /api/v1/monitors:
    get:
      operationId: listMonitors
      summary: List monitors
      description: |
        Returns all monitors for the authenticated account with current status,
        ordered by creation date (newest first).
      tags: [Monitors]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: List of monitors with inline status.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/MonitorListItem"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      operationId: createMonitor
      summary: Create monitor
      description: |
        Creates a new uptime monitor. `type` must be `http`, `tcp`, or
        `ping`; `dns` and `ssl` are defined in the wire format but rejected
        with `400` (issue #209 — see "Invalid request" below). Max 100
        monitors per account. Duplicate URLs within the same account are
        rejected.

        `recipient_ids` (issue #329) selects which recipients the new
        monitor is created with — see `MonitorCreateInput`. Omitted or
        `null` attaches the account's default recipients (`recipients.is_default`),
        unchanged from pre-#329 behavior. An explicit array (including `[]`)
        attaches exactly that set instead: validated like
        `PUT /monitors/{id}/recipients` (positive ids, no duplicates — `400`
        before any write) and ownership-checked against the calling account
        (`400` on a foreign or nonexistent id, before the monitor-limit and
        duplicate-URL checks, so a bad id is reported even for an account
        already at its cap).

        `recipients` (issue #338) is `recipient_ids`'s alternative form,
        letting each attached pair set its own `down_delay_s` at create
        time — see `MonitorRecipients`. Mutually exclusive with
        `recipient_ids`: specifying both is `400`. `recipients: null` is
        `400` (unlike `recipient_ids: null`, which keeps its
        "attach defaults" meaning).

        The dispatcher synchronously runs an immediate test check against
        the configured URL before returning, so the create response carries
        `state.status` of `up` or `down` instead of `unknown`. The check
        uses the same probe path as `POST /api/v1/monitors/{id}/test`. If
        the probe is unavailable, the kill switch is engaged, or the URL
        fails the dispatch-time SSRF guard (DNS rebind between validation
        and dispatch), the create still succeeds and `state.status` is
        `unknown` — the next scheduled tick will populate it.

        Per-account scan heuristic: a sliding window (10 minutes by
        default; `SCAN_WINDOW`) tracks the (host, port) tuples of each
        newly-created monitor — and, identically, every
        `PUT /api/v1/monitors/{monitorID}` that changes `url` or `type`
        (issue #335; see that endpoint's description) — and branches on two
        signatures:
        - **Port-fan** (≥6 distinct ports, by default — `SCAN_PORTFAN_THRESHOLD`
          — on ≤2 hosts, by default — `SCAN_PORTFAN_HOSTS`): scanner-shaped →
          account suspended (403), all monitors purged, security alert fired.
          A `tcp` monitor's port — already checked against the TCP
          allowlist below — counts toward a separate, wider ceiling,
          `SCAN_PORTFAN_TCP_THRESHOLD` (default 12, issue #331 — unset
          tracks the greater of 12 and the effective
          `SCAN_PORTFAN_THRESHOLD`); `http`/`ping` targets always trip at
          the stricter `SCAN_PORTFAN_THRESHOLD` (6), even mixed with `tcp`
          targets on the same host.
        - **Host-fan** (≥8 distinct registrable domains, by default —
          `SCAN_HOSTFAN_THRESHOLD` — eTLD+1; IP literals count individually;
          subdomains of one site fold into a single domain): ambiguous — also
          matches an agent provisioning a real multi-service stack → rejected
          with 429 + Retry-After (seconds until the oldest entry leaves the
          window). Account is NOT suspended; retry after Retry-After seconds.
          The response always carries the actual limit and window enforced
          (`X-RateLimit-Limit`/`X-RateLimit-Window` headers, `limit`/
          `window_seconds` body fields) — these defaults are examples, not
          hard-coded values a client should assume.

        Subject to two independent per-account rate limits. A request consumes
        one token from each:
          - Global cap: 200 requests/minute on any authed `/api/v1/*` route
            (envelope: `account rate limit exceeded; retry in <N>s`).
          - Creation cap: 20 `POST /api/v1/monitors` per minute
            (envelope: `monitor creation rate limit exceeded; retry in <N>s`).
        Either rejection responds 429 with an integer `Retry-After` header.
      tags: [Monitors]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MonitorCreateInput"
      responses:
        "201":
          description: |
            Monitor created. `state.status` carries `up` or `down` from the
            immediate test check. `unknown` indicates a best-effort skip
            (probe unavailable, kill switch active, or SSRF rebind detected
            at dispatch time); the next scheduled tick populates it.

            Soft-cap notice: when the post-insert per-account monitor count
            is in the 51..100 window, the response carries an additional
            `notice` object (`code: soft_cap_reached`). Creates at or below
            50 produce a response with no `notice` field at all (omitempty).
            The 101st create returns 409 (see "Monitor limit reached").
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MonitorDetail"
              examples:
                created:
                  summary: 1st-50th create — no soft-cap notice
                  value:
                    monitor:
                      id: 12345
                      account_id: 42
                      name: my site
                      url: https://example.com
                      type: http
                      interval_sec: 300
                      enabled: true
                    state:
                      monitor_id: 12345
                      status: up
                softCapReached:
                  summary: 51st create — soft-cap notice attached
                  value:
                    monitor:
                      id: 12651
                      account_id: 42
                      name: monitor-51
                      url: https://example.com/51
                      type: http
                      interval_sec: 300
                      enabled: true
                    state:
                      monitor_id: 12651
                      status: up
                    notice:
                      code: soft_cap_reached
                      message: "You've reached 50 monitors. You can keep adding up to 100 while we finalize our paid plans. Email hello@uptimemonitoring.com with your use case — it helps us design fair pricing."
                      monitors_current: 51
                      soft_cap: 50
                      hard_cap: 100
                tcpCreated:
                  summary: tcp monitor on a whitelisted port
                  value:
                    monitor:
                      id: 12652
                      account_id: 42
                      name: example https port
                      url: tcp://example.com:443
                      type: tcp
                      interval_sec: 60
                      enabled: true
                    state:
                      monitor_id: 12652
                      status: up
        "400":
          description: |
            Invalid request — including an unsupported `type` (issue #209),
            a `tcp` URL using a non-whitelisted port or wrong scheme, a
            `body_match` on a `tcp` monitor, and URLs that match the public
            hostname blocklist (`.gov`, `.mil`, major social platforms,
            known critical infrastructure).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                unsupportedType:
                  summary: type is defined but not yet serviced
                  value:
                    error: "type 'ssl' is not available yet; the check engine currently supports: http, ping, tcp"
                undefinedType:
                  summary: type is not a recognized value
                  value:
                    error: "type must be one of: http, tcp, ping, dns, ssl (currently available: http, ping, tcp)"
                tcpDisallowedPort:
                  summary: tcp URL uses a non-whitelisted port
                  value:
                    error: "port 11211 is not allowed for TCP monitors; allowed ports: 21, 22, 25, 80, 110, 143, 443, 465, 587, 993, 995, 2222, 3000, 3306, 5000, 5432, 6379, 8000, 8080, 8443, 8888, 27017"
                tcpBodyMatch:
                  summary: body_match set on a tcp monitor
                  value:
                    error: "body_match is not applicable to tcp monitors"
                blocklistedHostname:
                  summary: Hostname on public blocklist
                  value:
                    error: "url rejected: hostname cdc.gov is on the public blocklist (matches .gov)"
                recipientIDsDuplicate:
                  summary: recipient_ids contains a duplicate (issue #329)
                  value:
                    error: "recipient_ids must not contain duplicates"
                recipientIDsNotPositive:
                  summary: recipient_ids contains a non-positive id (issue #329)
                  value:
                    error: "recipient_ids must all be positive"
                recipientIDsNotOwned:
                  summary: recipient_ids names an id not owned by this account (issue #329)
                  value:
                    error: "one or more recipient_ids are not owned by this account"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "409":
          description: Monitor limit reached or duplicate URL.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          description: |
            Rate limit exceeded. Three possible causes, distinguishable by
            `X-RateLimit-Reason` header (absent on token-bucket rejections):

            1. **Global cap** (200/min per account): `error` contains
               `"account rate limit exceeded; retry in <N>s"`.
               `X-RateLimit-Limit: 200`.
            2. **Creation cap** (20/min per account): `error` contains
               `"monitor creation rate limit exceeded; retry in <N>s"`.
               `X-RateLimit-Limit: 20`.
            3. **Host-fan** (≥8, by default — `SCAN_HOSTFAN_THRESHOLD` —
               distinct registrable domains in 10 min, by default —
               `SCAN_WINDOW`): body is the `HostFanError` schema with
               `retry_after_seconds` and `limit`. `X-RateLimit-Reason:
               host_fan`. Account is NOT suspended. `X-RateLimit-Limit`/
               `X-RateLimit-Window` and the body's `limit`/`window_seconds`
               always reflect the actual configured values.

            The `Retry-After` header is present in all three cases (integer
            >= 1). Retry after that many seconds; for case 3 the account
            will accept the request once the oldest qualifying entry ages out.
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the next allowed request.
            X-RateLimit-Limit:
              $ref: "#/components/headers/XRateLimitLimit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/XRateLimitRemaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/XRateLimitReset"
            X-RateLimit-Reason:
              schema:
                type: string
                enum: [host_fan]
              description: Present only on host-fan rejections.
            X-RateLimit-Window:
              schema:
                type: integer
              description: Sliding-window length in seconds (present on host-fan rejections).
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/Error"
                  - $ref: "#/components/schemas/HostFanError"
              examples:
                creationCap:
                  summary: Creation rate cap (20/min)
                  value:
                    error: monitor creation rate limit exceeded; retry in 2s
                hostFan:
                  summary: Host-fan trip (≥8 distinct domains in 10 min — both defaults, env-overridable)
                  value:
                    error: "too many distinct domains in a short window; slow monitor creation or target changes and retry"
                    limit: 7
                    window_seconds: 600
                    retry_after_seconds: 347

  /api/v1/monitors/{monitorID}:
    get:
      operationId: getMonitor
      summary: Get monitor
      description: Returns a monitor with its full runtime state including evidence buffer.
      tags: [Monitors]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
      responses:
        "200":
          description: Monitor with state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MonitorDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "404":
          description: Monitor not found or belongs to another account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

    put:
      operationId: updateMonitor
      summary: Update monitor
      description: |
        Partially updates a monitor. Only include fields you want to change.
        Unchanged fields retain their current values. An explicit
        `"type"` other than `http`, `tcp`, or `ping` is rejected with `400`
        (issue #209), the same rule `POST /api/v1/monitors` enforces;
        omitting `type` leaves the stored value untouched and re-validates
        nothing about it.

        Per-account scan heuristic (issue #335): a request that changes
        `url` or `type` is evaluated by the identical port-fan/host-fan
        heuristic `POST /api/v1/monitors` uses — see that endpoint's
        description for the exact signatures, the `SCAN_PORTFAN_TCP_THRESHOLD`
        TCP-widened ceiling, and the sliding window. A request that leaves
        both `url` and `type` unchanged never consults the heuristic at
        all. A port-fan trip returns `403` (see "Account suspended" below);
        a host-fan trip returns `429` with the same `HostFanError` body and
        headers `POST /api/v1/monitors` documents (see "Rate limit
        exceeded" below) — this endpoint's 429 never includes the
        creation-cap case, since there is no per-route creation limiter on
        `PUT`.
      tags: [Monitors]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MonitorInput"
      responses:
        "200":
          description: Monitor updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MonitorDetail"
        "400":
          description: |
            Invalid request — including an unsupported `type` (issue #209)
            and the same field-level and URL rules `POST /api/v1/monitors`
            enforces.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                unsupportedType:
                  summary: type is defined but not yet serviced
                  value:
                    error: "type 'ssl' is not available yet; the check engine currently supports: http, ping, tcp"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "404":
          description: Monitor not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Conflict. Either the new URL duplicates another monitor on this
            account, or the monitor was modified concurrently by another
            request and retries were exhausted — retry the request with a
            fresh read in the latter case.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/MonitorUpdateRateLimited"

    delete:
      operationId: deleteMonitor
      summary: Delete monitor
      description: |
        Deletes a monitor and all associated data (recipient attachments,
        incidents, delivery log, runtime state). Attached recipients
        themselves are NOT deleted — only this monitor's attachment to
        them.
      tags: [Monitors]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
      responses:
        "204":
          description: Monitor deleted.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "404":
          description: Monitor not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/incidents:
    get:
      operationId: listIncidents
      summary: List incidents
      description: |
        Returns incidents for the authenticated account with optional filtering
        and pagination. Ordered by start time (newest first). A resolved
        incident older than 30 days is never returned here (issue #357) —
        this is a visibility floor enforced at read time, independent of
        how long the underlying row is physically kept (95 days, so the
        uptime-report worker and a status page's 90-day daily history both
        have enough lookback; see internal/retention/DECISIONS.md). An
        open incident is never hidden by this floor, however old it is.
      tags: [Incidents]
      security:
        - bearerAuth: []
      parameters:
        - name: monitor_id
          in: query
          schema:
            type: integer
            format: int64
          description: Filter by monitor ID.
        - name: type
          in: query
          schema:
            type: string
            enum: [down, flapping]
          description: Filter by incident type.
        - name: started_after
          in: query
          schema:
            type: string
            format: date-time
          description: Only incidents started after this time (RFC3339).
        - name: started_before
          in: query
          schema:
            type: string
            format: date-time
          description: Only incidents started before this time (RFC3339).
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Page size (1-100).
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Number of items to skip.
      responses:
        "200":
          description: Paginated incident list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  incidents:
                    type: array
                    items:
                      $ref: "#/components/schemas/Incident"
                  total:
                    type: integer
                    description: Total matching incidents (for pagination).
                  limit:
                    type: integer
                  offset:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/incidents/{incidentID}:
    get:
      operationId: getIncident
      summary: Get incident
      description: |
        Returns a single incident with its evidence snapshot. Same 30-day
        visibility floor as `GET /incidents` (issue #357): a resolved
        incident older than 30 days 404s here as if it did not exist.
      tags: [Incidents]
      security:
        - bearerAuth: []
      parameters:
        - name: incidentID
          in: path
          required: true
          schema:
            type: integer
            format: int64
          description: Incident ID.
      responses:
        "200":
          description: Incident details.
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    $ref: "#/components/schemas/Incident"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "404":
          description: Incident not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/me:
    get:
      operationId: getMe
      summary: Get the signed-in user's identities
      description: |
        Works with any authentication method (session, API key, or OAuth
        JWT) — keys off the request's resolved user, not the account. `401`
        if no user could be resolved at all.

        The response shape (issue #325) depends on the credential:
        - A dashboard **session** gets the full cross-account view:
          `memberships` lists EVERY account the user belongs to,
          `current_account_id` is the account this request authenticated
          against, and `default_account_id` is the user's own stored
          default (`null` if unset).
        - An **API key** or **OAuth JWT** gets only the CURRENT account's
          own entry in `memberships` (never another account) and
          `current_account_id`; `default_account_id` is **omitted
          entirely**, not merely `null` — neither credential may enumerate
          the user's other accounts or observe the user-global default.
      tags: [Account]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Identity presence for the resolved user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    format: int64
                  github_username:
                    type: ["string", "null"]
                  email:
                    type: ["string", "null"]
                  has_google:
                    type: boolean
                  current_account_id:
                    type: integer
                    format: int64
                  default_account_id:
                    description: Present only for a dashboard session.
                    type: ["integer", "null"]
                    format: int64
                  memberships:
                    type: array
                    items:
                      type: object
                      properties:
                        account_id:
                          type: integer
                          format: int64
                        label:
                          type: string
                          description: |
                            The account's earliest owner's `github_username`
                            when non-empty, else `"account <id>"` — never an
                            email address.
                        role:
                          type: string
                          enum: [owner, member, viewer]
                        suspended:
                          type: boolean
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/me/account:
    post:
      operationId: switchAccount
      summary: Switch the signed-in session's current account
      description: |
        Session-only, but returns `422 {"error":"session_required"}` for a
        non-session caller — deliberately NOT the `403` every other
        session-only route in this file uses, per issue #325's acceptance
        criteria. Re-validates `account_id` as a current, non-suspended
        membership for the caller (`403 {"error":"forbidden"}` on either
        failure), persists it as the user's new default, and moves the
        session's own current account — both under one per-session mutex,
        so two concurrent switches on the same token can't leave the
        session and the durable default disagreeing. A session token that
        vanishes (logout, expiry) between the start of the switch and the
        session update returns `401`; the database write has already
        landed and is not undone.
      tags: [Account]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_id]
              properties:
                account_id:
                  type: integer
                  format: int64
      responses:
        "200":
          description: Switched.
          content:
            application/json:
              schema:
                type: object
                properties:
                  account_id:
                    type: integer
                    format: int64
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: The session token vanished mid-switch.
        "403":
          description: Target is not a current membership, or is suspended.
        "422":
          description: Caller did not authenticate via a dashboard session.

  /api/v1/me/reports:
    get:
      operationId: getReportPrefs
      summary: Get the signed-in user's weekly/monthly report preferences (issue #357)
      description: |
        Session-only — `403 {"error":"session_required"}` for an API key
        or OAuth JWT, since these are a shared, account-wide credential
        and must never see or toggle one person's mail preference.
        Scoped to the session's current account and the caller's own
        user id.
      tags: [Account]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: The caller's report preferences on the current account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportPreferences"
        "403":
          description: Not a dashboard session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      operationId: setReportPrefs
      summary: Update the signed-in user's weekly/monthly report preferences (issue #357)
      description: |
        Session-only, same 403 as `GET`. Body must be a JSON object with
        at least one of `weekly`/`monthly`, each a plain JSON boolean —
        not `null`, not a string or number. Any other top-level key is
        rejected. Writes no audit event (a personal setting, not an
        account mutation).
      tags: [Account]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                weekly:
                  type: boolean
                monthly:
                  type: boolean
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportPreferences"
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          description: Not a dashboard session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/me/identities/github:
    post:
      operationId: startGitHubLink
      summary: Start linking a GitHub identity onto the signed-in user
      description: |
        Session-only (`403 {"error":"session_required"}` for an API key or
        an OAuth JWT). Mirrors `POST /api/v1/me/identities/google` exactly,
        substituting `POST /oauth/github/link`. Always mounted — GitHub
        sign-in is never feature-flagged the way Google is.
      tags: [Account]
      security:
        - bearerAuth: []
      responses:
        "201":
          description: Ticket minted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  link_url:
                    type: string
                    format: uri
                  link_ticket:
                    type: string
        "403":
          description: Caller did not authenticate via a dashboard session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/me/invites:
    get:
      operationId: listMyInvites
      summary: List invites addressed to the signed-in user's GitHub identity
      description: |
        Session-only (`403 {"error":"session_required"}` for an API key or
        an OAuth JWT) — neither credential may see invitations to other
        accounts. A user with no GitHub identity always gets an empty list.
      tags: [Account]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Live invites, oldest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/UserInvite"
        "403":
          description: Caller did not authenticate via a dashboard session.

  /api/v1/me/invites/{inviteID}/accept:
    post:
      operationId: acceptInvite
      summary: Accept an invite
      description: |
        Session-only. `404 invite_not_found` for an unknown, revoked,
        expired, or not-addressed-to-this-user invite; `409 member_exists`
        if the caller is already a member (the invite is consumed either
        way); `403` `"account suspended; contact support"` if the account is
        currently suspended — the invite survives, nothing is added.
      tags: [Account]
      security:
        - bearerAuth: []
      parameters:
        - name: inviteID
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        "200":
          description: Joined.
          content:
            application/json:
              schema:
                type: object
                properties:
                  account_id:
                    type: integer
                    format: int64
                  role:
                    type: string
                    enum: [owner, member, viewer]
        "403":
          description: Caller did not authenticate via a dashboard session, or the account is suspended.
        "404":
          description: Invite not found.
        "409":
          description: Caller is already a member.

  /api/v1/account/invites:
    post:
      operationId: createInvite
      summary: Invite a GitHub user to the account
      description: |
        Owner-only. `github_username` is validated locally (1–39 bytes,
        `^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$`) before any outbound call —
        `400 invalid_github_username` on failure. A valid login is resolved
        via GitHub's public, unauthenticated `GET /users/{login}`:
        `404 github_user_not_found` if GitHub reports no such user, `502
        github_error` on any other GitHub failure. `409 member_exists` if a
        user with that GitHub ID is already a member; `409 invite_exists`
        if a live invite for them already exists. Invites expire after 7
        days.
      tags: [Account]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [github_username, role]
              properties:
                github_username:
                  type: string
                role:
                  type: string
                  enum: [owner, member, viewer]
      responses:
        "201":
          description: Invite created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invite"
        "400":
          description: Invalid GitHub username, or an invalid role.
        "404":
          description: GitHub reports no such user.
        "409":
          description: Already a member, or a live invite already exists.
        "502":
          description: GitHub lookup failed for a reason other than 404.
    get:
      operationId: listInvites
      summary: List live invites on the account
      description: Owner-only. Returns every unexpired invite, oldest first.
      tags: [Account]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Live invites.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Invite"
        "403":
          description: Caller is not the account's owner.

  /api/v1/account/invites/{inviteID}:
    delete:
      operationId: revokeInvite
      summary: Revoke an invite
      description: Owner-only. `404 invite_not_found` if no such live invite exists on the account.
      tags: [Account]
      security:
        - bearerAuth: []
      parameters:
        - name: inviteID
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        "204":
          description: Revoked.
        "404":
          description: Invite not found.

  /api/v1/me/identities/google:
    post:
      operationId: startGoogleLink
      summary: Start linking a Google identity onto the signed-in user
      description: |
        Session-only (`403 {"error":"session_required"}` for an API key or
        an OAuth JWT). Mints a single-use ticket and returns the
        `POST /oauth/google/link` form target plus the raw ticket — the SPA
        submits these as a top-level form POST (never a redirect carrying
        the ticket in a query string: it is a live bearer credential for 10
        minutes and must never appear in a URL or request log). The user
        then completes Google's consent screen, and the callback links the
        resulting identity onto this session's user.

        Not mounted at all unless Google sign-in is configured — `404`
        otherwise (distinct from this handler's own `503`, which covers the
        case where `GET /api/v1/me` and the email-link endpoints are
        mounted but Google specifically is not).
      tags: [Account]
      security:
        - bearerAuth: []
      responses:
        "201":
          description: Ticket minted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  link_url:
                    type: string
                    format: uri
                  link_ticket:
                    type: string
        "403":
          description: Caller did not authenticate via a dashboard session.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Google sign-in not configured.
        "503":
          description: Google sign-in is not configured.

  /api/v1/me/identities/email/start:
    post:
      operationId: startEmailLink
      summary: Start linking an email identity onto the signed-in user
      description: |
        Session-only. Sends a link (`{FRONTEND_URL}/settings/email#token=<tok>`)
        and a six-digit code, both valid for 10 minutes, reusing the same
        sender/rate limiters as `/oauth/email/start`. Never reveals whether
        the address is already linked to another user — that answer only
        comes at verify, after ownership is proven.
      tags: [Account]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        "202":
          description: Byte-identical for a known or unknown address.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: sent
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          description: Caller did not authenticate via a dashboard session.
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "503":
          description: Email sign-in is disabled.

  /api/v1/me/identities/email/verify:
    post:
      operationId: verifyEmailLink
      summary: Verify and complete an email identity link
      description: |
        Session-only. Accepts exactly one of `{"token": "..."}` or
        `{"email", "code"}`, scoped to the calling session's user — a token
        or code minted for a different user always 401s, identically to any
        other invalid/expired case.
      tags: [Account]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token:
                  type: string
                email:
                  type: string
                  format: email
                code:
                  type: string
      responses:
        "200":
          description: Linked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: linked
        "401":
          description: Invalid, expired, wrong, reused, locked, malformed, or belonging to a different session user — identical body for every case.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Caller did not authenticate via a dashboard session, or the resolved account is suspended.
        "409":
          description: identity_in_use (another user already holds this address) or identity_already_linked (this user already has an email identity).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "503":
          description: Email sign-in is disabled.

  /api/v1/account:
    get:
      operationId: getAccount
      summary: Get the authenticated account
      tags: [Account]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Account
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Account"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      operationId: patchAccount
      summary: Update the authenticated account's notification_email
      description: |
        Sets or clears the optional `notification_email` field on the
        authenticated account. JSON null and the empty string both clear
        the field. The body must contain exactly the
        `notification_email` key — extra keys and an absent
        `notification_email` field are rejected with HTTP 400. The
        address is validated as RFC 5322 and capped at 254 characters
        (RFC 5321). Returns the updated account on success.
      tags: [Account]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AccountPatchInput"
            examples:
              setEmail:
                summary: Set the notification email
                value:
                  notification_email: user@example.com
              clearWithNull:
                summary: Clear the notification email with null
                value:
                  notification_email: null
              clearWithEmpty:
                summary: Clear the notification email with an empty string
                value:
                  notification_email: ""
      responses:
        "200":
          description: Account
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Account"
        "400":
          description: Bad request (missing field, unknown field, invalid address, over-length, invalid UTF-8, trailing data)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/account/usage:
    get:
      operationId: getAccountUsage
      summary: Get daily usage rows for the authenticated account
      tags: [Account]
      security:
        - bearerAuth: []
      parameters:
        - name: days
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 30
      responses:
        "200":
          description: Usage
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountUsageResponse"
        "400":
          description: Bad request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/account/events:
    get:
      operationId: listAccountEvents
      summary: List the authenticated account's audit events
      description: |
        Returns the per-account audit log written transactionally with every
        monitor / webhook / api-key mutation plus account-level signals
        (suspension, notification_email change). Newest-first by
        `(created_at DESC, id DESC)`. Paginated by opaque cursor — see the
        `cursor` parameter and the `next_cursor` response field.

        Rows become eligible for deletion after 90 days by a best-effort
        daily sweep (not a maximum age — see internal/retention). `since`
        filters older than the 90-day cutoff are rejected with HTTP 400.
      tags: [Account]
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
          description: Page size. Max 200.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: |
            Opaque base64url cursor returned as `next_cursor` from a previous
            response. Pass back verbatim — the value is URL-safe and must
            NOT be percent-encoded by the client. Treat as opaque; do not
            inspect or mutate.
        - name: event_type
          in: query
          required: false
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              enum:
                - monitor.created
                - monitor.updated
                - monitor.deleted
                - webhook.created
                - webhook.updated
                - webhook.deleted
                - recipient.created
                - recipient.updated
                - recipient.deleted
                - recipient.confirmed
                - recipient.unsubscribed
                - monitor.recipients_set
                - apikey.created
                - apikey.rotated
                - apikey.deleted
                - account.suspended
                - account.unsuspended
                - account.notification_email_changed
                - account.email_cap_hit
                - account.member_role_changed
                - account.member_removed
                - account.invite_created
                - account.invite_revoked
                - account.invite_accepted
                - monitor.status_page_enabled
                - monitor.status_page_disabled
                - account.identity_linked
                - account.github_email_skipped
          description: |
            Filter to specific event types. Repeatable — providing multiple
            values returns the union (OR). Unknown values return 400.
            `webhook.*` are historic (pre-#212) and kept for the 90-day
            retention window; new writes produce `recipient.*` and
            `monitor.recipients_set` instead. `recipient.confirmed`,
            `recipient.unsubscribed`, and `account.email_cap_hit` are
            issue #321 (email recipients) additions. `account.identity_linked`
            (issue #316, Google/email identity linking) and
            `account.github_email_skipped` (issue #390, GitHub email
            adoption/merge) were both previously omitted from this filter
            enum even though the store could already write
            `account.identity_linked` rows — a pre-#390 binary's unfiltered
            `GET /api/v1/account/events` (no `event_type` param) still
            serialized those rows, since filtering is the only place this
            enum was enforced; see internal/api/DECISIONS.md.
        - name: ref_type
          in: query
          required: false
          schema:
            type: string
            enum: [monitor, webhook, recipient, apikey]
          description: |
            Filter to events referring to a specific entity type. Must be
            paired with `ref_id`; providing one without the other returns 400.
        - name: ref_id
          in: query
          required: false
          schema:
            type: integer
            format: int64
          description: |
            Filter to events for a specific entity ID. Must be paired with
            `ref_type`.
        - name: since
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: |
            Inclusive lower bound on `created_at` (RFC3339). Values older than
            the 90-day retention window are rejected with 400.
        - name: until
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: |
            Exclusive upper bound on `created_at` (RFC3339). Must be strictly
            after `since` when both are provided.
      responses:
        "200":
          description: Paginated audit event list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountEventList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/announcements:
    get:
      operationId: listAnnouncements
      summary: List active system announcements
      tags: [Announcements]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Announcements
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnnouncementListResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: createAnnouncement
      summary: Create a system announcement (admin only)
      description: |
        Admin-gated. Creates a new banner row.

        - `title` and `body` are required; both are TrimSpace'd and
          length-checked (1..200 / 1..2000 chars).
        - `published_at` is optional; when omitted the server defaults
          to `now()`.
        - `expires_at` is optional; when set must be strictly greater
          than the effective `published_at`.

        Decode is permissive — unknown top-level fields are silently
        ignored to match the rest of the API's POST/PUT convention.
        `PATCH /api/v1/account` is the documented strict-decode
        exception, NOT the rule.
      tags: [Announcements]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AnnouncementInput"
      responses:
        "201":
          description: Announcement created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Announcement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/announcements/{announcementID}:
    put:
      operationId: updateAnnouncement
      summary: Update a system announcement (admin only)
      description: |
        Admin-gated. Body fields are all optional; only fields present
        in the request are mutated. An empty body `{}` returns 200
        with the row unchanged.

        `expires_at` carries three-state semantics:
          - **absent**         → column is left unchanged.
          - **explicit `null`** → column is set to NULL (no expiry).
          - **concrete value**  → column is set to the supplied
            timestamp; must be strictly greater than the effective
            `published_at` if both are supplied in the same request.

        `title` and `body`, when supplied, are TrimSpace'd and
        length-checked (1..200 / 1..2000 chars).

        Decode is permissive — unknown top-level fields are silently
        ignored to match the rest of the API's POST/PUT convention.
      tags: [Announcements]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/announcementID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AnnouncementUpdate"
      responses:
        "200":
          description: Announcement updated (or unchanged on empty body).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Announcement"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No announcement exists with the given ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      operationId: deleteAnnouncement
      summary: Delete a system announcement (admin only)
      description: |
        Admin-gated. Hard-deletes the row. Returns 204 on success and
        404 when the id does not exist (a second DELETE against the
        same id therefore returns 404, mirroring the rest of the
        API).
      tags: [Announcements]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/announcementID"
      responses:
        "204":
          description: Announcement deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No announcement exists with the given ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/monitors/{monitorID}/webhook:
    get:
      operationId: getMonitorWebhook
      summary: "Deprecated: get the legacy single webhook configured for a monitor"
      deprecated: true
      description: |
        Deprecated alias (issue #212). Backed by recipients: returns the
        lowest-id webhook-type recipient currently attached to this
        monitor, in the legacy `{id, monitor_id, url, secret, created_at}`
        shape, `secret` included. Prefer `GET /api/v1/recipients` /
        `GET /api/v1/monitors/{monitorID}/recipients`. Kept for one
        release for pre-#212 client compatibility.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
      responses:
        "200":
          description: Webhook
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Webhook"
        "404":
          description: Monitor or webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
    put:
      operationId: putMonitorWebhook
      summary: "Deprecated: create or replace the legacy single webhook for a monitor"
      deprecated: true
      description: |
        Deprecated alias (issue #212), backed by recipients. If this
        monitor has no attached webhook-type recipient, creates one named
        after the monitor, attaches it, and returns it (with its secret)
        in the legacy shape. If one is already attached, this updates
        ITS url and returns the SAME id and secret — including when that
        recipient is shared with other monitors via
        `PUT /api/v1/monitors/{monitorID}/recipients`, in which case the
        URL change applies to every monitor sharing it, not just this
        one. Prefer `POST /api/v1/recipients` +
        `PUT /api/v1/monitors/{monitorID}/recipients` for new
        integrations. The `url` must point to a public hostname;
        private/loopback/link-local/mDNS URLs are rejected with
        `400 {"error":"url must be a valid http(s) URL"}` (SSRF
        hardening).
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                The `url` must be valid UTF-8 with no null bytes and no ASCII
                control characters (U+0000..U+001F, U+007F). Violations return
                HTTP 400.
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  maxLength: 2048
                  example: https://example.com/uptime-webhook
      responses:
        "200":
          description: Webhook
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Webhook"
        "400":
          description: Bad request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Monitor not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      operationId: deleteMonitorWebhook
      summary: "Deprecated: remove the legacy single webhook from a monitor"
      deprecated: true
      description: |
        Deprecated alias (issue #212). Detaches the attached webhook-type
        recipient from this monitor; deletes the recipient itself only if,
        after detaching, it is attached to no other monitor and is not
        marked `default` — a shared or default recipient survives with its
        other attachments intact. Prefer
        `PUT /api/v1/monitors/{monitorID}/recipients` to detach without
        risking deletion of a shared recipient.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
      responses:
        "204":
          description: Deleted
        "404":
          description: Monitor or webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/recipients:
    get:
      operationId: listRecipients
      summary: List account-level recipients
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Recipients (never includes `secret`)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Recipient"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      operationId: createRecipient
      summary: Create an account-level recipient
      description: |
        `type` is `webhook`, `email`, `slack`, or `pagerduty`. For
        `type=webhook`, the response includes `secret` — shown exactly
        once, here, and never again on any subsequent read; `config.url`
        must point to a public hostname (SSRF hardening, same rule as the
        deprecated webhook alias). For `type=email`, the response carries
        no `secret` (every email recipient shares the fixed empty string
        internally); `config.address` is required instead of `config.url`,
        and the recipient starts `status=pending` until the mailed
        confirmation link is followed — see `POST /email/confirm/{token}`
        and `POST /api/v1/recipients/{recipientID}/confirm/resend`. For
        `type=slack` (issue #359), `config.url` must be a Slack incoming
        webhook URL in the exact shape
        `https://hooks.slack.com/services/{team}/{bot}/{token}` — no port,
        userinfo, query string, or fragment, and no trailing slash;
        `config.channel` is optional (e.g. `"#ops"` or `"@alice"`) and
        overrides the incoming webhook's own default channel. The response
        never carries `secret` for a slack recipient, and `config.url` is
        always masked to `https://hooks.slack.com/services/{team}/{bot}/********`
        (or, for a stored URL that doesn't match the expected shape, the
        bare `https://hooks.slack.com/services/********`) — the real token
        is never echoed back. For `type=pagerduty` (issue #363),
        `config.integration_key` is required instead of `config.url`: the
        PagerDuty Events API v2 routing/integration key, exactly 32
        characters of `[A-Za-z0-9]`. The response never carries `secret`
        or `config.url` for a pagerduty recipient, and `config.integration_key`
        is always masked to `"********"` plus the last 4 characters (or the
        bare `"********"` for a stored key that doesn't match the expected
        shape) — the real key is never echoed back. Every trigger/resolve
        for one outage shares one `dedup_key`
        (`"<monitor_id>-<outage_id>"`), but only `dedup_key` and
        `payload.custom_details.monitor_id` are stable across an entire
        outage — `payload.source`, `payload.custom_details.monitor_name`,
        and the resolve's own fields do not repeat the trigger's `event`
        or `reason`. If `config.integration_key` is an Event Orchestration
        routing key, route its rules on `custom_details.monitor_id`, or
        enable the service's Global Dedup setting, rather than on any
        field that can change mid-outage. `config` validates
        only the four reserved keys — `url`, `address`, `channel`,
        `integration_key` — against the chosen `type`: in any letter case,
        and regardless of value (including `null` or `""`), a reserved key
        the `type` does not allow (e.g. `config.address` on a webhook,
        slack, or pagerduty recipient, `config.integration_key` on any
        type but pagerduty) is rejected with 400, as are two keys that
        fold to the same reserved name (e.g. `url` and `URL`). Any other
        key in `config` is not one of the four reserved names and is
        silently ignored. `name` is required (max 255 chars); `events`
        defaults to `["down","up","flapping"]` when omitted and must be a
        non-empty subset of that set when provided.

        For `type=pagerduty`: deleting or detaching the recipient,
        changing `config.integration_key`, or deleting or disabling the
        monitor or the account does NOT resolve an incident already open
        in PagerDuty under the old key — only the customer, or the
        PagerDuty service's own auto-resolve, can close it. A delivery
        already queued before a monitor is disabled or an account is
        suspended can still send.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RecipientCreate"
      responses:
        "201":
          description: Created, with `secret`
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Recipient"
        "400":
          description: Bad request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/recipients/{recipientID}:
    parameters:
      - name: recipientID
        in: path
        required: true
        schema:
          type: integer
          format: int64
    get:
      operationId: getRecipient
      summary: Get a recipient
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Recipient (never includes `secret`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Recipient"
        "404":
          description: Recipient not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      operationId: patchRecipient
      summary: Update a recipient
      description: |
        Only fields present in the body are changed. `type` is immutable:
        including it at all (any value) returns 400. `name`, `default`,
        `events`, and `config` each reject an explicit JSON `null` with
        400 — omit the field entirely to leave it unchanged. `events`, if
        present, must be a non-empty subset of
        `["down","up","flapping"]`. Changing `config.address` on a
        `type=email` recipient resets it to `status=pending` and sends a
        new confirmation message BEST-EFFORT — a send failure here does
        not fail the request (the response still reflects the committed
        address change), so a caller that does not see the mail arrive
        should call `POST /recipients/{recipientID}/confirm/resend` to
        retry delivery rather than re-submitting the same PATCH, which
        sends nothing on an unchanged address (see `RecipientPatch`). For
        `type=slack` (issue #359), `config.url` and `config.channel` are
        each independently optional on PATCH — at least one is required —
        and `config.channel` set to `null` or `""` clears the override
        back to the incoming webhook's own default. PATCHing back the
        masked `config.url` from a GET/POST response is rejected with 400
        (it fails the same Slack URL grammar check) and leaves the stored
        URL unchanged. Any URL or channel change on a `type=slack`
        recipient cancels its own still-queued, not-yet-claimed
        (`pending`/`retrying`) deliveries, so a destination the caller
        just replaced never receives one of those. A delivery the Worker
        has already claimed for dispatch at the moment of the PATCH still
        runs against the pre-edit destination: a successful attempt is
        recorded as `delivered` (overwriting the cancellation), but a
        failed or retryable attempt leaves the row `cancelled`, with no
        attempt count, response code, or response body recorded for that
        attempt. For `type=pagerduty` (issue #363), `config.integration_key`
        is required when `config` is present (there is no optional
        secondary field the way Slack has `channel`). PATCHing back the
        masked `config.integration_key` from a GET/POST response is
        rejected with 400 (it fails the same key-shape check) and leaves
        the stored key unchanged. A key change on a `type=pagerduty`
        recipient cancels its own still-queued, not-yet-claimed
        deliveries the same way a Slack URL/channel change does — but
        never resolves an incident the old key already opened in
        PagerDuty; only the customer or PagerDuty's own auto-resolve can
        do that. `config` applies the
        same cross-type reserved-key rejection Create does (see that
        operation's description).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RecipientPatch"
      responses:
        "200":
          description: Updated (never includes `secret`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Recipient"
        "400":
          description: Bad request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Recipient not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      operationId: deleteRecipient
      summary: Delete a recipient
      description: |
        Detaches the recipient from every monitor it was attached to
        (cascade) and deletes it. Deletes the recipient's own
        `pending`/`retrying` webhook_deliveries rows so nothing keeps
        retrying to a removed destination.

        **Guarded delete (issue #396).** An optional `?replaced_by=` runs
        a stricter path instead: the recipient is deleted only if a
        replacement recipient already covers every one of its live
        (`pending`/`retrying`) deliveries. Omitting `replaced_by` keeps
        the plain, unguarded behavior described above exactly.

        Four checks, all evaluated in one transaction, each failing with
        409 and its own named reason:

        - `replacement_not_ready`: the replacement must exist in the same
          account, differ from the recipient being deleted, be active
          (not disabled; not an unconfirmed email recipient), have
          `events` that are a superset of the recipient being deleted's,
          and — when the recipient being deleted is an account default —
          itself be a default too.
        - `recipient_not_active`: the recipient being deleted must itself
          be active. An inactive recipient already lost its live
          deliveries when it went inactive, so a guarded delete would
          prove nothing; use the plain `DELETE` instead.
        - `attachment_missing`: every monitor the recipient being deleted
          is attached to must have the replacement attached too, with the
          same `down_delay_s`.
        - `delivery_not_covered`: every live delivery of the recipient
          being deleted must have a non-null `enqueued_at` strictly after
          the replacement's `attached_at` on that monitor (see `GET
          /api/v1/monitors/{id}/recipients`), AND a same-transition
          sibling delivery for the replacement (same monitor, event type,
          and occurrence) whose status is `pending`, `retrying`, or
          `delivered`.

        **What a 204 guarantees, and what it doesn't.** The checks hold
        at the instant the delete commits — every removed delivery had a
        replacement copy that was delivered or still live at that
        instant. It does NOT mean the deleted recipient's destination
        goes quiet immediately: an attempt the delivery worker already
        claimed before the commit can still reach it once more, signed
        with its credentials, with no delivery-log row recording it — we
        recommend keeping the old destination (and, for a secret
        rotation, the old signing secret) accepting requests for at least
        five minutes after the 204. After the commit, the replacement is
        an ordinary recipient — a later edit to it (unsubscribe, delete,
        narrowed `events`, changed Slack/PagerDuty configuration) affects
        its own deliveries the same as for any recipient.

        **Drain recipe.** Before calling this with `replaced_by`: create
        and attach the replacement everywhere the original recipient is
        attached (same `events`, same `down_delay_s`, same default
        status), then scan
        `GET /api/v1/webhook-deliveries?recipient_id={id}&status=pending&status=retrying&order=id`
        until `next_cursor` is `null` and every returned row's
        `enqueued_at` is non-null and greater than the replacement's
        `attached_at` on its monitor. Until the guarded delete returns
        204, avoid detaching, unsubscribing, deleting (without
        `replaced_by`), or otherwise editing the original recipient in
        any way that removes or cancels its live deliveries — the guard
        can only see rows that still exist, and any of those actions
        empties the scan and lets a later guarded delete succeed with the
        alert already silently lost.
      security:
        - bearerAuth: []
      parameters:
        - name: replaced_by
          in: query
          description: |
            Opt into the guarded delete (issue #396). Presence, not value,
            selects the guarded path: including the parameter at all —
            even as `?replaced_by=` or a bare `?replaced_by` — routes the
            request through the guard, and its value must then parse as
            an integer different from the path `recipientID`; otherwise
            400. Omitting the parameter entirely keeps the plain,
            unguarded delete. The query string itself is parsed strictly:
            a malformed query string (e.g. an unescaped `;` as in
            `?replaced_by=9;foo=bar`) is rejected with 400 before
            `replaced_by`'s presence is even checked, so a malformed query
            can never be mistaken for an absent parameter and fall
            through to the unguarded delete.
          schema:
            type: integer
            format: int64
      responses:
        "204":
          description: Deleted
        "400":
          description: |
            The query string was malformed, or `replaced_by` was present
            but empty, not an integer, or equal to the path
            `recipientID`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Recipient not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Guarded delete only (`replaced_by` present) — one of the four
            checks failed. `error` is the failed check's name:
            `replacement_not_ready`, `recipient_not_active`,
            `attachment_missing`, or `delivery_not_covered`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/recipients/{recipientID}/confirm/resend:
    parameters:
      - name: recipientID
        in: path
        required: true
        schema:
          type: integer
          format: int64
    post:
      operationId: resendRecipientConfirmation
      summary: Resend an email recipient's confirmation message (issue #321)
      description: |
        `type=email` only. Two-phase: stages a new confirmation token,
        sends it, then activates on success — a transient send failure
        never invalidates a previously-mailed link still in the
        recipient's inbox. Rate-limited to 5 per address per hour,
        process-wide across accounts.
      security:
        - bearerAuth: []
      responses:
        "202":
          description: Confirmation resent
        "404":
          description: Recipient not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Recipient is already confirmed, OR the mail was sent but the
            token it carries is no longer live (a concurrent resend or
            address change superseded it, or the recipient was deleted,
            between sending and activation) — the caller should retry the
            resend.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Resent too recently (60s cooldown), or rate-limited
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Sending the confirmation message failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/recipients/{recipientID}/test:
    parameters:
      - name: recipientID
        in: path
        required: true
        schema:
          type: integer
          format: int64
    post:
      operationId: testRecipient
      summary: Send a synchronous test alert to a recipient (issue #321)
      description: |
        Dispatches a `[TEST]` alert (email), a signed
        `event: "monitor.test"` POST (webhook), an unsigned 🧪 TEST
        Block Kit message (slack, issue #359), or a PagerDuty Events API v2
        trigger-then-resolve pair (pagerduty, issue #363) directly —
        bypasses the `webhook_deliveries` outbox entirely, since there is
        no real monitor behind a test send. Rate-limited to 1 per
        recipient per minute. A `pending` or `disabled` recipient returns
        409. For `type=slack`, a non-2xx response — 3xx included — is
        reported as `status="failed"` with `response_code` and `error` set
        to Slack's own response body (e.g. `"no_service"`); only a
        transport failure returns the top-level 502. For `type=pagerduty`,
        the trigger (severity `info`) is followed by an immediate resolve
        under the identical `dedup_key`, ONLY if the trigger's response was
        2xx; a successful pair reports `status="accepted"` (never
        `"delivered"`, since PagerDuty's Events API is asynchronous and two
        2xx responses do not prove the test incident actually closed) with
        `message` carrying that caveat. Any failure — a non-2xx trigger, a
        transport error on either request, or a non-2xx resolve — reports
        `status="failed"`; every failure except a cleanly-rejected trigger
        also carries a warning in `error` that a test incident may remain
        open in PagerDuty. No PagerDuty response body is ever read or
        returned; `error`/`message` always come from fixed outcome text.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Test attempted (check `status` for the outcome)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecipientTestResult"
        "404":
          description: Recipient not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Recipient not active (pending or disabled)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: No response could be obtained from the destination
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RecipientTestResult"

  /email/confirm/{token}:
    parameters:
      - name: token
        in: path
        required: true
        schema:
          type: string
    get:
      operationId: confirmEmailRecipientPage
      security: []
      summary: Render the email-confirmation page (issue #321)
      description: |
        Public, unauthenticated. Renders a plain HTML page with a form
        that POSTs to this same URL. Never consumes the token — only
        `POST` does, so a link scanner or mail-client prefetcher that
        merely opens the link cannot burn the one-time confirmation.
      responses:
        "200":
          description: HTML page
          content:
            text/html: {}
        "429":
          description: Rate-limited (60/hour per caller IP)
          content:
            text/html: {}
    post:
      operationId: confirmEmailRecipient
      security: []
      summary: Confirm an email recipient (issue #321)
      description: |
        Public, unauthenticated. Consumes the 64-hex confirmation token.
        Unknown, expired, malformed, and already-consumed tokens all
        render the identical response, so a caller cannot distinguish any
        of those cases.
      responses:
        "200":
          description: Confirmed.
          content:
            text/html: {}
        "404":
          description: |
            Unknown, expired, malformed, or already-consumed token.
            Renders the identical not-found page in every case, so a
            caller cannot distinguish them.
          content:
            text/html: {}
        "429":
          description: Rate-limited (60/hour per caller IP; confirm and unsubscribe are tracked separately).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Store failure.
          content:
            text/html: {}

  /email/unsubscribe/{token}:
    parameters:
      - name: token
        in: path
        required: true
        schema:
          type: string
    get:
      operationId: unsubscribeRecipientPage
      security: []
      summary: Render the unsubscribe page (issue #321)
      description: |
        Public, unauthenticated. Renders a plain HTML page with a form
        that POSTs to this same URL. Never consumes the token.
      responses:
        "200":
          description: HTML page
          content:
            text/html: {}
        "429":
          description: Rate-limited (60/hour per caller IP)
          content:
            text/html: {}
    post:
      operationId: unsubscribeRecipient
      security: []
      summary: Unsubscribe an email recipient (issue #321)
      description: |
        Public, unauthenticated. Also the target of RFC 8058 one-click
        unsubscribe (`List-Unsubscribe-Post: List-Unsubscribe=One-Click`),
        which POSTs here with no human interaction — the token lives in
        the path, so no request body is required. Idempotent: a second
        POST still returns the success page. A well-formed token's own
        traffic never charges the rate limiter — RFC 8058 one-click
        requests for many unrelated recipients commonly share one
        mail-provider egress IP, and a genuinely valid unsubscribe is
        never throttled by that shared traffic alone. But the lookup is
        skipped, and this returns 429, if the caller's IP or the
        process-wide backstop is already exhausted by PRIOR invalid
        requests — checked before the store call — so a well-formed,
        genuinely valid token can still be temporarily rejected once that
        budget is already spent. Only a confirmed-invalid token (or
        malformed input, rejected up front) actually charges the limiter,
        60/hour per caller IP plus a process-wide backstop across every
        IP.
      responses:
        "200":
          description: Unsubscribed.
          content:
            text/html: {}
        "404":
          description: Unknown or malformed token. Renders the not-found page.
          content:
            text/html: {}
        "429":
          description: |
            Rate-limited — reachable for an unknown/malformed token, OR
            for a well-formed, genuinely valid token whose caller IP or
            the process-wide backstop is already exhausted by prior
            invalid attempts (checked before the store lookup). A valid
            token's own traffic never trips this, but it is not immune to
            an already-spent budget. Confirm and unsubscribe are tracked
            separately.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Store failure.
          content:
            text/html: {}

  /email/reports/{type}/unsubscribe/{token}:
    parameters:
      - name: type
        in: path
        required: true
        schema:
          type: string
          enum: [weekly, monthly]
      - name: token
        in: path
        required: true
        schema:
          type: string
    get:
      operationId: reportUnsubscribePage
      security: []
      summary: Render the report-unsubscribe page (issue #357)
      description: |
        Public, unauthenticated. Renders a plain HTML page with a form
        that POSTs to this same URL. Never consumes the token. An
        invalid `type` segment renders the identical not-found page a
        confirmed-invalid token would — the two are indistinguishable.
      responses:
        "200":
          description: HTML page
          content:
            text/html: {}
        "404":
          description: Invalid type segment.
          content:
            text/html: {}
        "429":
          description: Rate-limited (60/hour per caller IP; tracked separately from the recipient confirm/unsubscribe routes)
          content:
            text/html: {}
    post:
      operationId: reportUnsubscribe
      security: []
      summary: Unsubscribe from a weekly or monthly report (issue #357)
      description: |
        Public, unauthenticated. Also the target of RFC 8058 one-click
        unsubscribe. Idempotent: a second POST still returns the success
        page. Flips only the named `type`'s report preference on the one
        membership the token identifies — a weekly-report unsubscribe
        never touches the monthly preference, and vice versa. An invalid
        `type` or an unknown token both render the identical not-found
        page.
      responses:
        "200":
          description: Unsubscribed.
          content:
            text/html: {}
        "404":
          description: Invalid type, or unknown/malformed token. Renders the not-found page.
          content:
            text/html: {}
        "429":
          description: |
            Rate-limited — reachable for an invalid type, an
            unknown/malformed token, OR a well-formed, genuinely valid
            token/type pair whose caller IP or the process-wide backstop
            is already exhausted by prior invalid attempts (checked
            before the store lookup). A valid token/type pair's own
            traffic never trips this, but it is not immune to an
            already-spent budget. Tracked separately from the recipient
            confirm/unsubscribe routes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Store failure.
          content:
            text/html: {}

  /api/v1/monitors/{monitorID}/recipients:
    get:
      operationId: getMonitorRecipients
      summary: List recipient ids attached to a monitor
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
      responses:
        "200":
          description: Attached recipient ids (`[]` for none)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MonitorRecipients"
        "404":
          description: Monitor not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
    put:
      operationId: putMonitorRecipients
      summary: Replace the set of recipients attached to a monitor
      description: |
        Full replacement, not a merge. `recipient_ids` entries must be
        positive and unique (400 before any write, for either violation).
        An id not owned by the calling account returns 400 and writes
        nothing. Removing a recipient from the set cancels that pair's
        queued (`pending`/`retrying`) webhook_deliveries rows in the same
        transaction, not just future transitions — but this is a bound on
        enqueued state, not a guarantee about what has already left the
        wire: the same documented race as delivery supersede (see
        `docs/SPEC.md`'s outbox section) means an attempt the Worker already
        passed its liveness check for just before this transaction commits
        can still complete. Whether it is recorded in the delivery log
        depends on which update commits first: if this transaction's delete
        (scoped to `pending`/`retrying` rows) runs first, the row is gone
        and the Worker's later terminal-status update is a no-op, so the
        attempt is NOT recorded; if the Worker's terminal-status update
        lands first, the row is already `delivered` (or `failed`) and this
        transaction's delete — which only targets `pending`/`retrying` rows
        — leaves it in place, so the attempt IS recorded. Both outcomes are
        possible; this is not a guarantee either way. `{"recipient_ids":
        []}` detaches everything, after which `silent_monitors` on
        `GET /api/v1/account` counts this monitor if it is still enabled.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MonitorRecipients"
      responses:
        "200":
          description: The resulting attached set
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MonitorRecipients"
        "400":
          description: Bad request (invalid id, or an id not owned by this account)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Monitor not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/monitors/{monitorID}/uptime:
    get:
      operationId: getMonitorUptime
      summary: Per-day uptime grid for a monitor
      description: |
        Returns a 30-element array describing the monitor's uptime over
        the last 30 UTC days, ordered today-first. Each entry is computed
        from `monitor_uptime_day` (authoritative scheduled count per day)
        and from `down`-typed incidents that overlap that day.

        `pct` is `null` when `scheduled = 0` for that day — the dispatcher
        never tried to check the monitor (account suspended, kill switch
        on, monitor disabled, dispatcher down, monitor freshly created).
        The dashboard renders this as the grey/no-data state and must
        treat null as "no information", NOT as "0%". A `pct` value of `0`
        means the monitor was checked and was down for every second of
        the day.

        `pct` is normally derived purely from down-incident overlap, but
        is reconciled against `monitor_uptime_day.succeeded`: a day with
        zero successful checks is floored to `0` when corroborated as
        down — an unresolved down incident overlaps the day (any day),
        or the monitor's current resolved status is `down` and its
        current outage (the last down-transition) began before that day
        ended (only when the monitor is enabled and not currently
        flapping) — even if the `incidents` table has no row covering
        that day (a dropped runtime transition write). A currently-down
        monitor is floored across every day of its continuous outage,
        but never a day that predates the current down-period: the
        down-since timestamp bounds which days the live status describes.
        It is never floored when the monitor's resolved status is `up`,
        so a day where the primary region failed every check but
        cross-region confirmation kept the monitor up still reads its
        incident-derived percentage.

        `flapping` incidents are intentionally excluded from the downtime
        math — uptime semantics treat a flapping monitor as "answering,
        just bouncing".

        Each entry also carries a `latency` daily candle — open, high,
        low, close, and average total response time in whole
        milliseconds, built only from successful checks against the
        monitor's sticky primary region (wopher's `total_ms`: the full
        request round-trip, DNS through last byte of download). `latency`
        is `null` when the
        day has no successful samples to build a candle from — this
        includes days predating the feature, and is a distinct null
        condition from `pct` (which keys off `scheduled = 0`): a day can
        have a non-null `pct` (fully scheduled, down the entire day) and
        still carry a null `latency`. `avg` is the arithmetic mean of
        the day's successful-check response times. Latency history is
        free-tier for every account — no plan gating.

        v1 supports `window=30d` only. The 30-day cap is a visibility and
        API limit, independent of the 95-day physical retention window for
        incidents and `monitor_uptime_day` (see internal/retention/
        DECISIONS.md). Wider or narrower windows are out of scope for v1.
      tags: [Monitors]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
        - name: window
          in: query
          required: false
          schema:
            type: string
            enum: [30d]
            default: 30d
          description: Window length. Only `30d` is accepted in v1.
      responses:
        "200":
          description: 30-element array of per-day uptime entries, today-first.
          content:
            application/json:
              schema:
                type: array
                minItems: 30
                maxItems: 30
                items:
                  $ref: "#/components/schemas/UptimeDay"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "404":
          description: Monitor not found or belongs to another account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/monitors/{monitorID}/status-page:
    get:
      operationId: getMonitorStatusPage
      summary: Get the monitor's free status-page switch state (issue #368)
      tags: [Monitors]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
      responses:
        "200":
          description: Current switch state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusPageSettings"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "404":
          description: Monitor not found or belongs to another account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    put:
      operationId: setMonitorStatusPage
      summary: Turn the monitor's free status page on or off (issue #368)
      description: |
        Owner role only — publishing a public URL is an owner decision,
        the same gate as `/account/feed-token`. Repeating `{"enabled":true}`
        on an already-on page returns the same URL. `{"enabled":false}`
        revokes the URL immediately at the origin (a caching CDN in front
        of the status host may still serve the old page for up to 60
        seconds — see `STATUS_PAGE_URL`/`Free Status Page` in SPEC.md).
        Turning it back on mints a different token. The body must be
        exactly `{"enabled": <bool>}` — a missing or null `enabled`, a
        non-boolean value, or any other key is `400` and leaves the
        stored token unchanged.
      tags: [Monitors]
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              additionalProperties: false
              properties:
                enabled:
                  type: boolean
      responses:
        "200":
          description: Updated switch state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusPageSettings"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Caller is not an owner, or the account is suspended.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Monitor not found or belongs to another account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"

  /api/v1/monitors/{monitorID}/test:
    post:
      operationId: testMonitor
      summary: Dispatch an ad-hoc check from the probe fleet
      description: |
        Runs one immediate check against the monitor's configured target. Does not
        modify monitor state, evidence buffer, or incidents. Only monitors of a
        type the check engine currently supports (`http`, `ping`, `tcp`) can be
        tested; a monitor of any other type returns 400 — unreachable in
        production after issue #209, since no such monitor can be created or
        persisted, but preserved as a defensive check. Rate limited to 1 call
        per monitor per 10 seconds.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/monitorID"
        - name: region
          in: query
          required: false
          schema:
            type: string
            enum: [EU, US-E, US-W, ASIA, OCE, SA]
          description: Probe region. Defaults to a random available region.
      responses:
        "200":
          description: Check completed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TestResult"
        "400":
          description: |
            Unsupported monitor type or invalid region. The unsupported-type
            body is byte-identical to `POST`/`PUT /api/v1/monitors`'s:
            `type '<type>' is not available yet; the check engine currently
            supports: http, ping, tcp`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Target URL resolves to a blocked address (SSRF)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Monitor not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: |
            One of three independent rate limiters fired:

            * **Per-monitor minimum-interval limiter (`MonitorTestLimiter`)** —
              another test for the same monitor ran too recently.
              Body: `too many tests for this monitor; retry in <N>s`.
            * **Per-account dispatcher cap (`AccountRateLimiter`)** — the
              authenticated account exceeded its 200/min cap on the
              authenticated `/api/v1/*` surface. See
              `components.responses.RateLimited`.
            * **Per-domain dispatch cap (`DomainLimiter`)** — too many
              checks have been dispatched to this monitor's eTLD+1 across
              ALL accounts within the rolling window. The cap protects
              third-party sites from coordinated-monitoring overload (e.g.
              N free-tier accounts each pointing a monitor at the same
              victim). Body:
              `domain rate limit exceeded; retry in <N>s`. The bucket is
              keyed on the public-suffix-trimmed apex
              (`api.victim.com` and `www.victim.com` share one bucket).

            The `Retry-After` header gives the integer seconds until the
            next allowed dispatch under whichever limiter fired. Scales
            differ: per-monitor (~tens of seconds), per-account
            (sub-second to seconds), per-domain (up to one minute).
          headers:
            Retry-After:
              schema:
                type: integer
              description: |
                Seconds until the next allowed test under whichever
                limiter rejected the call.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                perMonitor:
                  summary: Per-monitor minimum-interval limiter
                  value:
                    error: too many tests for this monitor; retry in 8s
                perDomain:
                  summary: Per-domain cross-account limiter
                  value:
                    error: domain rate limit exceeded; retry in 42s
        "502":
          description: No healthy probe or probe-level error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /api/v1/webhook-deliveries:
    get:
      operationId: listWebhookDeliveries
      summary: List recent webhook delivery attempts for the authenticated account
      description: |
        Two independent response shapes, selected by `?order=`:

        - Default (`order` key absent entirely): offset mode. `created_at`
          descending, `?limit=`/`?offset=`, `WebhookDeliveryListResponse`.
          Unchanged since before issue #396 apart from the added
          `enqueued_at` field on each delivery. A present `order` key —
          even an empty `?order=` or a bare `?order` — never falls back
          to this mode; see below.
        - `order=id`: cursor mode (issue #396). `id` ascending,
          keyset-paginated via `?cursor=`, `WebhookDeliveryCursorListResponse`.
          Any `?offset=` (any value, including `0`) is rejected with 400.
          A malformed or empty `?cursor=` is rejected with 400 — presence
          of the parameter is checked independently of its value, so a
          present-but-empty `?order=id&cursor=` is rejected the same as a
          malformed one, not treated as "no cursor". `?cursor=` without
          `order=id` is also rejected with 400, empty value included — a
          cursor only has meaning in keyset order. `order` itself must
          be present exactly once with the value `id`; a present but
          empty `?order=`, a bare `?order`, a repeated `order=` key, or
          any other value is rejected with 400 rather than silently
          running offset mode. A malformed query string itself (e.g. an
          unescaped `;`) is rejected with 400 before any parameter is
          inspected.

        `?recipient_id=` filters to one recipient's own deliveries
        (`webhook_deliveries.webhook_id`), still scoped to the caller's
        account. `?status=` is repeatable (OR-union) — a single
        `status=x` behaves exactly as before issue #396.

        **Recipient-queue drain recipe.** To prove a recipient's
        pre-attachment backlog is drained before deleting it (e.g.
        rotating to a replacement recipient via
        `DELETE /api/v1/recipients/{id}?replaced_by=`): scan
        `?recipient_id={id}&status=pending&status=retrying&order=id`,
        following `next_cursor` until it is `null`. Only an empty scan
        result proves the queue is literally empty; any row the scan
        returns proves the opposite, since the status filter selects
        live rows by construction. The backlog is considered drained
        once every returned row's `enqueued_at` is non-null and greater
        than the replacement's `attached_at` on every monitor
        (`GET /api/v1/monitors/{id}/recipients`'s `attached_at` field) —
        but that timestamp comparison alone does not prove sibling
        coverage (that each row has a counterpart delivery enqueued for
        the replacement); it only establishes that a live row postdates
        the replacement's attachment. `DELETE /api/v1/recipients/{recipientID}`
        is the authoritative check for a safe rotation — it verifies
        sibling coverage server-side, in one transaction, rather than
        trusting a client-computed timestamp comparison. See that
        endpoint for the full recipe and its guarantees/limits.
      security:
        - bearerAuth: []
      parameters:
        - name: monitor_id
          in: query
          schema:
            type: integer
            format: int64
        - name: recipient_id
          in: query
          description: Filter to one recipient's deliveries (issue #396).
          schema:
            type: integer
            format: int64
        - name: status
          in: query
          style: form
          explode: true
          description: |
            Repeatable (issue #396): `status=pending&status=retrying`
            returns the OR-union. A single `status=x` behaves exactly as
            on master.
          schema:
            type: array
            items:
              type: string
              enum: [pending, retrying, delivered, failed, superseded, cancelled]
        - name: since
          in: query
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: offset
          in: query
          description: |
            Offset-mode only. Rejected with 400 (any value, including
            `0`) when combined with `order=id`.
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: order
          in: query
          description: |
            Omit for the default offset-mode shape. `id` switches to
            cursor mode (issue #396). When present, the parameter must
            appear exactly once with the value `id` — an empty
            `?order=`, a bare `?order`, a repeated `order=` key, or any
            other value is rejected with 400 rather than silently
            falling back to offset mode.
          schema:
            type: string
            enum: [id]
        - name: cursor
          in: query
          description: |
            Cursor-mode only (issue #396) — the opaque `next_cursor` value
            from a previous page. Requires `order=id`.
          schema:
            type: string
      responses:
        "200":
          description: Deliveries
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/WebhookDeliveryListResponse"
                  - $ref: "#/components/schemas/WebhookDeliveryCursorListResponse"
        "400":
          description: |
            Bad request — an unknown `status` value; an `order` that is
            empty, repeated, or anything other than `id` (including a
            bare `?order`); a malformed `cursor`; `cursor` supplied
            without `order=id`; or `offset` supplied together with
            `order=id`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /feed/{token}:
    get:
      summary: Public JSON incident feed (unauthenticated, token-gated)
      security: []
      description: |
        Returns a trimmed view of recent incidents for the account owning this
        token. No sensitive configuration is exposed. Rate-limited at 20
        requests per minute per token.
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
          description: Feed token (prefix `fd_`).
      responses:
        "200":
          description: Feed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedResponse"
        "403":
          description: Owning account is suspended
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Unknown or rotated token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limit exceeded
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the current window ends.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /feed.xml:
    get:
      summary: Public platform-news Atom feed (unauthenticated)
      security: []
      description: |
        Public Atom XML feed rendering currently active platform announcements
        from the shared `announcements` table. No `Authorization` header is
        required. The feed contains at most 50 entries, ordered descending
        by `published_at` (with a stable secondary `id` tiebreaker).

        Active means `published_at <= now()` AND (`expires_at IS NULL` OR
        `expires_at > now()`). Empty active sets still return a valid Atom
        envelope with zero `<entry>` rows so aggregator-cache content hashes
        stay stable.

        Cache-Control is `public, max-age=300` — Cloudflare in front of the
        API will respect the hint. Announcements are not time-critical, so
        a 5-minute window is acceptable.

        `<updated>` semantics: the feed-level `<updated>` is a best-effort
        change marker, not a linearizable version counter. It is the
        GREATEST of four signals — an edit to any announcement (including
        one that unpublishes a row by pushing `published_at` into the
        future), a scheduled announcement crossing into visibility, an
        active announcement crossing into expiry, or a hard delete — and
        it never exceeds the current time. Writes to *different*
        announcement rows take no shared lock, so two concurrent edits can
        commit in an order where the marker does not advance past a value
        a reader already observed even though a new change became visible
        in that window. Treat `<updated>` as a "poll again soon" hint, not
        a guarantee that an unchanged value means nothing changed since
        your last read. Per-entry `<updated>` is the later of that
        announcement's last edit and its `published_at`, clamped so it
        never exceeds the current time — a just-published scheduled
        announcement reads `updated == published` rather than
        updated-before-published, and a row whose last-edit timestamp
        is ahead of real time (clock skew, or accumulated
        strict-advance drift) reads the current time instead of that
        future value. Timestamps use full sub-second precision (RFC
        3339 with fractional seconds) so edits within the same
        wall-clock second still render distinct values.
      responses:
        "200":
          description: Atom feed
          headers:
            Cache-Control:
              schema:
                type: string
              description: Always `public, max-age=300`.
          content:
            application/atom+xml:
              schema:
                type: string
        "500":
          description: Database error rendering the feed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /feed/{token}/rss:
    get:
      summary: Public Atom/RSS incident feed (unauthenticated, token-gated)
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Atom feed
          content:
            application/atom+xml:
              schema:
                type: string
        "404":
          description: Unknown or rotated token
        "429":
          description: Rate limit exceeded
          headers:
            Retry-After:
              schema:
                type: integer

  /api/v1/account/feed-token:
    get:
      summary: Get the current feed token for the authenticated account
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedTokenResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/account/feed-token/rotate:
    post:
      summary: Rotate the feed token (old token stops working immediately)
      security:
        - bearerAuth: []
      responses:
        "200":
          description: New token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedTokenResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/events/ticket:
    post:
      operationId: issueSSETicket
      summary: Issue a single-use SSE connection ticket
      description: |
        Mints a short-lived ticket that the dashboard exchanges for an SSE
        connection at `GET /api/v1/events/stream`. Requires Bearer auth.
        Ticket TTL is 30 seconds; tickets are single-use.
        Rate limited to 20 ticket mints per minute per credential (issue
        #339 — an API key is one credential shared by every caller who
        presents it; a session/OAuth-JWT caller is bucketed per user, not
        per account).
      tags: [Events]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Ticket issued.
          content:
            application/json:
              schema:
                type: object
                required: [ticket, expires_in]
                properties:
                  ticket:
                    type: string
                    description: Opaque single-use token, base64url encoded.
                  expires_in:
                    type: integer
                    description: Seconds until the ticket expires.
                    example: 30
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          description: |
            Per-credential ticket-mint rate limit exceeded (20
            requests/minute, fixed window, keyed the same way as the
            connection cap above — not per account). Distinct from the
            dispatcher-wide 200/min limit (`components.responses.RateLimited`)
            — this limiter only counts ticket mints. The `Retry-After`
            header gives the integer seconds until the oldest mint slides
            out of the window.
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the next ticket mint is allowed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: ticket rate limit exceeded

  /api/v1/events/stream:
    get:
      operationId: subscribeSSEStream
      summary: Subscribe to the live event stream (SSE)
      description: |
        Consumes a ticket from `POST /api/v1/events/ticket` and upgrades the
        HTTP response to an SSE stream (`text/event-stream`). Each frame is a
        JSON envelope with `type`, `monitor_id`, `occurred_at`, and a
        type-specific `data` payload. See `docs/SSE_LIVE_UPDATES_SPEC.md` for
        the four event types and their payloads. Heartbeat comments
        (`: heartbeat`) are emitted every 25 seconds.

        **Credential re-checked at open AND periodically while connected
        (issue #339).** A ticket's bound credential — the account, the
        minting user, and the API key if one was presented — is re-verified
        against durable storage both when the ticket is redeemed and, once
        the stream is open, every ~30 seconds thereafter. Removing the
        minting user from the account, revoking the API key past its grace
        window, or suspending the account closes an already-open stream
        within roughly 45 seconds on a healthy connection (a 30s recheck
        interval + 5s check timeout detects the revocation and cancels the
        stream in ~35s, plus up to a further 10s write deadline for an
        in-flight write/flush to unblock and the stream to actually close)
        — not instantly, and not a hard worst case: the write deadline is
        armed only for the duration of a write+flush and cleared between
        frames, so it bounds the handler's own blocking, not a stalled
        HTTP/2 connection writer. A transient failure (e.g. a database
        hiccup) never closes an open stream on its own; it only delays how
        soon a genuine revocation takes effect.
      tags: [Events]
      security: []
      parameters:
        - name: ticket
          in: query
          required: true
          schema:
            type: string
          description: Single-use ticket from `POST /api/v1/events/ticket`.
      responses:
        "200":
          description: SSE stream opened.
          content:
            text/event-stream:
              schema:
                type: string
                description: Server-Sent Events stream.
        "401":
          description: |
            Missing, invalid, expired, or already-consumed ticket — or a
            ticket whose bound API key has been revoked. A ticket refused
            here for a revoked key is also consumed (burned): the same
            ticket value cannot be retried even within its remaining TTL.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: |
            The ticket's bound user is no longer a member of the account,
            or the account is suspended. Also consumes (burns) the
            ticket — it cannot be retried.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: |
            Max concurrent SSE connections per credential reached (10 per
            credential — an API key, or a user for session/OAuth-JWT
            auth). This is a connection-cap rejection, not a rate-window
            rejection, so `Retry-After` is intentionally **not** set:
            retry depends on a peer disconnecting (no server-side clock
            event), and a guessed value would mislead clients into tight
            retry loops. Each API key has its own independent 10-slot
            budget shared across every caller who presents it; each
            session/OAuth-JWT user has their own independent 10-slot
            budget on the account, separate from every other user on that
            account. **This budget is shared with MCP GET `/mcp`
            streams** — REST event streams and MCP streams draw from the
            same per-credential counter, so a credential with zero open
            MCP streams can still hit this 429 if its REST streams (or
            vice versa) hold every slot. This 429's body text is shared
            byte-for-byte with the MCP refusal. This route is mounted
            outside the `/api/v1/*` Bearer block, so the dispatcher-wide
            200/min limiter (`components.responses.RateLimited`) does not
            apply here — the connection cap is the only 429 source. A
            429 here does NOT consume the ticket — retry with the same
            one once a slot frees.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/push/subscribe:
    post:
      operationId: subscribeBrowserPush
      summary: Register a browser Web Push subscription
      description: |
        Persists the browser's `PushSubscription` object so the dispatcher
        can deliver notifications on monitor state transitions. The
        `p256dh` and `auth` fields are write-only secrets used server-side
        to encrypt push payloads — they are NEVER returned in the
        response. The 64 KB body limit and the global per-account
        200/min rate limiter both apply. Per-account cap is enforced
        atomically: an account holds **exactly 20** active subscriptions
        at steady state — concurrent subscribes never produce a 21st
        row. The 21st insert evicts the oldest by `created_at` (FIFO).
        The `endpoint` must be an `https://` URL with a public hostname;
        private/loopback/link-local/mDNS endpoints are rejected with
        `400 {"error":"endpoint must be HTTPS"}` (SSRF hardening).
      tags: [Push]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PushSubscriptionRequest"
      responses:
        "201":
          description: Subscription stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PushSubscriptionResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "413":
          $ref: "#/components/responses/RequestEntityTooLarge"
        "415":
          $ref: "#/components/responses/UnsupportedMediaType"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/push/subscribe/{subscriptionID}:
    delete:
      operationId: unsubscribeBrowserPush
      summary: Revoke a browser Web Push subscription
      description: |
        Removes the subscription if and only if it belongs to the calling
        account. A subscription that exists but belongs to another
        account is indistinguishable from one that does not exist —
        both return 404. This is intentional: the API does not leak
        cross-account row existence.
      tags: [Push]
      security:
        - bearerAuth: []
      parameters:
        - name: subscriptionID
          in: path
          required: true
          schema:
            type: integer
            format: int64
          description: Push subscription ID.
      responses:
        "204":
          description: Subscription deleted.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "404":
          description: No subscription matched (or it belongs to another account).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/push/subscriptions:
    get:
      operationId: listBrowserPushSubscriptions
      summary: List the calling account's active push subscriptions
      description: |
        Returns the calling account's active push subscriptions newest
        first. The `p256dh` and `auth` secrets are NEVER included in
        the response. Returns `[]` (not `null`) when the account has
        no subscriptions.
      tags: [Push]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Subscriptions list.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/PushSubscriptionResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/admin/ping:
    get:
      operationId: adminPing
      summary: Smoke-check the admin authentication gate
      description: |
        Returns the AUTHENTICATED CALLER's own GitHub identity along with
        `admin: true` when (and only when) that caller (AuthInfo.UserID,
        joined to `admins` via `users.github_id`) has a row in the
        `admins` table (keyed on the immutable GitHub numeric ID, see
        migration 018). Used to confirm the admin gate is wired and
        working — the same `auth.RequireAdmin` middleware composes
        future admin-only routes (e.g., announcement CRUD, admin SSE).
        Team accounts B1 (issue #323) made non-owner admins possible, so
        this is NOT necessarily the identity of the account the request
        happens to be scoped to — a viewer/member admin inspecting
        another user's account sees their own identity here.

        Responses split cleanly:
        - **200** — caller is admin; payload includes the caller's own
          `github_id`/`github_username` (both `null` if the caller has
          no GitHub identity), and `admin: true`.
        - **401** — produced by the upstream Bearer middleware when the
          request has no `Authorization` header or the token is
          invalid.
        - **403** — opaque `{"error":"forbidden"}` when the request is
          authenticated but the account is not in the admins table.
          Same envelope on every non-admin path: do not leak whether
          the route requires admin or whether the caller happens to
          not be one.
        - **429** — the global per-account rate limiter still applies
          to admin routes (200/min cap shared with the rest of
          `/api/v1`).

        Admin membership is sourced from the immutable numeric GitHub
        ID (`accounts.github_id`), not the mutable username. A renamed
        admin retains access; a non-admin who happens to claim a freed
        username does not inherit it.
      tags: [Admin]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Caller is an admin.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminPingResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Authenticated but not an admin. Body is the opaque
            `{"error":"forbidden"}` envelope; same shape used for
            every admin-gated 403 across the API.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: |
            Lookup against the `admins` table failed. The middleware
            never fails open: a transient DB error returns 500 rather
            than letting a non-admin reach an admin-only handler.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/admin/kill-switch:
    get:
      operationId: adminKillSwitchGet
      summary: Read the global dispatcher kill-switch state
      description: |
        Returns the watcher's current `Active()` reading and the
        configured sentinel file path. Requires admin role per the
        same `auth.RequireAdmin` middleware that gates
        `/api/v1/admin/ping` (#18).

        **Lag note.** The `active` field reflects the watcher's
        in-memory state, which is updated on a 5 s poll cadence. An
        out-of-band file `touch`/`rm` issued via host shell may take
        up to 5 s to propagate. REST-driven flips (POST/DELETE on
        this same path) propagate within milliseconds because the
        handler bypasses the poll and updates after writing the
        file.

        Returns 503 `{"error":"kill switch not configured"}` when
        the dispatcher is running with `KILL_SWITCH_FILE` unset
        (the watcher is the no-op `Disabled` service).
      tags: [Admin]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Current kill-switch state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KillSwitchStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Authenticated but not an admin.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Kill switch not configured (`KILL_SWITCH_FILE` unset).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      operationId: adminKillSwitchEngage
      summary: Engage the global dispatcher kill switch
      description: |
        Creates the sentinel file at `KILL_SWITCH_FILE` (requested
        mode `0644`, masked by the dispatcher's umask) when it does
        not already exist. An existing sentinel of any kind (regular
        file of any mode or content, directory, FIFO, socket, or a
        symlink to one) is left untouched: the handler never opens
        or truncates it, so a retry cannot fail on a read-only or
        non-regular sentinel. Symlinks are followed, so
        `KILL_SWITCH_FILE` and every directory on its resolved chain
        must be writable only by trusted operators. Once observed by
        the watcher, every wopher dispatch is gated off and
        `POST /api/v1/monitors/{id}/test` returns 503. Webhook
        delivery, the DNS re-resolver, evidence flush, and read APIs
        continue to run.

        Requires admin role per #18.

        **Idempotent.** Repeat calls return 200 with the same
        body whether or not the sentinel file pre-existed; this
        endpoint never returns 409. Operators in an emergency
        want "make it engaged" semantics; a 409 on a benign retry
        is hostile.

        When the handler's own check of the sentinel file, made
        before it touches the file, finds the file absent, and the
        file operation completes without a fatal error, the
        dispatcher fires the `kill_switch_flipped` webhook (when both
        `SECURITY_ALERT_WEBHOOK_URL` and
        `SECURITY_ALERT_WEBHOOK_SECRET` are configured) with `actor`
        set to the calling admin's GitHub username. A URL configured
        without a secret disables delivery entirely — the dispatcher
        logs a `[security:webhook_disabled]` warning at startup and
        sends nothing on this or any other security-alert event.
        That check is not synchronized with other writers, so the
        actor-attributed alert reflects what the check saw, not
        whether this call changed the file: concurrent calls, or an
        out-of-band `touch`/`rm` between the check and the write, can
        make the two differ. A call whose check finds the file
        already present writes nothing and fires no actor-attributed
        alert, but still runs the watcher confirmation step; if that
        confirmation is the first to observe an out-of-band change
        made since the watcher's last poll, the watcher's own
        notification path can emit an alert with no `actor`.
        The handler makes a best-effort attempt to suppress the
        watcher's own poll-loop alert for the same flip via a
        one-shot flag; concurrent REST flips can race that flag
        and produce two alerts for one transition, and a failed
        sentinel-file write can leave it armed and suppress an
        unrelated later alert. Webhook delivery for this event is
        not exactly-once.

        The response's `active` field is the state the watcher
        actually confirmed after the file write, not necessarily
        the requested one: if the post-write confirmation hits a
        filesystem error, the write already happened but the
        in-memory state could not be confirmed, and the response
        is `503` reporting the last known state instead of `200`
        with a possibly-wrong `active:true`. The `kill_switch_flipped`
        webhook still fires in that case, with `active:true` (the
        requested/written state) — the file write is the real event,
        and withholding the alert would mean a completed engage could
        go unreported (the suppressed watcher poll won't fire it
        either, and a client retry afterward is a no-op).

        Returns 503 `{"error":"kill switch not configured"}` when
        `KILL_SWITCH_FILE` is unset.
      tags: [Admin]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Kill switch is engaged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KillSwitchStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Authenticated but not an admin.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: |
            Either a stat of the sentinel path failed for a reason
            other than it being missing (e.g. permission denied on
            a parent directory, a symlink loop), or creating a
            missing sentinel failed (e.g. dispatcher process lacks
            write permission to the sentinel directory). Operators
            should resolve the host-side permissions and retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: |
            Either `KILL_SWITCH_FILE` is unset
            (`{"error":"kill switch not configured"}`), or the
            sentinel file write succeeded but the watcher could
            not confirm the new state afterward (a filesystem
            error naming the failure and the last known `active`
            value it fell back to).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      operationId: adminKillSwitchDisengage
      summary: Disengage the global dispatcher kill switch
      description: |
        Removes the sentinel file at `KILL_SWITCH_FILE`. Once
        observed by the watcher, dispatch resumes.

        Requires admin role per #18.

        **Idempotent.** A missing sentinel file (ENOENT) is
        treated as success; this endpoint returns 200, never 404.

        When the handler's own check of the sentinel file, made
        before it touches the file, finds the file present, and the
        file operation completes without a fatal error, the
        dispatcher fires the `kill_switch_flipped` webhook (when both
        `SECURITY_ALERT_WEBHOOK_URL` and
        `SECURITY_ALERT_WEBHOOK_SECRET` are configured) with `actor`
        set to the calling admin's GitHub username. A URL configured
        without a secret disables delivery entirely — the dispatcher
        logs a `[security:webhook_disabled]` warning at startup and
        sends nothing on this or any other security-alert event.
        That check is not synchronized with other writers, so the
        actor-attributed alert reflects what the check saw, not
        whether this call changed the file: concurrent calls, or an
        out-of-band `touch`/`rm` between the check and the removal,
        can make the two differ. A call whose check finds the file
        already absent fires no actor-attributed alert, but still
        runs the watcher confirmation step; if that confirmation is
        the first to observe an out-of-band change made since the
        watcher's last poll, the watcher's own notification path can
        emit an alert with no `actor`. Watcher poll-loop suppression
        matches the engage path.

        The response's `active` field is the state the watcher
        actually confirmed after the file removal, not necessarily
        the requested one: if the post-removal confirmation hits a
        filesystem error, the removal already happened but the
        in-memory state could not be confirmed, and the response
        is `503` reporting the last known state instead of `200`
        with a possibly-wrong `active:false`. The `kill_switch_flipped`
        webhook still fires in that case, with `active:false` (the
        requested/removed state) — the file removal is the real event,
        and withholding the alert would mean a completed disengage could
        go unreported (the suppressed watcher poll won't fire it
        either, and a client retry afterward is a no-op).

        Returns 503 `{"error":"kill switch not configured"}` when
        `KILL_SWITCH_FILE` is unset.
      tags: [Admin]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Kill switch is disengaged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KillSwitchStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Authenticated but not an admin.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: |
            Filesystem remove failed for a reason other than
            ENOENT.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: |
            Either `KILL_SWITCH_FILE` is unset
            (`{"error":"kill switch not configured"}`), or the
            sentinel file removal succeeded but the watcher could
            not confirm the new state afterward (a filesystem
            error naming the failure and the last known `active`
            value it fell back to).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/admin/scheduler/transition-drops:
    get:
      operationId: adminSchedulerTransitionDrops
      summary: Read the scheduler's lossy transition-queue counters (admin only)
      description: |
        Returns three cumulative, process-lifetime counters tracking
        the scheduler's internal transition-enqueue path (issue #103),
        plus the timestamp the counters started from. Requires admin
        role per the same `auth.RequireAdmin` middleware that gates
        `/api/v1/admin/ping`.

        As of issue #377's lossless, retried transition queue, `overflow`
        and `flap_exit_deferred` are retained for wire compatibility only
        and always read zero — the shard-full and flap-exit-deferred
        conditions they used to count no longer exist (the per-shard
        queue is unbounded, and enqueuing a transition never fails).
        `writer_error` now counts only a PERMANENT RecordStateTransition
        failure (a data-exception or integrity-constraint-violation
        SQLSTATE, or an invalid revision) — a transient failure, including
        an ambiguous commit outcome, is retried against the same job
        instead of being dropped, so it never increments this counter.

        The dispatcher additionally fires a `scheduler_transition_dropped`
        security-rail alert (see `SECURITY_ALERT_WEBHOOK_URL` in
        `CLAUDE.md`) for `writer_error`, rate-limited to at most one
        alert per rolling 10-minute window — a burst lasting longer than
        10 minutes fires again once that window elapses. Each alert's
        payload carries the three counters' process-lifetime totals as
        of the triggering occurrence, not the size of any completed
        burst. This endpoint is the read-only complement for ad hoc
        polling or dashboards, not a substitute for that alert.

        Counters reset to zero only on dispatcher restart; `since`
        reports the process instant they started counting from, not a
        rolling window.
      tags: [Admin]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Current transition-drop counters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TransitionDropsStatus"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Authenticated but not an admin.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/admin/events/ticket:
    post:
      operationId: issueAdminSSETicket
      summary: Issue a single-use admin aggregate SSE ticket
      description: |
        Mints a short-lived ticket scoped to the back-office aggregate
        SSE stream at `GET /api/v1/admin/events/stream` (the dot-grid
        live feed of every monitor across every account). Requires
        Bearer auth AND admin role per `auth.RequireAdmin`. Tickets
        minted here are stored in a separate ticket store from the
        per-account `/api/v1/events/ticket`; an admin ticket presented
        to `/api/v1/events/stream` returns 401 and vice versa.

        Ticket TTL is 30 seconds, single-use. Per-admin mint rate is 10
        per minute (fixed window) — half the per-account rate, since
        admins reconnect rarely. See `docs/SSE_LIVE_UPDATES_SPEC.md` §15.
      tags: [Admin, Events]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Admin ticket issued.
          content:
            application/json:
              schema:
                type: object
                required: [ticket, expires_in]
                properties:
                  ticket:
                    type: string
                    description: Opaque single-use admin token, base64url encoded.
                  expires_in:
                    type: integer
                    description: Seconds until the ticket expires.
                    example: 30
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          description: |
            Per-admin ticket-mint rate limit exceeded (10 requests/minute,
            fixed window). The `Retry-After` header gives the integer
            seconds until the oldest mint slides out of the window.
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds until the next ticket mint is allowed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: ticket rate limit exceeded
        "503":
          description: Admin events surface not configured on this dispatcher.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/admin/events/stream:
    get:
      operationId: subscribeAdminSSEStream
      summary: Subscribe to the admin aggregate live event stream (SSE)
      description: |
        Consumes a ticket from `POST /api/v1/admin/events/ticket` and
        upgrades the response to an SSE stream that delivers every
        event published for every account. Used by the back-office
        dot-grid dashboard at `app.uptimemonitoring.com/ops` (SPEC
        §Back-Office Dashboard).

        Each frame carries the same envelope as
        `/api/v1/events/stream` (see `docs/SSE_LIVE_UPDATES_SPEC.md`
        §6 + §7); `account_id` identifies the originating account.
        Heartbeat comments (`: heartbeat`) are emitted every 25 s.

        Admin membership is re-checked at stream-open time against
        the durable `admins` table — a row deleted in the 0-30s
        ticket-redeem window cannot keep a long-lived connection
        alive. **Also re-checked periodically while connected (issue
        #339):** once open, the same account/membership/key check plus
        the admin-lookup check both re-run every ~30 seconds. Removing
        the minting admin's account membership, revoking their API key,
        suspending the account, or deleting their `admins` row closes
        an already-open admin stream within roughly 45 seconds on a
        healthy connection (detected within ~35 seconds by the recheck,
        plus up to a further 10s write deadline for an in-flight
        write/flush to unblock and the stream to actually close) — not
        instantly, not a hard worst case (the write deadline is armed
        only for the duration of a write+flush and cleared between
        frames, so a stalled HTTP/2 connection writer is not bounded by
        it), and never due to a merely transient error (e.g. a database
        hiccup), which only delays how soon a genuine revocation takes
        effect.
      tags: [Admin, Events]
      security: []
      parameters:
        - name: ticket
          in: query
          required: true
          schema:
            type: string
          description: Single-use admin ticket from `POST /api/v1/admin/events/ticket`.
      responses:
        "200":
          description: Admin SSE stream opened.
          content:
            text/event-stream:
              schema:
                type: string
                description: Server-Sent Events stream, multiplexed across all accounts.
        "401":
          description: |
            Missing/invalid/expired admin ticket; OR admin membership
            was revoked between mint and redeem; OR admin status
            (the `admins` row) was revoked; OR a per-account ticket was
            presented to this admin route. A ticket refused for any of
            the DEFINITE reasons above (not "missing/invalid/expired")
            is also consumed (burned) — it cannot be retried.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          description: |
            Max concurrent admin SSE connections reached (5 per
            admin). Connection-cap rejection, not a rate-window
            rejection — `Retry-After` is intentionally not set. Does
            NOT consume the ticket — retry with the same one once a
            slot frees.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Admin events surface not configured on this dispatcher.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/admin/monitors/state:
    get:
      operationId: adminMonitorsState
      summary: Get a packed snapshot of all monitor states (admin only)
      description: |
        Returns a packed snapshot of the current in-memory state for every
        monitor across every account. The response uses a compact columnar
        encoding to minimise payload size over the wire: `states` is the
        ordered string palette of status values, and each `monitors` tuple
        is `[monitorID, accountID, stateIndex]` where `stateIndex` is a
        zero-based index into `states`.

        Requires admin auth via the same `auth.RequireAdmin` middleware that
        gates `/api/v1/admin/ping`.
      tags: [Admin]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Packed monitor state snapshot.
          content:
            application/json:
              schema:
                type: object
                required: [schema, states, monitors]
                properties:
                  schema:
                    type: string
                    description: Encoding version identifier.
                    example: v1-packed
                  states:
                    type: array
                    description: Ordered palette of status strings referenced by monitors tuples.
                    items:
                      type: string
                    example: ["unknown", "up", "down", "paused", "flapping"]
                  monitors:
                    type: array
                    description: >
                      Array of 3-element tuples: [monitorID, accountID, stateIndex].
                      stateIndex is a zero-based index into the states palette.
                    items:
                      type: array
                      items:
                        type: integer
                        format: int64
                      minItems: 3
                      maxItems: 3
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Authenticated but not an admin.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/admin/monitors/{accountID}/{monitorID}/meta:
    get:
      operationId: adminMonitorMeta
      summary: Get metadata for a single monitor (admin only)
      description: |
        Returns basic metadata for the given monitor: display name, check
        URL, current status string, and whether the monitor is currently
        flapping. Intended for the back-office ops grid detail panel.

        Requires admin auth via the same `auth.RequireAdmin` middleware that
        gates `/api/v1/admin/ping`.
      tags: [Admin]
      security:
        - bearerAuth: []
      parameters:
        - name: accountID
          in: path
          required: true
          schema:
            type: integer
            format: int64
          description: Account that owns the monitor.
        - name: monitorID
          in: path
          required: true
          schema:
            type: integer
            format: int64
          description: Monitor identifier.
      responses:
        "200":
          description: Monitor metadata.
          content:
            application/json:
              schema:
                type: object
                required: [name, url, status, flapping]
                properties:
                  name:
                    type: string
                    description: Human-readable display name of the monitor.
                  url:
                    type: string
                    description: URL or endpoint being monitored.
                  status:
                    type: string
                    description: Current status string (e.g. "up", "down", "paused", "unknown").
                  flapping:
                    type: boolean
                    description: Whether the monitor is currently in a flapping state.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Authenticated but not an admin.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Monitor not found for the given accountID and monitorID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/push/vapid-public-key:
    get:
      operationId: getBrowserPushVapidPublicKey
      summary: Fetch the dispatcher's VAPID application-server public key
      description: |
        Returns the base64url-encoded application-server public key the
        SPA must pass to `PushManager.subscribe({applicationServerKey})`
        before posting the resulting subscription to
        `POST /api/v1/push/subscribe`. The key is the public half of a
        VAPID keypair generated on the dispatcher's first boot and
        persisted thereafter — not a secret, but auth-gated so the SPA
        already has a token by the time it asks. Returns 503 in the
        defensive case where the dispatcher router is up but the
        keypair has not been initialised yet (not reachable on the
        current boot order).
      tags: [Push]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: VAPID public key.
          content:
            application/json:
              schema:
                type: object
                required: [public_key]
                properties:
                  public_key:
                    type: string
                    description: |
                      Base64url-encoded (unpadded) uncompressed P-256
                      public key, ~87 characters, alphabet
                      `[-_A-Za-z0-9]`. Pass this directly as
                      `applicationServerKey` to
                      `PushManager.subscribe`.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AccountSuspended"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: VAPID keypair not yet initialised.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /{token}:
    servers:
      - url: https://status.uptimemonitoring.com
        description: Free status page host (STATUS_PAGE_URL)
    get:
      operationId: getStatusPageHTML
      summary: Public status page (issue #368)
      description: |
        Unauthenticated — the token is the only credential, same shape as
        `/feed/{token}`. Served on its own host (`STATUS_PAGE_URL`), not
        under `/api/v1`; a request to this path on the API host 404s. Any
        method other than `GET` on this path (`HEAD`/`POST`/`PUT`/`DELETE`)
        also 404s, never 405. A token that doesn't match the pattern below
        gets the same 404 without a database lookup. Carries `Age` (seconds
        the served copy has aged at the origin) and `Cache-Control: public,
        max-age=<N>`, where `N` is the full freshness lifetime the view was
        cached under — 60 for a settled view, 5 for one where the live
        status currently disagrees with the open incident rows — never
        reduced by `Age`, since a cache already subtracts `Age` from
        `max-age` itself; both headers are computed the same way on the
        RSS/Atom/JSON/badge siblings below.
      tags: [Public]
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: '^sp_[0-9a-f]{32}$'
      responses:
        "200":
          description: HTML status page.
          headers:
            Age:
              $ref: "#/components/headers/StatusPageAge"
            Cache-Control:
              $ref: "#/components/headers/StatusPageCacheControl"
          content:
            text/html: {}
        "404":
          $ref: "#/components/responses/StatusPageNotFound"
        "500":
          $ref: "#/components/responses/StatusPageInternalError"
        "503":
          $ref: "#/components/responses/StatusPageUnavailable"

  /{token}/feed.rss:
    servers:
      - url: https://status.uptimemonitoring.com
        description: Free status page host (STATUS_PAGE_URL)
    get:
      operationId: getStatusPageRSS
      summary: Public status page, RSS 2.0 (issue #368)
      tags: [Public]
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: '^sp_[0-9a-f]{32}$'
      responses:
        "200":
          description: RSS 2.0 feed of down/up events, capped at 50, newest-first.
          headers:
            Age:
              $ref: "#/components/headers/StatusPageAge"
            Cache-Control:
              $ref: "#/components/headers/StatusPageCacheControl"
          content:
            application/rss+xml: {}
        "404":
          $ref: "#/components/responses/StatusPageNotFound"
        "500":
          $ref: "#/components/responses/StatusPageInternalError"
        "503":
          $ref: "#/components/responses/StatusPageUnavailable"

  /{token}/feed.atom:
    servers:
      - url: https://status.uptimemonitoring.com
        description: Free status page host (STATUS_PAGE_URL)
    get:
      operationId: getStatusPageAtom
      summary: Public status page, Atom (issue #368)
      tags: [Public]
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: '^sp_[0-9a-f]{32}$'
      responses:
        "200":
          description: Atom feed of down/up events, capped at 50, newest-first.
          headers:
            Age:
              $ref: "#/components/headers/StatusPageAge"
            Cache-Control:
              $ref: "#/components/headers/StatusPageCacheControl"
          content:
            application/atom+xml: {}
        "404":
          $ref: "#/components/responses/StatusPageNotFound"
        "500":
          $ref: "#/components/responses/StatusPageInternalError"
        "503":
          $ref: "#/components/responses/StatusPageUnavailable"

  /{token}/status.json:
    servers:
      - url: https://status.uptimemonitoring.com
        description: Free status page host (STATUS_PAGE_URL)
    get:
      operationId: getStatusPageJSON
      summary: Public status page, JSON (issue #368)
      tags: [Public]
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: '^sp_[0-9a-f]{32}$'
      responses:
        "200":
          description: JSON status page.
          headers:
            Age:
              $ref: "#/components/headers/StatusPageAge"
            Cache-Control:
              $ref: "#/components/headers/StatusPageCacheControl"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusPageJSON"
        "404":
          $ref: "#/components/responses/StatusPageNotFound"
        "500":
          $ref: "#/components/responses/StatusPageInternalError"
        "503":
          $ref: "#/components/responses/StatusPageUnavailable"

  /{token}/badge.svg:
    servers:
      - url: https://status.uptimemonitoring.com
        description: Free status page host (STATUS_PAGE_URL)
    get:
      operationId: getStatusPageBadge
      summary: Public status page, SVG uptime badge (issue #383)
      description: |
        A static, namespaced SVG — a fixed "uptime" label plus a
        status-colored message, `<pct>% · <state>` (or `no data ·
        <state>` when the 30-day average has no data yet) — rendered
        from the same cached `View` the HTML page, RSS, Atom, and JSON
        siblings share — requests landing on the same cache entry always
        render identical state, but a rebuild after the entry's TTL
        expires (or a downstream cache holding the two URLs
        independently) can leave the badge and page briefly disagreeing
        across that boundary. Resolved, cached, and error-handled
        identically to those four siblings (same token-shape check, same
        `GetStatusPageByToken` lookup folding an unknown/off/deleted/
        suspended token into one 404, same view cache and
        `Age`/`Cache-Control` computation, recomputed from the live clock
        on every response). No
        style element, no inline style attribute, no script element, and
        no external reference — presentation attributes only, so the
        document stays inside the status host's Content-Security-Policy
        and renders safely through an image proxy such as GitHub's camo.
      tags: [Public]
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: '^sp_[0-9a-f]{32}$'
      responses:
        "200":
          description: SVG uptime badge.
          headers:
            Age:
              $ref: "#/components/headers/StatusPageAge"
            Cache-Control:
              $ref: "#/components/headers/StatusPageCacheControl"
          content:
            image/svg+xml: {}
        "404":
          $ref: "#/components/responses/StatusPageNotFound"
        "500":
          $ref: "#/components/responses/StatusPageInternalError"
        "503":
          $ref: "#/components/responses/StatusPageUnavailable"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API key (`umk_live_*`) or session token from OAuth exchange.

  parameters:
    keyID:
      name: keyID
      in: path
      required: true
      schema:
        type: integer
        format: int64
      description: API key ID.
    monitorID:
      name: monitorID
      in: path
      required: true
      schema:
        type: integer
        format: int64
      description: Monitor ID.
    announcementID:
      name: announcementID
      in: path
      required: true
      schema:
        type: integer
      description: Announcement ID.

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
      example:
        error: something went wrong

    HostFanError:
      description: |
        429 body returned when the host-fan heuristic trips on
        `POST /api/v1/monitors`, or on a `PUT /api/v1/monitors/{monitorID}`
        that changes `url` or `type` (issue #335). The account is not
        suspended; retry after `retry_after_seconds`. `limit` and
        `window_seconds` reflect the server's actual configured values
        (`SCAN_HOSTFAN_THRESHOLD` - 1 and `SCAN_WINDOW`) — the example
        values below are the defaults, not hard-coded constants.
      type: object
      required: [error, limit, window_seconds, retry_after_seconds]
      properties:
        error:
          type: string
          example: "too many distinct domains in a short window; slow monitor creation or target changes and retry"
        limit:
          type: integer
          description: Maximum distinct registrable domains (eTLD+1) allowed in the window (inclusive).
          example: 7
        window_seconds:
          type: integer
          description: Length of the sliding window in seconds.
          example: 600
        retry_after_seconds:
          type: integer
          description: Seconds until the oldest qualifying entry leaves the window.
          example: 347

    AdminPingResponse:
      type: object
      required: [github_id, github_username, admin]
      properties:
        github_id:
          type: ["integer", "null"]
          format: int64
          description: |
            GitHub numeric ID of the AUTHENTICATED CALLER (sourced from
            `users.github_id` for the user identified by
            AuthInfo.UserID) — never the account owner's. Team accounts B1
            (issue #323) made non-owner admins possible, so a viewer/member
            admin inspecting someone else's account must see their own
            identity here, not the account's. Display-only — the gate
            itself keys on AuthInfo.UserID, not this field. `null` if the
            caller has no GitHub identity (an email- or Google-only user
            who is still an admin via their users.id).
        github_username:
          type: ["string", "null"]
          description: |
            GitHub username of the authenticated caller, sourced the same
            way as github_id. Display-only; the gate does not key on this
            value because GitHub usernames are mutable. `null` under the
            same condition as github_id.
        admin:
          type: boolean
          description: Always `true` on a 200 response.
      example:
        github_id: 19820
        github_username: dlucian
        admin: true

    KillSwitchStatus:
      type: object
      required: [active, sentinel_path]
      properties:
        active:
          type: boolean
          description: |
            Whether the global kill switch is currently engaged.
            Reflects the watcher's in-memory state, which is
            updated on a 5 s poll cadence (out-of-band flips can
            lag up to 5 s; REST-driven flips propagate within
            milliseconds).
        sentinel_path:
          type: string
          description: |
            Absolute path of the sentinel file the watcher is
            polling — the value of `KILL_SWITCH_FILE` at process
            start.
      example:
        active: true
        sentinel_path: /var/run/um/killswitch

    TransitionDropsStatus:
      type: object
      required: [overflow, writer_error, flap_exit_deferred, since]
      properties:
        overflow:
          type: integer
          format: int64
          description: |
            Always zero (issue #377). Retained for wire compatibility
            only: the per-monitor writer shard used to be a bounded
            channel that could fill at enqueue time, dropping the
            transition. It is now an unbounded, retried queue — nothing
            is ever dropped for being full.
        writer_error:
          type: integer
          format: int64
          description: |
            Cumulative count of transitions dropped because
            RecordStateTransition failed with a PERMANENT error (issue
            #377) — a data-exception or integrity-constraint-violation
            SQLSTATE, or an invalid revision. A transient failure,
            including the ambiguous "commit outcome unknown" case, is
            retried against the same job instead of being dropped, so
            it never increments this counter.
        flap_exit_deferred:
          type: integer
          format: int64
          description: |
            Always zero (issue #377). Retained for wire compatibility
            only: flap-decay exits used to be deferred when the writer
            shard was full; the queue is now unbounded, so there is
            nothing left to defer.
        since:
          type: string
          format: date-time
          description: |
            Process instant the three counters started at zero
            (dispatcher start), not a rolling window.
      example:
        overflow: 0
        writer_error: 1
        flap_exit_deferred: 0
        since: "2026-10-09T12:00:00Z"

    Invite:
      type: object
      description: An owner-issued invitation, as seen by the account's owners (issue #325).
      properties:
        id:
          type: integer
          format: int64
        github_id:
          type: integer
          format: int64
        github_username:
          type: string
        role:
          type: string
          enum: [owner, member, viewer]
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          description: Always `created_at` + 7 days.

    UserInvite:
      type: object
      description: An invite addressed to the signed-in user, as seen via `GET /api/v1/me/invites` (issue #325).
      properties:
        id:
          type: integer
          format: int64
        account_label:
          type: string
          description: |
            The account's earliest owner's `github_username` when
            non-empty, else `"account <id>"` — never an email address.
        github_username:
          type: string
        role:
          type: string
          enum: [owner, member, viewer]
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time

    Account:
      type: object
      properties:
        id:
          type: integer
          format: int64
        github_id:
          type: integer
          format: int64
          description: |
            GitHub numeric ID of the account's owner. Since issue #311,
            sourced from `users.github_id` via the account's owner
            membership (`account_members`), not a column on `accounts`
            itself — display-only, never an authorization input. `0` for
            an owner whose identity is email-only (issue #315) rather than
            GitHub — never `null`.
        github_username:
          type: string
          description: |
            GitHub username of the account's owner, sourced the same way
            as github_id. `""` (empty string) for an email-only owner —
            never `null`.
        email:
          type: ["string", "null"]
          description: |
            Any verified email identity held by the account's owner —
            either the address behind an email-identity sign-in (issue
            #315, sign-in part A) or one GitHub reported as verified and
            primary, adopted onto the owner's `users` row on a sign-in
            that found none on file yet (issue #390) — sourced from
            `users.email` via the account's owner membership, the same
            way github_id/github_username are. `null` when the owner holds
            no verified email of either kind. Display-only, never an
            authorization input; never present on any public,
            unauthenticated surface (`GET /feed/{token}`, `GET
            /feed/{token}/rss`). Omitted entirely (issue #323, team
            accounts B1) unless the authenticated caller's OWN role is
            `owner` (issue #325: an account can have more than one owner,
            so this is no longer "caller is THE owner") — a viewer or
            member on the same account never sees it, regardless of
            whether the account's (earliest) owner has adopted an email of
            either kind.
        notification_email:
          type: ["string", "null"]
        role:
          type: string
          enum: [viewer, member, owner]
          description: |
            The AUTHENTICATED CALLER's own authority on this account (issue
            #323, team accounts B1) — not a property of the account itself.
            Always present. The SPA uses this to hide owner/member-only
            controls.
        is_admin:
          type: boolean
          description: |
            True when the authenticated account is in the admins table —
            i.e. the same answer auth.RequireAdmin returns for any
            admin-gated route. Always present on GET /api/v1/account and
            on the PATCH /api/v1/account success response.
        last_cap_hit_at:
          type: ["string", "null"]
          format: date-time
          description: |
            Timestamp of the latest successfully recorded POST
            /api/v1/monitors (or MCP create_monitor) rejection for hitting
            the per-account hard cap (100 monitors) — not a guaranteed
            latest-refusal timestamp. Updates are coalesced to at most once
            per account per minute, and a failure to record a hit is
            swallowed (the REST 409 or MCP tool-error result the client
            sees is unaffected either way), so a refusal can occur
            without advancing this field, and null
            does not prove the account has never been refused. Advisory
            only — the app uses it to time an upgrade offer; never cleared
            once set.
        silent_monitors:
          type: integer
          description: |
            Count of ENABLED monitors on this account with no ACTIVE
            attached recipient (issue #212). "Active" excludes a disabled
            recipient and a still-unconfirmed (`pending`) email recipient
            (issue #321) — a monitor whose only attached recipient is
            pending or disabled is counted as silent, the same as one with
            no attached recipient at all. Disabling a monitor removes it
            from this count — a disabled, unreachable monitor is not
            flagged as silent since it is not being checked. Present on
            both `GET` and `PATCH /api/v1/account`.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    AccountPatchInput:
      type: object
      required: [notification_email]
      additionalProperties: false
      properties:
        notification_email:
          type: ["string", "null"]
          maxLength: 254
          description: |
            New notification_email value. JSON null or an empty string
            clears the field. Otherwise the value must parse as an
            RFC 5322 address. Must be valid UTF-8 with no null bytes;
            violations return HTTP 400.
          example: user@example.com

    Monitor:
      type: object
      properties:
        id:
          type: integer
          format: int64
        account_id:
          type: integer
          format: int64
        name:
          type: string
          example: Production API
        url:
          type: string
          description: |
            Target URL. Format depends on `type`:
              * `http` — full http(s) URL, e.g. `https://api.example.com/health`.
              * `tcp` — always returned/stored as `tcp://host:port`, port
                restricted to a whitelist (see `MonitorInput.url` for the
                accepted input shapes).
              * `ping` — a bare hostname or IP literal (e.g.
                `api.example.com`, `1.1.1.1`) — no scheme, path, query,
                userinfo, or port.
              * `dns` / `ssl` — reserved formats (a bare hostname for `dns`)
                for a `type` that is defined but not yet serviced by the
                check engine; see the `type` field.
          example: https://api.example.com/health
        type:
          type: string
          description: |
            `http`, `tcp`, and `ping` can appear here. `dns` and `ssl` are
            reserved for a check engine that does not exist yet (issue
            #209); no monitor of one of those two types can be created,
            updated, or persisted.
          enum: [http, tcp, ping]
        interval_sec:
          type: integer
          minimum: 60
          example: 60
        expected_status:
          type: string
          description: Single HTTP status code, expressed as a string, 100–599. Defaults to "200".
          example: "200"
        body_match:
          type: ["string", "null"]
        enabled:
          type: boolean
        response_time_threshold_ms:
          type: ["integer", "null"]
          description: Alert if response time exceeds this (ms). Null = disabled.
        maintenance:
          $ref: "#/components/schemas/MaintenanceWindow"
        method:
          type: string
          enum: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
          description: |
            HTTP request method (issue #364). Always `GET` for `tcp`/`ping`/
            `dns`/`ssl` monitors, regardless of what might once have been
            stored — a non-http monitor's custom-request fields are always
            reset to their defaults on write.
        body:
          type: string
          description: |
            Request body, verbatim (issue #364). Always `""` for a non-http
            monitor. Unlike `headers`, the body is NOT write-only — it is
            returned verbatim on every read to anyone who can read the
            monitor. It must not contain credentials or other sensitive
            data unless that exposure is acceptable; `headers` is the
            write-only place for secrets.
        headers:
          type: array
          items:
            $ref: "#/components/schemas/RequestHeader"
          description: |
            Custom request headers (issue #364), write-only: each entry
            reports only `name` and `value_set`, never the value itself.
            Always `[]` for a non-http monitor.
        timeout_sec:
          type: integer
          minimum: 1
          maximum: 30
          description: Request timeout in seconds (issue #364). Always `30` for a non-http monitor.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    RequestHeader:
      type: object
      description: |
        One custom request header (issue #364). Write-only: the value
        itself is never present on a read — only whether one is set.
      required: [name, value_set]
      properties:
        name:
          type: string
        value_set:
          type: boolean
          description: Whether this header carries a non-empty value.

    MaintenanceWindow:
      type: object
      description: |
        A monitor's single alert-silencing window (issue #361). Always
        present on a `Monitor`; `{"type":"none"}` (the default) silences
        nothing. Accepted on `MonitorInput`/`MonitorCreateInput` as one of
        three discriminated shapes — each accepts ONLY the fields listed
        for it, plus `type`; any other field (including one that belongs to
        a different shape) is `400` naming it, e.g.
        `maintenance.timezone: not allowed for type "once"`.
          * `none` — no other fields.
          * `daily` — `start_time`, `end_time`, `timezone` all required.
            Repeats every day in that IANA zone; may span midnight
            (`start_time > end_time`). `start_time` must differ from
            `end_time`. The zone must be a real IANA name — empty, `Local`,
            and anything `LoadLocation` can't resolve are all rejected.
          * `once` — `starts_at`, `ends_at` both required, `ends_at` after
            `starts_at`. A single absolute span; no zone, since both
            instants are already pinned by their offsets. Both timestamps
            are normalized to UTC at microsecond precision (PostgreSQL's
            `timestamptz` precision) before being validated or stored, so
            two timestamps that differ only below a microsecond collapse to
            equal and are rejected as `ends_at` not being after `starts_at`.
        On `PUT`, omitting `maintenance` entirely leaves the stored window
        unchanged; an explicit JSON `null` is `400`
        (`maintenance must not be null`) — the same rule `recipients`
        follows elsewhere. `POST` omitting it defaults to `{"type":"none"}`.
      properties:
        type:
          type: string
          enum: [none, daily, once]
        start_time:
          type: string
          description: '"HH:MM", daily only. Canonical: zero seconds, below 24:00.'
          example: "23:00"
        end_time:
          type: string
          description: '"HH:MM", daily only.'
          example: "03:00"
        timezone:
          type: string
          description: IANA zone name, daily only.
          example: Europe/Lisbon
        starts_at:
          type: string
          format: date-time
          description: RFC3339, once only. Echoed back UTC-normalized at microsecond precision.
        ends_at:
          type: string
          format: date-time
          description: RFC3339, once only. Echoed back UTC-normalized at microsecond precision.

    MonitorState:
      type: object
      properties:
        monitor_id:
          type: integer
          format: int64
        status:
          type: string
          enum: [up, down, unknown, flapping]
        last_check_at:
          type: ["string", "null"]
          format: date-time
        last_transition_at:
          type: ["string", "null"]
          format: date-time
        suspect_since:
          type: ["string", "null"]
          format: date-time
        flap_count_1h:
          type: integer
        flapping:
          type: boolean
        evidence_buffer:
          type: array
          items:
            $ref: "#/components/schemas/CheckResult"
        primary_region:
          type: ["string", "null"]
          enum: [EU, US-E, US-W, ASIA, OCE, SA, null]
          description: The region assigned as primary for this monitor's checks. Null until the first check is dispatched.

    CheckResult:
      type: object
      properties:
        timestamp:
          type: string
          format: date-time
        region:
          type: string
        status_code:
          type: integer
        error_class:
          type: string
        dns_ms:
          type: number
        tls_ms:
          type: number
        connect_ms:
          type: number
        ttfb_ms:
          type: number
        download_ms:
          type: number
        content_length:
          type: integer
          format: int64
        response_hash:
          type: string
        packet_loss_pct:
          type: number
          minimum: 0
          maximum: 100
          description: |
            Ping-only packet loss percentage (0-100). Omitted (not present
            in the object) when not measured — every non-ping check result,
            and a ping probe-side failure that never reached the target. A
            measured 0% loss is present with value 0, distinct from omitted.

    MonitorInput:
      type: object
      description: |
        All string fields must be valid UTF-8 with no null bytes.
        URL fields must additionally be free of ASCII control characters
        (U+0000..U+001F and U+007F). Violations return HTTP 400.
      properties:
        name:
          type: string
          maxLength: 255
        url:
          type: string
          maxLength: 2048
          description: |
            Target URL. Format depends on `type`:
              * `http` — full http(s) URL.
              * `tcp` — `host:port` or `tcp://host:port`, port restricted to
                a fixed allowlist (issue #331): 21, 22, 25, 80, 110, 143,
                443, 465, 587, 993, 995, 2222, 3000, 3306, 5000, 5432, 6379,
                8000, 8080, 8443, 8888, or 27017. Every create,
                and every update whose body includes `url` or `type`, stores
                the canonical `tcp://host:port` form (lowercase, no path)
                regardless of which shape was sent — `example.com:443`,
                `tcp://example.com:443`, and `tcp://EXAMPLE.com:443/` all
                store as `tcp://example.com:443`. An update that changes
                neither `url` nor `type` leaves the stored URL as it is. A
                bracketed IPv6 literal is required (`[2001:db8::1]:443`); an
                unbracketed one is rejected as ambiguous.
              * `ping` — a bare hostname or IP literal (e.g.
                `api.example.com`, `1.1.1.1`) — no scheme, path, query,
                userinfo, or port.
              * `dns` / `ssl` — reserved formats (a bare hostname for `dns`)
                for a `type` that is defined but not yet serviced by the
                check engine; see the `type` field.
        type:
          type: string
          description: |
            `http`, `tcp`, and `ping` are accepted on create or update.
            `dns` and `ssl` are defined in the wire format but rejected
            with `400` (issue #209): `type must be one of: http, tcp, ping,
            dns, ssl (currently available: http, ping, tcp)` for an
            unrecognized value, or `type '<type>' is not available yet; the
            check engine currently supports: http, ping, tcp` for one of
            those two.
          enum: [http, tcp, ping]
        interval_sec:
          type: integer
          minimum: 60
          description: Check interval in seconds. Default 60.
        expected_status:
          type: string
          description: |
            HTTP status range. Default: 200-399. Accepted but silently
            ignored for `type: tcp` or `type: ping` (neither has an HTTP
            response to evaluate it against).
        body_match:
          type: string
          maxLength: 1024
          description: |
            Optional string to match in the response body. HTTP only —
            rejected with `400` (`body_match is not applicable to tcp
            monitors`) when `type` is `tcp`. Accepted but silently ignored
            (a harmless no-op; wopher's ping check never reads it) when
            `type` is `ping`.
        enabled:
          type: boolean
          description: Default true.
        maintenance:
          $ref: "#/components/schemas/MaintenanceWindow"
        method:
          type: string
          enum: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
          description: |
            HTTP request method (issue #364). Default `GET`. HTTP only —
            sending this field at all (including its default value) on a
            `tcp` or `ping` create/update is `400`
            (`method is only applicable to http monitors`). On update,
            omitting it leaves the stored method unchanged; an explicit
            `null` is `400` (`method must not be null`) — but only when
            `null` is the value the request body actually decodes to:
            exact-duplicate JSON keys (same spelling) resolve via
            `encoding/json`'s last-value-wins before this check ever runs,
            so `{"method":null,"method":"POST"}` is accepted as `POST`;
            only a differently-cased duplicate (e.g. `Method` alongside
            `method`) is itself rejected, as more than one key for the
            same field. The same caveat applies to `body`/`timeout_sec`
            below, which share this rejection rule.
        body:
          type: string
          maxLength: 4096
          description: |
            Request body (issue #364). Default `""` — a bodiless POST is
            allowed. Must be valid UTF-8 with no null bytes. HTTP only, same
            rejection rule as `method`. Cleared by sending `""` on update.
        headers:
          type: array
          maxItems: 20
          items:
            type: object
            required: [name]
            description: |
              The case-insensitive duplicate-key rejection described below
              for `name`/`value` applies only to the request's TOP-LEVEL
              keys (`method`, `body`, `headers`, `timeout_sec` — see
              `method`'s description above). It does NOT recurse into each
              header object: a header entry is decoded by
              `encoding/json` into a plain struct, which matches `name`/
              `value` case-insensitively and, when more than one JSON key
              in the same object folds to the same field (any casing, not
              just an exact-spelling duplicate), keeps whichever key
              occurs LAST in that object — e.g. `{"value":null,"Value":"x"}`
              decodes to `value: "x"`, not a rejection. Unknown properties
              on a header object are silently ignored by the same decode,
              not rejected.
            properties:
              name:
                type: string
                description: |
                  An RFC 7230 token. `Host`, `Content-Length`,
                  `Transfer-Encoding`, and `Connection` are rejected
                  case-insensitively, as are two entries with the same name
                  (case-insensitive). This is a check across the headers
                  array's entries, not of this field's own JSON key
                  spelling within one entry — see this schema's own
                  description above for that.
              value:
                type: string
                description: |
                  On `PUT`, an entry with `value` omitted keeps the
                  currently stored value for that name (case-insensitive
                  match) — the array as a whole still REPLACES the full
                  stored list, so omitting `headers` entirely (not sending
                  an entry at all) is what leaves every header untouched.
                  A name with no stored value and no `value` sent is `400`.
                  An explicit `null` is `400` (`header "<name>" value must
                  not be null`) when `null` is the value `encoding/json`
                  resolves this field to — see this schema's own
                  description above for how a same- or differently-cased
                  duplicate `value`/`Value` key within one header object
                  resolves instead (last occurrence wins, never rejected).
                  On `POST`, every entry must carry a `value` — there is
                  nothing stored yet to keep.
          description: |
            Custom request headers (issue #364), at most 20 entries and
            8192 bytes total (`len(name)+2+len(value)` summed). HTTP only,
            same rejection rule as `method`. Values must be valid UTF-8 with
            no CR, LF, NUL, or other control character except horizontal
            tab. On update, sending `url` with a different scheme,
            lowercased host, or effective port than the stored URL requires
            every resulting header to carry a `value` in THIS request — an
            omitted `headers` field (when headers are stored) or any
            kept-by-name entry is `400`
            (`headers must be resent with their values when the URL's
            scheme, host, or port changes`), closing the path where one
            account member could repoint another member's monitor (with
            its still-live secret header) at a host they control. An empty
            array (`[]`) clears every header.
        timeout_sec:
          type: integer
          minimum: 1
          maximum: 30
          description: |
            Request timeout in seconds (issue #364). Default 30. HTTP only,
            same rejection rule as `method`.

    MonitorCreateInput:
      description: |
        `POST /api/v1/monitors`'s own request shape (issue #329):
        `MonitorInput` plus `recipient_ids`/`recipients`. Not used by
        `PUT /monitors/{id}`, which stays on plain `MonitorInput` — a
        `recipient_ids` or `recipients` field in a PUT body is silently
        ignored, not validated.
      allOf:
        - $ref: "#/components/schemas/MonitorInput"
        - type: object
          properties:
            recipient_ids:
              type: ["array", "null"]
              items:
                type: integer
                format: int64
              description: |
                Selects which recipients to attach at create time. Three
                cases:
                  * Omitted or `null` — attach the account's default
                    recipients (`recipients.is_default`). Identical to
                    pre-#329 behavior.
                  * `[]` — attach none.
                  * A non-empty array — attach exactly those ids. Validated
                    like `PUT /monitors/{id}/recipients`: every id must be
                    positive and the array must not contain duplicates
                    (`400` before any write), and every id must be owned by
                    the calling account (`400`, checked before the
                    monitor-limit and duplicate-URL checks).

                Mutually exclusive with `recipients` — both present is
                `400`.
            recipients:
              type: array
              items:
                type: object
                required: [id]
                additionalProperties: false
                properties:
                  id:
                    type: integer
                    format: int64
                  down_delay_s:
                    type: integer
                    enum: [0, 60, 120, 180, 240, 300, 600, 900, 1200, 1500, 1800, 3600, 7200, 10800, 14400]
                    description: |
                      Seconds to hold a "down" alert before sending it.
                      Optional; defaults to `0`.
              description: |
                `recipient_ids`'s alternative form (issue #338): attach
                exactly these pairs, each with its own `down_delay_s`.
                Mutually exclusive with `recipient_ids` — both present is
                `400`. Unlike `recipient_ids`, `null` is `400` rather than
                "attach defaults" — there is no sensible delay to default
                an implicit set to.

    MonitorListItem:
      description: Monitor with inline status summary for list views.
      allOf:
        - $ref: "#/components/schemas/Monitor"
        - type: object
          properties:
            status:
              type: string
              enum: [up, down, unknown, flapping]
            last_check_at:
              type: ["string", "null"]
              format: date-time
            recipient_ids:
              type: array
              items:
                type: integer
                format: int64
              description: Attached recipient ids (issue #212). Always present, `[]` for none.

    MonitorDetail:
      type: object
      properties:
        monitor:
          $ref: "#/components/schemas/Monitor"
        state:
          $ref: "#/components/schemas/MonitorState"
        notice:
          $ref: "#/components/schemas/SoftCapNotice"
          description: |
            Optional advisory message attached to write responses. Today only
            the soft-cap crossing (51..100 monitors per account) produces a
            notice. Omitted entirely when no notice applies.
        last_check_skipped:
          type: string
          description: |
            Populated only on `POST /monitors` when the create-time initial
            check did not produce a usable result — either short-circuited
            before any probe ran, or the probe started but did not finish
            within the create-time budget, leaving the first check pending.
            The state row's `last_check_at` remains `null` in either case;
            clients distinguish "no data yet" from "skipped on purpose" via
            this field.

            Vocabulary:
            * `domain_rate_limited` — the per-domain dispatch cap
              (`DomainLimiter`) consumed the slot; the next scheduled tick
              will retry once the bucket refills.
            * `initial_check_timeout` — the create-time probe did not
              return within the server's bounded wait; the monitor is
              registered normally and the next scheduled tick resolves it.
        recipient_ids:
          type: array
          items:
            type: integer
            format: int64
          description: |
            Attached recipient ids (issue #212). Always present, `[]` for
            none. On `POST /monitors`, reflects any account default
            recipients attached inside the create transaction — though a
            monitor born down does not notify them at creation; see
            `Recipient.default`.

    StatusPageSettings:
      type: object
      description: |
        The owner's free status-page switch state for one monitor
        (issue #368). `url` is `null` when the page is off.
      required: [enabled, url]
      properties:
        enabled:
          type: boolean
        url:
          type: [string, "null"]
          format: uri
          example: "https://status.uptimemonitoring.com/sp_0123456789abcdef0123456789abcdef"

    StatusPageDay:
      type: object
      description: One day of the public status page's 30-day grid.
      required: [date, uptime_pct, not_monitored_yet]
      properties:
        date:
          type: string
          format: date
        uptime_pct:
          type: [number, "null"]
          format: float
        not_monitored_yet:
          type: boolean
          description: True for a day before the monitor existed — rendered grey, never a fabricated 0% or 100%.

    StatusPageIncident:
      type: object
      description: One entry of the public downtime history, newest-first, capped at 50.
      required: [id, started_at, clipped_start, ongoing_over_30_days]
      properties:
        id:
          type: integer
          format: int64
        started_at:
          type: string
          format: date-time
        clipped_start:
          type: boolean
          description: True when the incident actually began before the 30-day window.
        resolved_at:
          type: string
          format: date-time
        ongoing_over_30_days:
          type: boolean
          description: True for a still-open incident older than 30 days — rendered as "Down for more than 30 days".

    StatusPageJSON:
      type: object
      description: |
        The public `GET /{token}/status.json` response (issue #368). Served
        from the status host (`STATUS_PAGE_URL`), unauthenticated — the
        token is the only credential.
      required: [monitor_name, monitor_host, status, headline, days, uptime_pct_30d, avg_response_ms, history, claimed, generated_at]
      properties:
        monitor_name:
          type: string
        monitor_host:
          type: string
        status:
          type: string
          enum: [up, down, flapping, paused, unknown]
        headline:
          type: string
          example: "example.com is up"
        last_checked_at:
          type: string
          format: date-time
        down_since:
          type: string
          format: date-time
        region_clause:
          type: string
          example: "confirmed from 2 of 3 regions"
          description: Present only when status is down and the open incident carries a region tally.
        days:
          type: array
          minItems: 30
          maxItems: 30
          items:
            $ref: "#/components/schemas/StatusPageDay"
        uptime_pct_30d:
          type: [number, "null"]
          format: float
          description: Plain average of the days whose uptime_pct is not null.
        avg_response_ms:
          type: [number, "null"]
          format: float
        history:
          type: array
          maxItems: 50
          items:
            $ref: "#/components/schemas/StatusPageIncident"
        claimed:
          type: boolean
        generated_at:
          type: string
          format: date-time

    UptimeDay:
      type: object
      description: |
        One day's uptime entry returned by `GET /api/v1/monitors/{monitorID}/uptime`.

        `pct` is nullable: `null` means the day had no scheduled checks
        (render grey). A numeric `pct` is the share of the day NOT
        covered by `down`-incident overlap, clamped to `[0, 100]`, then
        floored to `0` when the day's counters show zero successful
        checks AND the monitor is corroborated down by either signal
        below (both netted against overlapping flapping-incident
        windows, so a flapping monitor's masked-downtime treatment is
        never overridden): an unresolved down incident whose net
        overlap with the day is greater than zero (any day), or the
        monitor's current resolved status being `down`, only when
        enabled, not flapping, and the current status began before the
        day ended — i.e. the day falls inside the monitor's current
        continuous down-period, not merely "today". Never floored when
        the monitor's resolved status is `up`.

        `latency` is nullable and independent of `pct`'s null condition:
        `pct` null keys off `scheduled = 0`, while `latency` null keys
        off there being zero successful checks to sample from that day
        (including days predating this feature). A day can carry a
        non-null `pct` and a null `latency` at the same time.
      required: [day, scheduled, pct, latency]
      properties:
        day:
          type: string
          format: date
          description: UTC calendar day (YYYY-MM-DD).
          example: "2026-05-02"
        scheduled:
          type: integer
          description: |
            Number of checks scheduled for this monitor on this day,
            sourced from `monitor_uptime_day.scheduled`. `0` means the
            dispatcher never tried to check this monitor (suspended,
            disabled, kill switch, dispatcher outage, freshly created).
          example: 1440
        pct:
          type: [number, "null"]
          format: float
          minimum: 0
          maximum: 100
          description: |
            Uptime percentage for the day, derived from down-incident
            overlap. `null` when `scheduled = 0`. Floored to `0` when
            the day had zero successful checks and the monitor is
            corroborated down by either signal, both netted against
            overlapping flapping-incident windows so a flapping
            monitor's masked-downtime treatment is never overridden:
            an unresolved down incident whose net (post flap-mask)
            overlap with the day is greater than zero (any day), or the
            monitor's current resolved status being `down` — only when
            the monitor is enabled, not currently flapping, AND the
            current status began before the day ended, i.e. the day
            falls inside the monitor's current continuous down-period.
            This reconciliation catches days where a runtime incident
            write was dropped and no `incidents` row covers the outage
            — including every day of a multi-week outage, not just the
            most recent one. A day that predates the current down-period
            (status flipped down after that day already ended) is never
            floored by current status alone. Never applied when the
            monitor's resolved status is `up`. Always present in the
            JSON object — never omitted.
          example: 99.86
        latency:
          type: [object, "null"]
          description: |
            The day's latency candle: open/high/low/close/avg total
            response time in whole milliseconds (wopher `total_ms`: the
            full request round-trip, DNS through last byte of
            download), built only from
            successful checks against the monitor's sticky primary
            region. All five values are rounded to the nearest whole
            millisecond server-side (half away from zero).

            `null` when the day has no successful samples to build a
            candle from — this includes days predating the feature.
            This is a distinct null condition from `pct`: a fully
            scheduled, all-down day has a non-null `pct` but a null
            `latency`. Always present in the JSON object — never
            omitted.
          required: [open, high, low, close, avg]
          properties:
            open:
              type: number
              format: float
              description: First successful check's total response time of the day, in whole ms.
              example: 212
            high:
              type: number
              format: float
              description: Highest total response time among the day's successful checks, in whole ms.
              example: 480
            low:
              type: number
              format: float
              description: Lowest total response time among the day's successful checks, in whole ms.
              example: 190
            close:
              type: number
              format: float
              description: Most recent successful check's total response time of the day, in whole ms.
              example: 220
            avg:
              type: number
              format: float
              description: |
                Arithmetic mean of the day's successful-check total
                response times (`latency_sum_ms / latency_count`,
                computed server-side; the underlying sum/count are not
                exposed on the wire), in whole ms.
              example: 231

    SoftCapNotice:
      type: object
      description: |
        Advisory attached to `POST /api/v1/monitors` when the post-insert
        per-account monitor count falls in the soft-cap window (51..100).
        Clients should branch on `code` rather than parsing `message`.
      required: [code, message, monitors_current, soft_cap, hard_cap]
      properties:
        code:
          type: string
          enum: [soft_cap_reached]
        message:
          type: string
          description: Human-readable advisory text. Wording is set by SPEC and may evolve.
        monitors_current:
          type: integer
          description: Per-account monitor count after the create.
        soft_cap:
          type: integer
          description: Soft cap threshold; crossing it triggers the notice.
        hard_cap:
          type: integer
          description: Hard cap; reached → 409 on subsequent creates.

    Incident:
      type: object
      properties:
        id:
          type: integer
          format: int64
        monitor_id:
          type: integer
          format: int64
        account_id:
          type: integer
          format: int64
        type:
          type: string
          enum: [down, flapping]
        started_at:
          type: string
          format: date-time
        resolved_at:
          type: ["string", "null"]
          format: date-time
          description: Null if the incident is still ongoing.
        evidence:
          type: array
          description: Snapshot of the evidence ring buffer at the time of the state transition.
          items:
            $ref: "#/components/schemas/CheckResult"
        created_at:
          type: string
          format: date-time
        maintenance:
          type: boolean
          description: |
            True when this incident opened while the monitor's maintenance
            window was active (issue #361) — or, for a down-to-flapping or
            flapping-to-down handoff, inherited from the incident it
            replaced. A monitor-wide down/flapping alert is suppressed for
            every recipient while this is true, and the recovery ("up")
            alert is suppressed too once every incident the recovery
            resolved carries this flag.

    APIKey:
      type: object
      description: API key metadata (plaintext and hash are never exposed).
      properties:
        id:
          type: integer
          format: int64
        account_id:
          type: integer
          format: int64
        key_prefix:
          type: string
          description: "Prefix for identification: `umk_live_` + first 8 hex chars."
          example: umk_live_a1b2c3d4
        label:
          type: ["string", "null"]
          description: |
            User-assigned label. New labels (created on or after the #115
            tightening) are trimmed to 3..100 characters. Legacy labels
            persisted before that rule may fall outside this range or carry
            untrimmed surrounding whitespace and are returned verbatim.
          example: production
        revoked_at:
          type: ["string", "null"]
          format: date-time
          description: If set, the key will stop working after this timestamp.
        last_used_at:
          type: ["string", "null"]
          format: date-time
        last_used_ip:
          type: ["string", "null"]
          description: IP address of the last request authenticated with this key.
        created_at:
          type: string
          format: date-time

    AccountUsage:
      type: object
      description: |
        Nine daily usage counters keyed on (account_id, date), written by
        usage.Worker (internal/usage). The source-derived totals behind
        all nine columns stop changing once every writer attributed to
        that day has committed, but the stored row only reflects those
        totals once a later aggregation pass that still covers the day
        re-writes it — a writer that commits after its day closed is
        repaired only if a RunOnce pass covering that day still runs
        (bounded to a 30-day re-walk; D+30 is the last UTC *date*
        eligible to re-aggregate day D, not a single pass — any number of
        runs on D+30 can still repair it), AND that pass's own three
        source reads all happen after the last attributed writer commits.
        Concurrent passes in the same process now serialize on a
        package-level lock (a later-started pass only begins reading
        once every UPSERT from an earlier pass has committed — see
        internal/usage/usage.go), so a later-started pass's UPSERT no
        longer races an older, still-in-flight one; there is no
        equivalent lock across separate dispatcher processes, though
        production never runs two against the same database. RunOnce
        also rechecks window membership against the live clock before
        each day and each account-day it reads, so a pass running late
        into the next UTC date stops at the first day — or, within a
        day, the first account — the recheck finds outside the window,
        rather than continuing to read it. At D+31, RunOnce's window
        permanently stops reaching day D for any *new* pass, and the row
        is stuck on whatever was last written — regardless of whether a
        pass over D "completed" before then. Completion alone does not
        make a pass correct: the row only reflects reality if that
        pass's source reads all happened after the last attributed
        writer for day D committed, not merely that the pass ran and
        its UPSERT succeeded. A completed pass whose reads raced a late
        writer, or that upserted a mixed snapshot because its three
        reads are independent, freezes the row just as permanently at
        D+31 as no pass running at all. The residual the window recheck
        narrows but does not close: a single account's three source
        reads plus its UPSERT are
        still independent statements, not one atomic snapshot, so they
        can still straddle the recheck and race the independent
        retention sweep below. From the point D falls out of the window,
        day D's three source tables become eligible for their own
        independent retention sweep, each on its own window:
        account_activity_day after 30 days (the narrowest of the three,
        and what sets this worker's 30-day backfill window),
        monitor_uptime_day after 95 days (issue #367 widened this from
        65), and account_events after 90 days — every sweep is
        asynchronous and retryable, so none is guaranteed to run at that
        exact moment, and dispatcher downtime or repeated deletion errors
        (logged and swallowed) can leave rows physically present past
        their own mark.

        `incidents` and `webhooks_sent` additionally carry an exactness
        boundary around migration 032's deploy date — see those fields.
      properties:
        account_id:
          type: integer
          format: int64
        date:
          type: string
          format: date
        incidents:
          type: integer
          format: int64
          description: Count of incidents whose started_at fell on this UTC day, from account_activity_day. Exact after migration 032's deploy date; earlier days may hold a backfilled/legacy-merged value instead of an exact recount.
        webhooks_sent:
          type: integer
          format: int64
          description: Count of delivered webhooks whose attempt-start time (not completion) fell on this UTC day, from account_activity_day. Exact after migration 032's deploy date; earlier days may hold a backfilled/legacy-merged value instead of an exact recount.
        monitors_avg:
          type: number
          format: float
        monitors_created:
          type: integer
          format: int64
        monitors_deleted:
          type: integer
          format: int64
        webhooks_created:
          type: integer
          format: int64
        webhooks_deleted:
          type: integer
          format: int64
        checks_total:
          type: integer
          format: int64
          description: Per-day total check count, summed from monitor_uptime_day.
        checks_succeeded:
          type: integer
          format: int64

    AccountUsageResponse:
      type: object
      properties:
        monitors_current:
          type: integer
        days:
          type: array
          items:
            $ref: "#/components/schemas/AccountUsage"

    ReportPreferences:
      type: object
      description: |
        A signed-in user's weekly/monthly uptime-report opt-ins on one
        account (issue #357) — stored per `account_members` row, not per
        user, so leaving one team's reports never silences another.
        `deliverable` is `true` exactly when `email` is non-null; a
        client can use it to prompt the user to verify an email address
        before expecting any report to actually arrive.
      properties:
        account_id:
          type: integer
          format: int64
        weekly:
          type: boolean
        monthly:
          type: boolean
        email:
          type: ["string", "null"]
        deliverable:
          type: boolean

    AccountEvent:
      type: object
      description: |
        One row of the per-account audit log. Mirrors the `account_events`
        table verbatim (see SPEC §Account Events). `payload` is the
        recorder's bytes round-tripped without re-shaping at the read path
        — the per-event-type contract is documented in the spec and in
        `internal/audit/DECISIONS.md`.
      properties:
        id:
          type: integer
          format: int64
        account_id:
          type: integer
          format: int64
        event_type:
          type: string
          enum:
            - monitor.created
            - monitor.updated
            - monitor.deleted
            - webhook.created
            - webhook.updated
            - webhook.deleted
            - recipient.created
            - recipient.updated
            - recipient.deleted
            - recipient.confirmed
            - recipient.unsubscribed
            - monitor.recipients_set
            - apikey.created
            - apikey.rotated
            - apikey.deleted
            - account.suspended
            - account.unsuspended
            - account.notification_email_changed
            - account.email_cap_hit
            - account.member_role_changed
            - account.member_removed
            - account.invite_created
            - account.invite_revoked
            - account.invite_accepted
            - monitor.status_page_enabled
            - monitor.status_page_disabled
            - account.identity_linked
            - account.github_email_skipped
        ref_type:
          type: ["string", "null"]
          enum: [monitor, webhook, recipient, apikey, null]
          description: |
            Identifies the kind of entity `ref_id` refers to. Null for
            account-level events that have no associated entity.
        ref_id:
          type: ["integer", "null"]
          format: int64
          description: |
            ID of the entity the event refers to. Null for account-level
            events.
        payload:
          type: object
          additionalProperties: true
          description: |
            Event-specific context. Shape varies per `event_type`; see the
            table in SPEC §Account Events for the contract.

            `account.identity_linked`'s payload is `{provider, user_id}`
            for a Google link, an explicit GitHub link, or a first
            GitHub sign-in merged into an existing user (issue #390) — in
            all three cases `provider` is `"google"` or `"github"`
            unambiguously. For an adopted email address (issue #390,
            GitHub-verified-email adoption) the payload is
            `{provider: "email", source: "github", user_id}` — `source`
            is optional, present only on a GitHub-sourced write, and
            useful only within the 90-day `account_events` retention
            window, not as durable provenance. An explicit
            `POST /api/v1/me/identities/email/verify` link writes the
            two-field form (`{provider: "email", user_id}`, no `source`),
            same as every historical row written before issue #390.
        created_at:
          type: string
          format: date-time
        actor:
          type: string
          description: |
            Who or what caused this event (issue #323, team accounts B1):
            `"user:<id>"`, `"api-key:<id>@account:<accountID>"`,
            `"system"` for dispatcher-internal writes (e.g. auto-suspension
            from the port-scan heuristic or DNS-rebinding detection), or
            `"legacy"` for a row written by a pre-#323 binary with no actor
            columns at all. See `internal/audit.RenderActor`.

    AccountEventList:
      type: object
      properties:
        events:
          type: array
          items:
            $ref: "#/components/schemas/AccountEvent"
        next_cursor:
          type: ["string", "null"]
          description: |
            Opaque base64url cursor for the next page, or null if this was
            the last page. Pass back verbatim as `?cursor=` on the next
            request — the value is URL-safe; clients must NOT
            percent-encode it.

    Announcement:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        body:
          type: string
        published_at:
          type: string
          format: date-time
        expires_at:
          type: ["string", "null"]
          format: date-time
        updated_at:
          type: string
          format: date-time
          description: |
            Last time this row was mutated. For rows created after
            migration 025 (2026-08-01), this is exact creation time if
            never edited. For rows that already existed when that
            migration ran, no creation timestamp had ever been recorded,
            so the backfilled starting value is
            `LEAST(published_at, migration time)` instead — not the row's
            actual original creation time. Bumped by any PUT that changes
            at least one field; an empty-body no-op PUT does not bump it.
            Strictly monotonic per row — never ties or regresses across
            concurrent writes.

    AnnouncementListResponse:
      type: object
      properties:
        announcements:
          type: array
          items:
            $ref: "#/components/schemas/Announcement"

    AnnouncementInput:
      type: object
      required: [title, body]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
          description: |
            Short banner title. Server TrimSpace's leading and
            trailing whitespace before length-checking.
        body:
          type: string
          minLength: 1
          maxLength: 2000
          description: |
            Banner body / paragraph. Server TrimSpace's leading
            and trailing whitespace before length-checking.
        published_at:
          type: string
          format: date-time
          description: |
            RFC 3339 timestamp at which the banner becomes active.
            When omitted the server defaults to `now()`.
        expires_at:
          type: ["string", "null"]
          format: date-time
          description: |
            RFC 3339 timestamp after which the banner is hidden.
            When set, must be strictly greater than the effective
            `published_at`. Omit (or send `null`) for no expiry.

    AnnouncementUpdate:
      type: object
      description: |
        All fields optional. Only fields present in the request body
        are mutated; any field that is absent is left untouched on
        the row.

        `expires_at` distinguishes three states:
          - **absent**         → column unchanged.
          - **explicit `null`** → column set to NULL (no expiry).
          - **concrete value**  → column set to the supplied value.
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
        body:
          type: string
          minLength: 1
          maxLength: 2000
        published_at:
          type: string
          format: date-time
        expires_at:
          type: ["string", "null"]
          format: date-time

    Webhook:
      type: object
      description: |
        Configured notification endpoint. The signed JSON body for an HTTP
        attempt contains `event`, `monitor_id`, `account_id`, `occurred_at`,
        `delivery_id`, `attempt`, plus `reason`/`monitor_name`/`monitor_url`
        when non-empty. Since issue #165, the body also
        carries `collapsed_transitions` (integer) when a delivery superseded
        one or more earlier transitions for the same monitor+webhook pair —
        e.g. a down→up→down→up flap against a slow-to-recover subscriber
        can collapse to a survivor carrying `collapsed_transitions: 3`. The
        field is omitted entirely (not sent as 0) when nothing was
        collapsed, so existing subscribers see a byte-identical body to
        before this field existed.
      properties:
        id:
          type: integer
          format: int64
        monitor_id:
          type: integer
          format: int64
        url:
          type: string
          format: uri
        secret:
          type: string
          description: HMAC-SHA256 signing secret. Prefix `whs_live_`.
        created_at:
          type: string
          format: date-time

    Recipient:
      type: object
      description: |
        Account-level, named delivery target (issue #212) assignable to any
        number of monitors via `PUT /api/v1/monitors/{monitorID}/recipients`.
        `secret` is included ONLY on the `POST /api/v1/recipients` response
        for `type=webhook` — never on `GET`/`PATCH`, and never for
        `type=email`, `type=slack`, or `type=pagerduty`. The signed JSON
        delivery body for `webhook` is unchanged from the pre-#212
        `Webhook` contract; see its description. `type=email` (issue #321)
        sends outage/recovery alert mail instead of a signed POST — see
        `status` below for its confirmation lifecycle. `type=slack` (issue
        #359) posts an unsigned Block Kit message to a Slack incoming
        webhook URL; `config.url` is always masked (the real token is
        never echoed). `type=pagerduty` (issue #363) posts to the
        PagerDuty Events API v2; `config.integration_key` is always masked
        (the real key is never echoed).
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          maxLength: 255
        type:
          type: string
          enum: [webhook, email, slack, pagerduty]
        config:
          type: object
          description: |
            Channel-specific config: `url` for `type=webhook` or
            `type=slack`, `address` for `type=email`, `integration_key`
            for `type=pagerduty`, plus an optional `channel` for
            `type=slack`. For `type=slack`, `url` is always the masked
            form `https://hooks.slack.com/services/{team}/{bot}/********`
            (or the bare `https://hooks.slack.com/services/********` for a
            stored URL that doesn't match the expected shape) — the real
            token is never returned. For `type=pagerduty`,
            `integration_key` is always the masked form `"********"` plus
            the last 4 characters (or the bare `"********"` for a stored
            key that doesn't match the expected shape) — the real key is
            never returned.
          properties:
            url:
              type: string
              format: uri
              maxLength: 2048
            address:
              type: string
              format: email
              description: Normalized (lowercased) email address.
            channel:
              type: string
              description: |
                `type=slack` only. Optional channel/user override (e.g.
                `"#ops"` or `"@alice"`); omitted when unset.
            integration_key:
              type: string
              description: |
                `type=pagerduty` only. Masked — see this object's own
                description above.
        default:
          type: boolean
          description: |
            When true, this recipient is attached automatically to every
            new monitor created on this account, at create time, in the
            same transaction as the monitor row. A monitor born down does
            NOT notify a newly-attached default recipient at creation —
            the create response already shows the monitor's state
            directly, and the default's first notification is the
            monitor's next qualifying transition.
        status:
          type: string
          enum: [active, pending, disabled]
          description: |
            `pending`: a `type=email` recipient awaiting confirmation (no
            alerts sent yet). `disabled`: unsubscribed (via the mailed
            unsubscribe link) — alerts stopped; re-enabling requires
            deleting and recreating the recipient, which re-confirms from
            scratch. `active`: every `type=webhook`, `type=slack`, and
            `type=pagerduty` recipient, and a confirmed, non-disabled
            `type=email` recipient.
        events:
          type: array
          items:
            type: string
            enum: [down, up, flapping]
          minItems: 1
          description: Non-empty subset of event types this recipient is notified for.
        monitor_ids:
          type: array
          items:
            type: integer
            format: int64
          description: Monitors this recipient is currently attached to. `[]` for none.
        secret:
          type: string
          description: |
            HMAC-SHA256 signing secret, prefix `whs_live_`. Present ONLY on
            the `POST /api/v1/recipients` response for `type=webhook` (and
            the deprecated `GET /api/v1/monitors/{monitorID}/webhook`
            alias, for compatibility) — omitted everywhere else, and
            always omitted for `type=email`, `type=slack`, and
            `type=pagerduty` (neither a slack nor a pagerduty recipient
            has a secret at all — the incoming webhook URL or the routing
            key itself is the credential, and both live in `config`,
            masked).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    RecipientCreate:
      type: object
      required: [name, type, config]
      properties:
        name:
          type: string
          maxLength: 255
        type:
          type: string
          enum: [webhook, email, slack, pagerduty]
        config:
          type: object
          description: |
            `{"url": "..."}` for `type=webhook`, `{"address": "..."}` for
            `type=email`, `{"url": "...", "channel": "..."}` (channel
            optional) for `type=slack`, `{"integration_key": "..."}` for
            `type=pagerduty`. A reserved key the chosen `type` does not
            allow — `address` on webhook/slack/pagerduty, `url`/`channel`
            on email or pagerduty, `channel` on webhook,
            `integration_key` on any type but pagerduty — is 400, matched
            case-insensitively and regardless of value (including `null`
            or `""`); two keys that fold to the same reserved name (e.g.
            `url` and `URL`) are also 400. For `type=slack`, `config.url`
            must match
            `https://hooks.slack.com/services/{team}/{bot}/{token}`
            exactly (no port, userinfo, query string, or fragment, no
            trailing slash). For `type=pagerduty`,
            `config.integration_key` must be exactly 32 characters of
            `[A-Za-z0-9]`.
          properties:
            url:
              type: string
              format: uri
              maxLength: 2048
            address:
              type: string
              format: email
            channel:
              type: string
              description: '`type=slack` only. Optional; e.g. "#ops" or "@alice".'
            integration_key:
              type: string
              description: |
                `type=pagerduty` only. The PagerDuty Events API v2
                routing/integration key, exactly 32 characters of
                `[A-Za-z0-9]`.
        default:
          type: boolean
          default: false
        events:
          type: array
          items:
            type: string
            enum: [down, up, flapping]
          minItems: 1
          description: Defaults to `["down","up","flapping"]` when omitted.

    RecipientPatch:
      type: object
      description: |
        Every field is optional; only fields present are changed. `type`
        must not be present at all. `name`/`default`/`events`/`config`
        each reject an explicit JSON `null`. Changing `config.address` on
        a `type=email` recipient resets it to `status:"pending"` (old
        confirm/unsubscribe links stop working, a new confirmation is
        sent) and records `recipient.updated` with
        `changes.address: {"changed": true}` — never the old or new
        address itself. For `type=slack` (issue #359), `config.url` and
        `config.channel` are each independently optional — at least one
        is required when `config` is present — and changing either
        records `changes.url: {"changed": true}` (never the URL text) and
        `changes.channel: {"old": ..., "new": ...}` respectively, and
        cancels the recipient's own still-queued deliveries. Setting
        `config.channel` to `null` or `""` clears the override. For
        `type=pagerduty` (issue #363), `config.integration_key` is
        required when `config` is present; changing it records
        `changes.integration_key: {"changed": true}` (never the key
        itself) and cancels the recipient's own still-queued deliveries —
        it does not resolve an incident the old key already opened in
        PagerDuty.
      properties:
        name:
          type: string
          maxLength: 255
        config:
          type: object
          properties:
            url:
              type: string
              format: uri
              maxLength: 2048
            address:
              type: string
              format: email
            channel:
              type: ["string", "null"]
              description: |
                `type=slack` only. `null` or `""` clears the channel
                override back to the incoming webhook's own default.
            integration_key:
              type: string
              description: |
                `type=pagerduty` only. Exactly 32 characters of
                `[A-Za-z0-9]`.
        default:
          type: boolean
        events:
          type: array
          items:
            type: string
            enum: [down, up, flapping]
          minItems: 1

    RecipientTestResult:
      type: object
      description: Response body of `POST /api/v1/recipients/{recipientID}/test`.
      properties:
        recipient_id:
          type: integer
          format: int64
        type:
          type: string
          enum: [webhook, email, slack, pagerduty]
        status:
          type: string
          enum: [delivered, accepted, failed]
          description: |
            `accepted` is PagerDuty-only: PagerDuty's Events API v2 is
            asynchronous, so a 2xx response to the test trigger and its
            immediate resolve does not prove the test incident actually
            closed — see `message`.
        response_code:
          type: integer
          description: |
            Webhook: the destination's HTTP status. Email: 202 on
            success, SendGrid's status on failure, or 0 for a transport
            error. Slack: Slack's HTTP status, 3xx included (a redirect
            is never followed and is reported as its own 3xx code) — only
            a transport failure omits this field (502). PagerDuty: the
            trigger's HTTP status on success or on a trigger rejection;
            the resolve's HTTP status when the trigger succeeded but the
            resolve failed; omitted for a transport error on either
            request. Omitted when no response was obtained at all (502).
        error:
          type: string
          description: |
            Present only when status is "failed" or the request 502s. For
            `type=pagerduty`, this is always fixed outcome text (e.g.
            `"PagerDuty rejected the event (HTTP 400)"`) — never a
            response body, which could echo the routing key — and, for
            every failure except a cleanly-rejected trigger, is followed
            by a warning that a test incident may remain open in
            PagerDuty.
        message:
          type: string
          description: |
            Present only for `type=pagerduty` on a successful
            `status="accepted"` test send: notes that PagerDuty processes
            events asynchronously and a test incident can remain open,
            to be resolved in PagerDuty if so. Omitted for every other
            type and outcome.

    MonitorRecipients:
      type: object
      description: |
        `GET`/`PUT` responses always carry both `recipient_ids` and
        `recipients` (resolved from the same snapshot), so a client reading
        either field sees the attached set. `PUT` request bodies accept
        exactly one of `recipient_ids` (today's form) or `recipients`
        (issue #338, per-pair `down_delay_s`) — specifying both, or
        specifying neither, is 400. Either field, when present, must not
        be JSON `null` (400) — only an explicit `[]` detaches every
        recipient. Omitting `down_delay_s` on a `recipients` entry keeps an
        already-attached pair's stored delay, or defaults a newly-attached
        pair to `0`; the `recipient_ids` form always keeps every attached
        pair's existing delay.
      properties:
        recipient_ids:
          type: array
          items:
            type: integer
            format: int64
          description: Must be positive and unique.
        recipients:
          type: array
          items:
            type: object
            required: [id]
            additionalProperties: false
            properties:
              id:
                type: integer
                format: int64
              down_delay_s:
                type: integer
                enum: [0, 60, 120, 180, 240, 300, 600, 900, 1200, 1500, 1800, 3600, 7200, 10800, 14400]
                description: |
                  Seconds to hold a "down" alert before sending it, so a
                  blip shorter than this never reaches the recipient at
                  all — like Monitive's alert delay. `up`/`flapping` are
                  always immediate. Optional on write; always present on
                  read.
              attached_at:
                type: string
                format: date-time
                description: |
                  Read-only (issue #396) — when this monitor/recipient
                  attachment was made, stamped by the Postgres server
                  clock. A kept attachment keeps its `attached_at` across
                  a `PUT` that only changes its `down_delay_s`. This is
                  the per-monitor boundary
                  `DELETE /api/v1/recipients/{recipientID}?replaced_by=`
                  measures a replacement recipient's delivery coverage
                  against. Ignored if sent on a request.

    PushSubscriptionRequest:
      type: object
      required: [endpoint, p256dh, auth]
      properties:
        endpoint:
          type: string
          format: uri
          description: |
            Push service endpoint URL from the browser's
            `PushSubscription`. Must be HTTPS with a non-empty host.
          example: https://fcm.googleapis.com/fcm/send/abcd1234
        p256dh:
          type: string
          description: |
            Base64url-encoded P-256 ECDH public key from the browser.
            Write-only — never returned in any response.
        auth:
          type: string
          description: |
            Base64url-encoded auth secret from the browser, used
            server-side to encrypt push payloads. Write-only —
            never returned in any response.
        user_agent:
          type: ["string", "null"]
          description: Optional client UA string for operator-facing UI.

    PushSubscriptionResponse:
      type: object
      properties:
        id:
          type: integer
          format: int64
        endpoint:
          type: string
          format: uri
        user_agent:
          type: ["string", "null"]
        created_at:
          type: string
          format: date-time
      description: |
        Persisted push subscription as exposed via the REST API. The
        `p256dh` and `auth` write-only secrets are intentionally
        omitted to prevent exfiltration via a compromised dashboard
        token.

    WebhookDelivery:
      type: object
      properties:
        id:
          type: integer
          format: int64
        webhook_id:
          type: integer
          format: int64
          description: |
            A recipient id (issue #212) — the field name is kept for wire
            compatibility; it has not named a row in the legacy `webhooks`
            table since the recipients migration. A row can represent an
            email delivery (issue #321), a Slack post (issue #359), or a
            PagerDuty Events API v2 event (issue #363) as well as a
            webhook POST — check the referenced recipient's `type` to tell
            which; `last_response_code` is SendGrid's status for email
            (202 on success, 0 on transport error), the destination's HTTP
            status for webhook, Slack's own HTTP status for slack
            (`response_body` carries Slack's error string, e.g.
            `"no_service"`), and PagerDuty's own HTTP status for pagerduty
            (`response_body` carries FIXED outcome text, e.g.
            `"PagerDuty accepted the event (HTTP 202)"` or
            `"PagerDuty rate-limited the event (HTTP 429)"` — never
            PagerDuty's actual response body, which could echo the
            routing key) — for both slack and pagerduty, only 429 and 5xx
            retry; every other non-2xx, 3xx included, is terminal on the
            first attempt. A pagerduty row with no `outage_id` — enqueued
            before issue #363, or an "up" recovery whose onset transition
            left no open incident to inherit an outage id from — fails
            immediately with `response_body` `"no outage key"` and no
            request ever sent; such a recovery is never reported to
            PagerDuty.
        monitor_id:
          type: integer
          format: int64
        account_id:
          type: integer
          format: int64
        event_type:
          type: string
          example: down
        payload_hash:
          type: ["string", "null"]
        status:
          type: string
          enum: [pending, retrying, delivered, failed, superseded, cancelled]
          description: |
            `superseded` (added for issue #165): an older `pending`/`retrying`
            delivery for the same webhook+monitor pair was replaced by a
            newer one, bounding a swarm of stale notifications after an
            outage recovers to at most one live delivery per pair at a
            time. Not immutably terminal: the
            replacement is not atomic with dispatch, so a delivery already
            in flight when it was superseded can still complete and
            transition to `delivered` for that one attempt — a subscriber
            can still receive a delivery for a transition that was
            superseded before it arrived.

            `cancelled` (added for issue #338): a delayed `down` delivery
            whose outage recovered before its `down_delay_s` elapsed, or
            whose recipient's events narrowed to drop `down` while the
            row was still pending. Never reused for `superseded` — nothing
            replaced the row, it was simply never sent. Also not
            immutably terminal: a row already claimed for dispatch when an
            events-narrowing edit cancels it can still complete and
            transition to `delivered`.
        attempt_count:
          type: integer
        collapsed_count:
          type: integer
          description: |
            The number of older live (`pending`/`retrying`) delivery rows
            this row superseded, plus their own accumulated collapsed
            counts. Zero for a delivery that superseded nothing. This is
            NOT a count of POSTs guaranteed suppressed, and NOT a count of
            transitions the subscriber missed: a superseded row can still
            be in flight and reach `delivered` after being counted here.
            Mirrors the outbound `collapsed_transitions` payload field.
        next_attempt_at:
          type: string
          format: date-time
        last_attempt_at:
          type: ["string", "null"]
          format: date-time
        last_response_code:
          type: ["integer", "null"]
        response_body:
          type: ["string", "null"]
          description: |
            Truncated copy (≤4096 bytes, NUL-stripped, valid UTF-8) of the
            subscriber's HTTP response body from the last attempt, for
            `type=webhook`/`type=email`/`type=slack`. Present to help
            debug rejected deliveries; null for rows recorded before this
            was captured or when the subscriber returned no body. For
            `type=pagerduty` (issue #363), this is NEVER the actual
            response body — it is always one of a small set of fixed
            outcome strings (e.g. `"PagerDuty accepted the event (HTTP
            202)"`, `"PagerDuty rejected the event (HTTP 400)"`,
            `"PagerDuty request failed (timeout)"`, or `"no outage key"`),
            because a real PagerDuty response body could echo the request
            back, including the routing key.
        created_at:
          type: string
          format: date-time
        cancelled_at:
          type: ["string", "null"]
          format: date-time
          description: Set only when status is `cancelled` (issue #338).
        enqueued_at:
          type: ["string", "null"]
          format: date-time
          description: |
            The Postgres server clock's own stamp of this row's insert
            moment (issue #396) — distinct from `created_at`, which is
            the dispatcher's own Go-clock transition time and so cannot
            be compared against anything the database generates. Null
            for a row written before this column existed; every reader
            treats null as "before any boundary". Used by the
            `?recipient_id=&order=id` drain scan (see
            `/api/v1/webhook-deliveries`'s description) and the guarded
            `DELETE /api/v1/recipients/{recipientID}?replaced_by=`
            endpoint to decide whether a row predates a replacement
            recipient's attachment.

    WebhookDeliveryListResponse:
      type: object
      properties:
        deliveries:
          type: array
          items:
            $ref: "#/components/schemas/WebhookDelivery"
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    WebhookDeliveryCursorListResponse:
      type: object
      description: |
        The response shape for `?order=id` (issue #396) — keyset-paginated,
        ascending by `id`. No `total`/`offset`: cursor mode never runs the
        COUNT(*) that would produce one. `next_cursor` is always present,
        even on the last page, where its value is `null`.
      required: [deliveries, limit, next_cursor]
      properties:
        deliveries:
          type: array
          items:
            $ref: "#/components/schemas/WebhookDelivery"
        limit:
          type: integer
        next_cursor:
          type: ["string", "null"]
          description: |
            Opaque base64url token. Pass back verbatim as `?cursor=` to
            fetch the next page. `null` means no further page exists.

    APIKeyCreated:
      description: API key with the one-time plaintext included.
      type: object
      properties:
        id:
          type: integer
          format: int64
        account_id:
          type: integer
          format: int64
        key_prefix:
          type: string
          example: umk_live_a1b2c3d4
        label:
          type: ["string", "null"]
        revoked_at:
          type: ["string", "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
        key:
          type: string
          description: |
            Full plaintext API key. **Shown once** — store it securely.
          example: umk_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6

    Timings:
      type: object
      properties:
        total_ms:
          type: number
          format: float
        dns_ms:
          type: number
          format: float
        tcp_ms:
          type: number
          format: float
        tls_ms:
          type: number
          format: float
        ttfb_ms:
          type: number
          format: float
        server_ms:
          type: number
          format: float

    TestResult:
      type: object
      properties:
        monitor_id:
          type: integer
          format: int64
        timestamp:
          type: string
          format: date-time
        region:
          type: string
        probe_location:
          type: string
        state:
          type: string
          enum: [up, down]
        status_code:
          type: integer
        error:
          type: string
        body_bytes:
          type: integer
          format: int64
        timings:
          $ref: "#/components/schemas/Timings"
        packet_loss_pct:
          type: number
          minimum: 0
          maximum: 100
          description: |
            Ping-only packet loss percentage (0-100). Omitted (not present
            in the object) when not measured — every non-ping check result,
            and a ping probe-side failure that never reached the target. A
            measured 0% loss is present with value 0, distinct from omitted.

    FeedTokenResponse:
      type: object
      properties:
        token:
          type: string
          example: fd_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d

    FeedIncident:
      type: object
      properties:
        id:
          type: integer
          format: int64
        monitor_name:
          type: string
        monitor_url:
          type: string
        monitor_type:
          type: string
        type:
          type: string
          enum: [down, flapping]
        started_at:
          type: string
          format: date-time
        resolved_at:
          type: ["string", "null"]
          format: date-time

    FeedResponse:
      type: object
      properties:
        account:
          type: object
          properties:
            github_username:
              type: string
        incidents:
          type: array
          items:
            $ref: "#/components/schemas/FeedIncident"

  headers:
    XRateLimitLimit:
      schema:
        type: integer
      description: |
        Bucket capacity for the limiter that produced this response. `200`
        for the global per-account dispatcher cap; `20` on a 429 from the
        per-route monitor-create limiter.
    XRateLimitRemaining:
      schema:
        type: integer
      description: |
        Tokens left in the bucket after this request was charged
        (floor of the post-charge token count). `0` on a 429 means the
        request was rejected with the bucket empty.
    XRateLimitReset:
      schema:
        type: integer
      description: |
        Unix epoch seconds at which the bucket would be full again if no
        further requests are made. Sub-second fragments are rounded up so
        `Reset - now` is never less than the actual refill duration.
    StatusPageAge:
      schema:
        type: integer
      description: |
        Seconds since the origin built the `View` currently being served —
        `0` on the request that populated the cache, growing on every
        subsequent request served from that same entry. Computed the same
        way (live clock minus the cache entry's insertion time) for all
        five status-host formats (issue #368, badge added by #383), so two
        requests served from one cache entry at the same instant carry
        identical `Age` — this is not a promise that `Age` itself stays
        constant across requests over time.
    StatusPageCacheControl:
      schema:
        type: string
      description: |
        `public, max-age=<N>`, where `N` is the serving cache entry's full
        freshness lifetime as cached — `60` for a settled view (the live
        status agrees with the open incident rows), `5` for an unsettled
        one — never reduced by `Age`: an HTTP cache already subtracts
        `Age` from `max-age` itself to get the remaining lifetime, so an
        already-reduced `max-age` would double-subtract. Identical across
        all five formats for one cache entry.
    StatusPageRetryAfter:
      schema:
        type: integer
      description: |
        Fixed at `5` — the shared view-build deadline
        (`StatusPageBuildTimeout`) that expired while this request was
        still live.

  responses:
    BadRequest:
      description: Invalid request.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: invalid credentials
    Forbidden:
      description: |
        Authenticated but the caller is not permitted on this route.
        Body is the opaque `{"error":"forbidden"}` envelope used for
        every admin-gated 403 across the API; the same shape is used
        whether the caller is missing the admin role or the route
        otherwise denies them, so clients cannot distinguish the two.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: forbidden
    InternalError:
      description: Internal server error.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    AccountSuspended:
      description: |
        Account is currently suspended. On `POST /api/v1/monitors`, and on
        a `PUT /api/v1/monitors/{monitorID}` that changes `url` or `type`
        (issue #335), this can also be returned by the per-account
        port-scan heuristic when the triggering request crossed the
        port-fan threshold inside the sliding window (10 minutes by
        default; `SCAN_WINDOW`). A host-fan trip does NOT suspend the
        account — see the 429 response instead.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: account suspended; contact support
    RequestEntityTooLarge:
      description: |
        Request body exceeds the 64 KB hard limit. Per docs/SPEC.md
        §Hard Limits the dispatcher rejects any payload larger than
        65,536 bytes before it is decoded.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: request body exceeds 64 KB limit
    UnsupportedMediaType:
      description: |
        The request `Content-Type` is not `application/json`. Write
        operations (POST, PUT, PATCH) require that header; a missing or
        non-JSON media type is rejected before the body is read.
        The `charset` parameter is accepted and ignored, so
        `application/json; charset=utf-8` is valid.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: unsupported media type, expected application/json
    MethodNotAllowed:
      description: |
        The HTTP method is not supported for this path. The `Allow`
        response header lists the methods this endpoint accepts.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: method not allowed
    RateLimited:
      description: |
        Per-account dispatcher rate limit exceeded (200 requests/minute).
        The `Retry-After` header gives the integer seconds until at least
        one token has refilled. The `X-RateLimit-*` trio reflects the
        bucket whose rejection produced this response — usually the global
        200/min cap; on `POST /api/v1/monitors` a 429 may instead reflect
        the per-route 20/min creation limit.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the next allowed request.
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitReset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: account rate limit exceeded; retry in 1s
    MonitorUpdateRateLimited:
      description: |
        Per-account dispatcher rate limit exceeded on
        `PUT /api/v1/monitors/{monitorID}` (issue #335). Two possible
        causes, distinguishable by `X-RateLimit-Reason` header (absent on
        the global-cap rejection) — no creation-cap case, since `PUT` has
        no equivalent to `POST`'s 20/min creation limiter:

        1. **Global cap** (200/min per account): `error` contains
           `"account rate limit exceeded; retry in <N>s"`.
           `X-RateLimit-Limit: 200`.
        2. **Host-fan** (≥8, by default — `SCAN_HOSTFAN_THRESHOLD` —
           distinct registrable domains in 10 min, by default —
           `SCAN_WINDOW`), tripped by a request that changes `url` or
           `type`: body is the `HostFanError` schema with
           `retry_after_seconds` and `limit`. `X-RateLimit-Reason:
           host_fan`. Account is NOT suspended. `X-RateLimit-Limit`/
           `X-RateLimit-Window` and the body's `limit`/`window_seconds`
           always reflect the actual configured values.

        The `Retry-After` header is present in both cases (integer >= 1).
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the next allowed request.
        X-RateLimit-Limit:
          $ref: "#/components/headers/XRateLimitLimit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/XRateLimitRemaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/XRateLimitReset"
        X-RateLimit-Reason:
          schema:
            type: string
            enum: [host_fan]
          description: Present only on host-fan rejections.
        X-RateLimit-Window:
          schema:
            type: integer
          description: Sliding-window length in seconds (present on host-fan rejections).
      content:
        application/json:
          schema:
            anyOf:
              - $ref: "#/components/schemas/Error"
              - $ref: "#/components/schemas/HostFanError"
          examples:
            globalCap:
              summary: Global rate cap (200/min)
              value:
                error: account rate limit exceeded; retry in 1s
            hostFan:
              summary: Host-fan trip (≥8 distinct domains in 10 min — both defaults, env-overridable)
              value:
                error: "too many distinct domains in a short window; slow monitor creation or target changes and retry"
                limit: 7
                window_seconds: 600
                retry_after_seconds: 347
    ServiceUnavailable:
      description: |
        The dispatcher's global kill switch is engaged; no checks will be
        dispatched until an operator lifts it. The `Retry-After` header
        suggests a polite re-try window.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Suggested seconds before retrying.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: kill_switch_active
              message:
                type: string
                example: check dispatches temporarily disabled
            required: [error, message]
    StatusPageNotFound:
      description: |
        Unknown or malformed token, a page that is off, a deleted
        monitor, or a suspended account — all indistinguishable, and all
        five status-host formats (`/{token}`, `/{token}/feed.rss`,
        `/{token}/feed.atom`, `/{token}/status.json`,
        `/{token}/badge.svg`) return this identical shape. A malformed
        token (wrong length, prefix, or a non-hex/uppercase character)
        never reaches the database lookup the other cases do.
    StatusPageInternalError:
      description: |
        The token lookup, the view build, or the format's own render
        step failed for a reason other than the deliberately
        indistinguishable 404 cases above — a connection failure, query
        timeout, or scan error. Distinct from `503` below, which is
        specifically the shared view-build deadline expiring.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    StatusPageUnavailable:
      description: |
        The shared view-build deadline (`StatusPageBuildTimeout`, 10s)
        was exceeded while this request was still live — e.g. database
        slowness. Distinct from a client disconnecting mid-build, which
        writes no response at all. Every one of the five status-host
        formats returns this identical shape, including the
        `Retry-After` header.
      headers:
        Retry-After:
          $ref: "#/components/headers/StatusPageRetryAfter"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
