openapi: 3.1.0
info:
  title: zerolog Monitoring API
  version: "1.0.0"
  summary: Power, network and website state for Europe and the Americas. Static rules, no AI.
  description: |
    Read endpoints are public, need no key, send `Access-Control-Allow-Origin: *` and
    `Cache-Control: public, max-age=60, stale-while-revalidate=240`.
    Write endpoints need a Cloudflare Turnstile token (`cf-turnstile-response`) or the monitor's
    admin token. Bodies are `application/x-www-form-urlencoded` or `application/json`; unknown fields
    are dropped. Every error has the form `{"error": {"code": "...", "message": "..."}}`.
    States: ok (no disruption reported), suspected, outage, resolved, expired, no_data (no source),
    stale (our data is older than allowed). A partnership of sll-hosting.com and zerolog.ch.
  license:
    name: Data attributions see /monitoring/methodik
  contact:
    email: info@zerolog.ch
servers:
  - url: https://zerolog.ch/monitoring
  - url: https://zerolog-monitoring.cecilia-west.workers.dev/monitoring
paths:
  /api/v1/summary.json:
    get:
      operationId: getSummary
      summary: Totals across all countries
      responses:
        "200":
          description: Summary
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Summary"}
  /api/v1/country/{cc}.json:
    get:
      operationId: getCountry
      parameters:
        - {name: cc, in: path, required: true, schema: {type: string, pattern: "^[a-zA-Z]{2}$"}}
      responses:
        "200":
          description: Country
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Country"}
        "404": {$ref: "#/components/responses/Error"}
  /api/v1/region/{code}.json:
    get:
      operationId: getRegion
      parameters:
        - {name: code, in: path, required: true, description: ISO 3166-2, schema: {type: string, example: CH-AG}}
      responses:
        "200":
          description: Region
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Region"}
        "404": {$ref: "#/components/responses/Error"}
  /api/v1/website/{id}.json:
    get:
      operationId: getWebsite
      parameters:
        - {name: id, in: path, required: true, schema: {type: string, pattern: "^[a-z2-7]{10}$"}}
      responses:
        "200":
          description: Website monitor (url only when listed)
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Website"}
        "404": {$ref: "#/components/responses/Error"}
  /api/v1/search:
    get:
      operationId: search
      parameters:
        - {name: q, in: query, required: true, schema: {type: string, minLength: 2, maxLength: 100}}
        - {name: typ, in: query, schema: {type: string, enum: [strom, netz, website], default: strom}}
      responses:
        "200":
          description: At most 20 results
          content:
            application/json:
              schema: {$ref: "#/components/schemas/SearchResult"}
        "400": {$ref: "#/components/responses/Error"}
  /api/v1/status.json:
    get:
      operationId: getStatus
      responses:
        "200":
          description: Pipeline health
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Status"}
  /api/v1/map/{topic}.json:
    get:
      operationId: getMap
      parameters:
        - {name: topic, in: path, required: true, schema: {type: string, enum: [power, network]}}
      responses:
        "200":
          description: State per country and region (no_data omitted)
          content:
            application/json:
              schema: {$ref: "#/components/schemas/MapState"}
  /api/v1/monitors:
    post:
      operationId: createMonitor
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: {$ref: "#/components/schemas/MonitorCreate"}
          application/json:
            schema: {$ref: "#/components/schemas/MonitorCreate"}
      responses:
        "201":
          description: Created and active
          content:
            application/json:
              schema: {$ref: "#/components/schemas/MonitorCreated"}
        "202":
          description: Created on the waiting list
          content:
            application/json:
              schema: {$ref: "#/components/schemas/MonitorCreated"}
        "400": {$ref: "#/components/responses/Error"}
        "403": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
  /api/v1/monitors/{id}:
    post:
      operationId: updateMonitor
      parameters: [{$ref: "#/components/parameters/MonitorId"}]
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: {$ref: "#/components/schemas/MonitorUpdate"}
          application/json:
            schema: {$ref: "#/components/schemas/MonitorUpdate"}
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [id, updated]
                properties:
                  id: {type: string}
                  updated: {type: array, items: {type: string}}
                  email: {type: [string, "null"]}
        "403": {$ref: "#/components/responses/Error"}
        "404": {$ref: "#/components/responses/Error"}
  /api/v1/monitors/{id}/delete:
    post:
      operationId: deleteMonitor
      parameters: [{$ref: "#/components/parameters/MonitorId"}]
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: {$ref: "#/components/schemas/TokenOnly"}
          application/json:
            schema: {$ref: "#/components/schemas/TokenOnly"}
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                required: [id, deleted]
                properties:
                  id: {type: string}
                  deleted: {type: boolean}
        "403": {$ref: "#/components/responses/Error"}
        "404": {$ref: "#/components/responses/Error"}
  /api/v1/monitors/{id}/verify:
    post:
      operationId: verifyMonitor
      parameters: [{$ref: "#/components/parameters/MonitorId"}]
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: {$ref: "#/components/schemas/TokenOnly"}
      responses:
        "200":
          description: Verified
          content:
            application/json:
              schema:
                type: object
                required: [verified]
                properties:
                  verified: {type: boolean}
                  method: {type: [string, "null"]}
        "403": {$ref: "#/components/responses/Error"}
        "404": {$ref: "#/components/responses/Error"}
        "422": {$ref: "#/components/responses/Error"}
  /api/v1/monitors/{id}/abuse:
    post:
      operationId: reportAbuse
      parameters: [{$ref: "#/components/parameters/MonitorId"}]
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [reason, cf-turnstile-response]
              properties:
                reason: {type: string, enum: [spam, malware, phishing, illegal, privacy, other]}
                note: {type: string, maxLength: 1000}
                cf-turnstile-response: {type: string}
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: {type: boolean}
                  duplicate: {type: boolean}
        "400": {$ref: "#/components/responses/Error"}
        "403": {$ref: "#/components/responses/Error"}
        "404": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
  /api/v1/requests:
    post:
      operationId: createRequest
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: {$ref: "#/components/schemas/RequestCreate"}
          application/json:
            schema: {$ref: "#/components/schemas/RequestCreate"}
      responses:
        "201":
          description: Created, or merged into an identical open request (merged=true)
          content:
            application/json:
              schema:
                type: object
                required: [id, merged]
                properties:
                  id: {type: integer}
                  merged: {type: boolean}
                  votes: {type: [integer, "null"]}
        "400": {$ref: "#/components/responses/Error"}
        "403": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
  /api/v1/requests/{id}/vote:
    post:
      operationId: voteRequest
      parameters:
        - {name: id, in: path, required: true, schema: {type: integer}}
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [cf-turnstile-response]
              properties:
                cf-turnstile-response: {type: string}
      responses:
        "200":
          description: Counted
          content:
            application/json:
              schema:
                type: object
                required: [id, votes]
                properties:
                  id: {type: integer}
                  votes: {type: integer}
        "403": {$ref: "#/components/responses/Error"}
        "404": {$ref: "#/components/responses/Error"}
        "409": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
  /health:
    get:
      operationId: health
      summary: Touches D1 and KV; 503 when either fails
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Health"}
        "503":
          description: Unhealthy
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Health"}
components:
  parameters:
    MonitorId:
      name: id
      in: path
      required: true
      schema: {type: string, pattern: "^[a-z2-7]{10}$"}
  responses:
    Error:
      description: Error with a stable code
      content:
        application/json:
          schema: {$ref: "#/components/schemas/Error"}
  schemas:
    State:
      type: string
      enum: [ok, suspected, outage, resolved, expired, no_data, stale]
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [invalid_url, url_blocked, ip_literal, private_address, port_not_allowed, credentials_in_url, rate_limited,
                     host_limit, turnstile_failed, capacity_waitlist, not_found, forbidden, invalid_input, internal,
                     verify_failed, already_voted, method_not_allowed]
            message: {type: string}
            field: {type: string}
    Counts:
      type: object
      required: [outage, suspected]
      properties:
        outage: {type: integer}
        suspected: {type: integer}
    Summary:
      type: object
      required: [generated_at, power, network, websites, last_run, sources]
      properties:
        generated_at: {type: [string, "null"], format: date-time}
        power: {$ref: "#/components/schemas/Counts"}
        network: {$ref: "#/components/schemas/Counts"}
        websites:
          type: object
          required: [monitored, down]
          properties:
            monitored: {type: integer}
            down: {type: integer}
        last_run: {type: [string, "null"], format: date-time}
        sources:
          type: object
          required: [ok, degraded, disabled]
          properties:
            ok: {type: integer}
            degraded: {type: integer}
            disabled: {type: integer}
        stale: {type: boolean}
    Event:
      type: object
      required: [id, topic, cc, status, started_at, updated_at]
      properties:
        id: {type: integer}
        topic: {type: string, enum: [power, network]}
        cc: {type: string}
        region_code: {type: [string, "null"]}
        provider: {type: [string, "null"]}
        status: {type: string, enum: [suspected, outage, resolved, expired]}
        official: {type: boolean}
        customers: {type: [integer, "null"]}
        title: {type: [string, "null"]}
        started_at: {type: string}
        updated_at: {type: string}
        ended_at: {type: [string, "null"]}
        evidence:
          type: array
          items:
            type: object
            properties:
              publisher: {type: [string, "null"]}
              title: {type: [string, "null"]}
              url: {type: [string, "null"]}
              at: {type: string}
              kind: {type: string}
    Source:
      type: object
      required: [id]
      properties:
        id: {type: string}
        type: {type: [string, "null"]}
        attribution: {type: [string, "null"]}
    Country:
      type: object
      required: [cc, name, generated_at, power, network, regions, sources]
      properties:
        cc: {type: string}
        name: {type: string}
        generated_at: {type: [string, "null"]}
        stale: {type: boolean}
        power:
          type: object
          required: [state, events]
          properties:
            state: {$ref: "#/components/schemas/State"}
            events: {type: array, items: {$ref: "#/components/schemas/Event"}}
        network:
          type: object
          required: [state, providers]
          properties:
            state: {$ref: "#/components/schemas/State"}
            events: {type: array, items: {$ref: "#/components/schemas/Event"}}
            providers:
              type: array
              items:
                type: object
                required: [id, name]
                properties:
                  id: {type: string}
                  name: {type: string}
                  note: {type: [string, "null"]}
        regions:
          type: array
          items:
            type: object
            required: [code, name, power, network]
            properties:
              code: {type: string}
              name: {type: string}
              slug: {type: string}
              power: {$ref: "#/components/schemas/State"}
              network: {$ref: "#/components/schemas/State"}
        sources: {type: array, items: {$ref: "#/components/schemas/Source"}}
    Region:
      type: object
      required: [code, cc, name, power, network, places_top, history7d, sources]
      properties:
        code: {type: string}
        cc: {type: string}
        name: {type: string}
        generated_at: {type: [string, "null"]}
        stale: {type: boolean}
        power:
          type: object
          required: [state, events]
          properties:
            state: {$ref: "#/components/schemas/State"}
            events: {type: array, items: {$ref: "#/components/schemas/Event"}}
        network:
          type: object
          required: [state, events]
          properties:
            state: {$ref: "#/components/schemas/State"}
            events: {type: array, items: {$ref: "#/components/schemas/Event"}}
        places_top:
          type: array
          items:
            type: object
            required: [id, name, anchor]
            properties:
              id: {type: integer}
              name: {type: string}
              anchor: {type: string}
        history7d:
          type: array
          items:
            type: object
            required: [day, topic, worst]
            properties:
              day: {type: string, format: date}
              topic: {type: string}
              worst: {type: string}
              events: {type: integer}
        sources: {type: array, items: {$ref: "#/components/schemas/Source"}}
    Website:
      type: object
      required: [id, host, name, state, since, uptime, last_check, response_ms, verified, listed]
      properties:
        id: {type: string}
        host: {type: string}
        name: {type: string}
        url: {type: string, description: only when verified or seeded}
        state: {type: string, enum: [up, slow, down, unknown]}
        status: {type: string, enum: [active, paused, disabled, waitlist]}
        stale: {type: boolean, description: "true when the last check is older than 15 minutes; state is then unknown"}
        since: {type: [string, "null"]}
        uptime:
          type: object
          required: [h24, d7, d90]
          properties:
            h24: {type: [number, "null"]}
            d7: {type: [number, "null"]}
            d90: {type: [number, "null"]}
        last_check: {type: [string, "null"]}
        response_ms: {type: [integer, "null"]}
        http_code: {type: [integer, "null"]}
        verified: {type: boolean}
        listed: {type: boolean}
        tls_available: {type: boolean}
    SearchResult:
      type: object
      required: [q, typ, results]
      properties:
        q: {type: string}
        typ: {type: string}
        results:
          type: array
          maxItems: 20
          items:
            type: object
            required: [kind, ref, label, sub, url]
            properties:
              kind: {type: string, enum: [country, region, place, provider, website]}
              ref: {type: string}
              label: {type: string}
              sub: {type: string}
              url: {type: string}
    Status:
      type: object
      required: [generated_at, runs, gaps, sources]
      properties:
        generated_at: {type: [string, "null"]}
        runs:
          type: array
          items:
            type: object
            required: [started_at, finished_at, jobs, ok, failed]
            properties:
              started_at: {type: string}
              finished_at: {type: [string, "null"]}
              jobs: {type: integer}
              ok: {type: integer}
              failed: {type: integer}
        gaps:
          type: array
          items:
            type: object
            required: [from, to, minutes]
            properties:
              from: {type: string}
              to: {type: string}
              minutes: {type: number}
        sources:
          type: array
          items:
            type: object
            required: [id, type, country, state, last_ok, last_error]
            properties:
              id: {type: string}
              type: {type: string}
              country: {type: string}
              state: {type: string, enum: [ok, degraded, disabled, pending]}
              last_ok: {type: [string, "null"]}
              last_error: {type: [string, "null"]}
    MapState:
      type: object
      required: [generated_at, countries, regions]
      properties:
        generated_at: {type: [string, "null"]}
        countries: {type: object, additionalProperties: {$ref: "#/components/schemas/State"}}
        regions: {type: object, additionalProperties: {$ref: "#/components/schemas/State"}}
        stale: {type: boolean}
    Health:
      type: object
      required: [ok, db, kv]
      properties:
        ok: {type: boolean}
        db: {type: boolean}
        kv: {type: boolean}
        at: {type: string}
    MonitorCreate:
      type: object
      required: [url, cf-turnstile-response]
      properties:
        url: {type: string, maxLength: 2048}
        keyword: {type: string, maxLength: 100}
        name: {type: string, maxLength: 80}
        alert_webhook: {type: string, maxLength: 2048}
        alert_ntfy: {type: string}
        alert_email: {type: string, description: only when e-mail alerts are enabled (double opt-in)}
        cf-turnstile-response: {type: string}
    MonitorCreated:
      type: object
      required: [id, status_url, admin_url, verify_token, status]
      properties:
        id: {type: string}
        status: {type: string, enum: [active, waitlist]}
        status_url: {type: string}
        admin_url: {type: string, description: private, shown once}
        admin_token: {type: string}
        verify_token: {type: string}
        verify_txt: {type: string}
    MonitorUpdate:
      type: object
      required: [token]
      properties:
        _method: {type: string, enum: [update]}
        token: {type: string}
        keyword: {type: string}
        name: {type: string}
        alert_webhook: {type: string}
        alert_ntfy: {type: string}
        alert_email: {type: string}
    TokenOnly:
      type: object
      required: [token]
      properties:
        token: {type: string}
    RequestCreate:
      type: object
      required: [scope, category, cf-turnstile-response]
      properties:
        scope: {type: string, enum: [region, country]}
        cc: {type: string}
        region_code: {type: string}
        country_name: {type: string, maxLength: 100}
        category: {type: string, enum: [missing_source, false_alarm, missed_outage, other, new_country]}
        topic: {type: string, enum: [power, network]}
        note: {type: string, maxLength: 1000}
        source_url: {type: string, maxLength: 2048}
        email: {type: string, description: never published}
        cf-turnstile-response: {type: string}
