openapi: 3.0.3
info:
  title: Cronitorex API
  description: >
    Cronitorex exposes two APIs, each with its own API key (panel: Settings → API key).

    **Ingest API** (`https://api.cronitorex.com`, key prefix `ck_`) receives
    monitoring events: POST /ping, GET /ping/{monitor}, GET /health.

    **Management API** (`https://app.cronitorex.com/api/v1`, key prefix `mk_`)
    manages monitors as manifest documents: list, get, create, update, delete
    (with an explicit confirmation), plus whole-account export and declarative
    apply for config as code.

    Every error response carries a stable machine-readable code: `code` for
    single-cause errors, and an `error_codes` map alongside `errors` for
    validation failures. Branch on those, never on message text.
  version: 1.2.0
  contact:
    url: https://docs.cronitorex.com

servers:
  - url: https://api.cronitorex.com
    description: Ingest API

security:
  - IngestKey: []

tags:
  - name: Ping
    description: Ingest API — submit monitoring events (server https://api.cronitorex.com, key ck_...)
  - name: Monitors
    description: Management API — manage monitors as manifests (server https://app.cronitorex.com, key mk_...)
  - name: Health
    description: Service health

paths:
  /ping:
    post:
      tags: [Ping]
      summary: Submit a monitoring event
      description: >
        Records a lifecycle event for a monitor. Use `status: run` when a job
        begins, `status: complete` on success, `status: fail` on failure,
        `status: skip` when the job was intentionally skipped.
      operationId: postPing
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PingRequest'
            examples:
              job_run:
                summary: Job started
                value:
                  event_type: ping
                  monitor: db-backup
                  status: run
                  host: server-01
              job_complete:
                summary: Job completed
                value:
                  event_type: ping
                  monitor: db-backup
                  status: complete
                  duration: 47.3
                  exit_code: 0
              job_fail:
                summary: Job failed
                value:
                  event_type: ping
                  monitor: db-backup
                  status: fail
                  exit_code: 1
                  message: "pg_dump: connection refused"
              job_skip:
                summary: Job skipped (maintenance window)
                value:
                  event_type: ping
                  monitor: payment-service
                  status: skip
      responses:
        '200':
          description: Event accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_status:
                  value:
                    error: "Missing required field: status"
                invalid_status:
                  value:
                    error: "Invalid status value 'broken' for event_type 'ping'. Allowed values: [run complete fail skip]"
        '401':
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: unauthorized
        '429':
          description: Rate limit exceeded

  /ping/{monitor}:
    get:
      tags: [Ping]
      summary: Submit a monitoring event via GET
      description: >
        URL-based variant of POST /ping for environments where sending a JSON
        body is inconvenient (crontab one-liners, IoT, CI).
        Authenticate with the `Authorization: Bearer` header or the `api_key`
        query parameter. The header takes priority; prefer it for recurring
        traffic — tokens in URLs end up in access logs and browser history.
      operationId: getPing
      parameters:
        - name: monitor
          in: path
          required: true
          description: Monitor identifier (slug)
          schema:
            type: string
            maxLength: 255
          example: db-backup
        - name: status
          in: query
          required: true
          description: Event status
          schema:
            type: string
            enum: [run, complete, fail, skip]
        - name: duration
          in: query
          description: Job execution time in seconds
          schema:
            type: number
            format: float
        - name: exit_code
          in: query
          description: Process exit code
          schema:
            type: integer
        - name: host
          in: query
          description: Hostname that ran the job
          schema:
            type: string
        - name: msg
          in: query
          description: Free-form log message or error text
          schema:
            type: string
        - name: series
          in: query
          description: Correlation id linking run/complete pairs of one execution
          schema:
            type: string
        - name: api_key
          in: query
          description: API key alternative to the Authorization header (ad-hoc use only)
          schema:
            type: string
      responses:
        '200':
          description: Event accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Missing or invalid API key
        '429':
          description: Rate limit exceeded

  /health:
    get:
      tags: [Health]
      summary: API health check
      description: Returns OK if the API is running. No authentication required.
      operationId: getHealth
      security: []
      responses:
        '200':
          description: Service healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  /api/v1/monitors:
    servers:
      - url: https://app.cronitorex.com
        description: Management API (panel)
    get:
      tags: [Monitors]
      summary: List monitors
      description: >
        Returns manifests of all monitors on the account (ping monitors and
        uptime checks). Rate limit: 60 requests/min per API key.
      operationId: listMonitors
      security:
        - ManagementKey: []
      parameters:
        - name: kind
          in: query
          description: Filter by monitor kind
          schema:
            type: string
            enum: [ping, http_check, ssl_check]
      responses:
        '200':
          description: Monitor manifests
          content:
            application/json:
              schema:
                type: object
                properties:
                  monitors:
                    type: array
                    items:
                      $ref: '#/components/schemas/Monitor'
                  meta:
                    $ref: '#/components/schemas/ListMeta'
        '401':
          description: Missing or invalid API key
        '429':
          description: Rate limit exceeded
    post:
      tags: [Monitors]
      summary: Create a monitor
      description: >
        Creates a monitor from a manifest document (same format as panel
        export/import). A `uuid` field in the body is ignored.
      operationId: createMonitor
      security:
        - ManagementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Manifest'
            examples:
              ping_monitor:
                summary: Ping monitor
                value:
                  manifest_version: 1
                  kind: ping
                  name: db-backup
                  enabled: true
                  timeout_seconds: 60
                  expected_interval_seconds: 86400
                  tags: [prod, backup]
              http_check:
                summary: HTTP check
                value:
                  manifest_version: 1
                  kind: http_check
                  name: Website health
                  enabled: true
                  schedule: "*/5 * * * *"
                  config:
                    url: https://example.com/health
                    method: GET
                    timeout: 30
                    expected_status: 200
              ssl_check:
                summary: SSL certificate check
                value:
                  manifest_version: 1
                  kind: ssl_check
                  name: example.com certificate
                  enabled: true
                  schedule: "0 6 * * *"
                  config:
                    domain: example.com
                    warning_days: 30
                    alert_days: 7
      responses:
        '201':
          description: Monitor created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Monitor'
        '401':
          description: Missing or invalid API key
        '422':
          description: Invalid manifest, duplicate name or plan limit reached
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorResponse'
        '429':
          description: Rate limit exceeded

  /api/v1/monitors/{uuid}:
    servers:
      - url: https://app.cronitorex.com
        description: Management API (panel)
    parameters:
      - name: uuid
        in: path
        required: true
        description: Monitor uuid (returned by list/create)
        schema:
          type: string
          format: uuid
    get:
      tags: [Monitors]
      summary: Get a monitor
      operationId: getMonitor
      security:
        - ManagementKey: []
      responses:
        '200':
          description: Monitor manifest
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Monitor'
        '401':
          description: Missing or invalid API key
        '404':
          description: Monitor not found (or belongs to another account)
        '429':
          description: Rate limit exceeded
    put:
      tags: [Monitors]
      summary: Update a monitor
      description: >
        Applies a full manifest to an existing monitor (no partial patch).
        The `kind` must match the existing monitor and a ping monitor's `name`
        is immutable — both violations return 422.
      operationId: updateMonitor
      security:
        - ManagementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Manifest'
      responses:
        '200':
          description: Updated monitor manifest
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Monitor'
        '401':
          description: Missing or invalid API key
        '404':
          description: Monitor not found
        '422':
          description: Invalid manifest, kind mismatch or immutable name change
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorResponse'
        '429':
          description: Rate limit exceeded
    delete:
      tags: [Monitors]
      summary: Delete a monitor
      description: >
        Deletion is irreversible: removing a ping monitor also removes its event
        history, expected executions and related notifications. A bare DELETE is
        therefore refused with 403 so a mistyped script or a generic CRUD client
        cannot wipe data by accident — repeat the request with `?confirm=true`
        to go through with it.
      operationId: deleteMonitor
      security:
        - ManagementKey: []
      parameters:
        - name: confirm
          in: query
          required: false
          description: Must be `true` to actually delete. Without it the request is refused.
          schema:
            type: boolean
      responses:
        '204':
          description: Monitor deleted
        '403':
          description: Confirmation missing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeleteRequiresConfirmResponse'
        '401':
          description: Missing or invalid API key
        '404':
          description: Monitor not found
        '429':
          description: Rate limit exceeded

  /api/v1/monitors/export:
    servers:
      - url: https://app.cronitorex.com
        description: Management API (panel)
    get:
      tags: [Monitors]
      summary: Export the account as a bundle
      description: >
        Returns every monitor on the account as a single bundle document that
        `POST /api/v1/monitors/apply` accepts unchanged. No `uuid` fields, so the
        result is portable between accounts and reviewable in version control.
        Not gated by the config-as-code plan flag — it returns the same data as
        GET /api/v1/monitors, only reshaped.
      operationId: exportMonitors
      security:
        - ManagementKey: []
      responses:
        '200':
          description: Applyable bundle
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Bundle'
        '401':
          description: Missing or invalid API key
        '429':
          description: Rate limit exceeded

  /api/v1/monitors/apply:
    servers:
      - url: https://app.cronitorex.com
        description: Management API (panel)
    post:
      tags: [Monitors]
      summary: Reconcile the account with a bundle
      description: >
        Declarative apply (config as code). Monitors are matched by `kind` and
        `name`, not by uuid. Three guarantees: apply never deletes (monitors
        missing from the bundle are reported under `orphaned` and left alone);
        the whole bundle is validated before anything is written, so a bad entry
        cannot leave a partial apply behind; and applying an unchanged bundle is
        a no-op, which makes it safe to run on every push.

        Ping monitors and checks have separate name spaces, so a `ping` and an
        `http_check` may share a name.
      operationId: applyMonitorBundle
      security:
        - ManagementKey: []
      parameters:
        - name: dry_run
          in: query
          required: false
          description: Report what would change without writing anything.
          schema:
            type: boolean
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Bundle'
      responses:
        '200':
          description: Apply report (identical shape for dry runs and real runs)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApplyReport'
        '401':
          description: Missing or invalid API key
        '403':
          description: Config as code is disabled for this plan
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConfigAsCodeDisabledResponse'
        '422':
          description: Invalid bundle — nothing was written
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorResponse'
        '429':
          description: Rate limit exceeded

components:
  securitySchemes:
    IngestKey:
      type: http
      scheme: bearer
      description: >
        Ingest API key (prefix ck_) generated in the panel (Settings → API key).
        Authenticates pings only.
    ManagementKey:
      type: http
      scheme: bearer
      description: >
        Management API key (prefix mk_) generated in the panel
        (Settings → API key, Management API Key section).
        Authenticates the /api/v1 endpoints only.

  schemas:
    PingRequest:
      type: object
      required:
        - event_type
        - monitor
        - status
      properties:
        event_type:
          type: string
          description: Type of event
          enum: [ping, stream_event]
          example: ping
        monitor:
          type: string
          description: Monitor identifier (slug)
          maxLength: 255
          example: db-backup
        status:
          type: string
          description: >
            Event status for event_type=ping: run | complete | fail | skip.
            Other event types (e.g. stream_event) use the `state` field instead,
            with any non-empty string.
          enum: [run, complete, fail, skip]
          example: complete
        state:
          type: string
          description: >
            Event state for non-ping event types (any non-empty string).
            Ignored for event_type=ping — use `status` there.
          example: deployed
        duration:
          type: number
          format: float
          description: Job execution time in seconds
          example: 47.3
        exit_code:
          type: integer
          description: Process exit code
          example: 0
        host:
          type: string
          description: Hostname that ran the job
          example: server-01
        message:
          type: string
          description: Free-form log message or error text
          example: "Backup complete: 1.2 GB"
        timestamp:
          type: integer
          description: Unix timestamp (defaults to server time if omitted)
          example: 1698334800

    Manifest:
      type: object
      description: >
        Portable monitor definition — the same document the panel exports and
        imports. Exactly one of the three kinds.
      oneOf:
        - $ref: '#/components/schemas/ManifestPing'
        - $ref: '#/components/schemas/ManifestHttpCheck'
        - $ref: '#/components/schemas/ManifestSslCheck'
      discriminator:
        propertyName: kind
        mapping:
          ping: '#/components/schemas/ManifestPing'
          http_check: '#/components/schemas/ManifestHttpCheck'
          ssl_check: '#/components/schemas/ManifestSslCheck'

    ManifestPing:
      type: object
      required: [manifest_version, kind, name]
      properties:
        manifest_version:
          type: integer
          enum: [1]
        kind:
          type: string
          enum: [ping]
        name:
          type: string
          description: Ping slug (a-zA-Z0-9_-, max 64). Immutable after creation.
          maxLength: 64
          pattern: '^[a-zA-Z0-9_-]+$'
          example: db-backup
        enabled:
          type: boolean
          default: true
        timeout_seconds:
          type: integer
          minimum: 10
          maximum: 86400
          default: 30
        expected_interval_seconds:
          type: integer
          nullable: true
          minimum: 60
          maximum: 2592000
          description: Expected interval between pings; null disables interval checks
        grace_seconds:
          type: integer
          nullable: true
          minimum: 0
          maximum: 86400
        tags:
          type: array
          items:
            type: string
            maxLength: 32
          maxItems: 20

    ManifestHttpCheck:
      type: object
      required: [manifest_version, kind, name, schedule, config]
      properties:
        manifest_version:
          type: integer
          enum: [1]
        kind:
          type: string
          enum: [http_check]
        name:
          type: string
          maxLength: 255
          example: Website health
        description:
          type: string
          nullable: true
        enabled:
          type: boolean
          default: true
        schedule:
          type: string
          description: >
            Standard 5-field cron expression, or an interval shorthand
            (30s, 5m, 1h, 1d, 7d). Both are honoured by the scheduler.
          pattern: '^(\S+\s+\S+\s+\S+\s+\S+\s+\S+|\d+[smh]|1d|7d)$'
          example: '*/5 * * * *'
        tags:
          type: array
          items:
            type: string
        config:
          type: object
          required: [url]
          description: At least one of expected_status / expected_content must be set.
          properties:
            url:
              type: string
              format: uri
            method:
              type: string
              enum: [GET, POST, PUT, DELETE, HEAD]
              default: GET
            timeout:
              type: integer
              minimum: 1
              maximum: 300
              default: 30
            expected_status:
              type: integer
              example: 200
            expected_content:
              type: string

    ManifestSslCheck:
      type: object
      required: [manifest_version, kind, name, schedule, config]
      properties:
        manifest_version:
          type: integer
          enum: [1]
        kind:
          type: string
          enum: [ssl_check]
        name:
          type: string
          maxLength: 255
          example: example.com certificate
        description:
          type: string
          nullable: true
        enabled:
          type: boolean
          default: true
        schedule:
          type: string
          description: >
            Standard 5-field cron expression, or an interval shorthand
            (30s, 5m, 1h, 1d, 7d). Both are honoured by the scheduler.
          pattern: '^(\S+\s+\S+\s+\S+\s+\S+\s+\S+|\d+[smh]|1d|7d)$'
          example: '0 6 * * *'
        tags:
          type: array
          items:
            type: string
        config:
          type: object
          required: [domain]
          properties:
            domain:
              type: string
              example: example.com
            warning_days:
              type: integer
              minimum: 1
              default: 30
            alert_days:
              type: integer
              minimum: 1
              default: 7

    Monitor:
      description: A manifest extended with the server-assigned read-only uuid.
      allOf:
        - type: object
          required: [uuid]
          properties:
            uuid:
              type: string
              format: uuid
              readOnly: true
        - $ref: '#/components/schemas/Manifest'

    ListMeta:
      type: object
      description: >
        Envelope metadata. Present so pagination can be added later as extra
        keys here instead of changing the shape of the response.
      properties:
        total:
          type: integer
          example: 42

    Bundle:
      type: object
      description: The manifest format wrapped in an array — the config-as-code document.
      required: [manifest_version, monitors]
      properties:
        manifest_version:
          type: integer
          enum: [1]
        monitors:
          type: array
          items:
            $ref: '#/components/schemas/Manifest'

    Change:
      type: object
      properties:
        name:
          type: string
          example: db-backup
        kind:
          type: string
          enum: [ping, http_check, ssl_check]
        action:
          type: string
          enum: [create, update, unchanged]
        uuid:
          type: string
          format: uuid
          description: Absent for `create`, since the monitor does not exist yet.
        diff:
          type: object
          description: >
            Changed fields only, each as a [before, after] pair. Absent for
            `create`, empty for `unchanged`.
          additionalProperties:
            type: array
            minItems: 2
            maxItems: 2
          example:
            grace_seconds: [300, 600]

    Orphan:
      type: object
      description: A monitor that exists on the account but is missing from the bundle. Never deleted.
      properties:
        name:
          type: string
          example: old-cleanup-job
        kind:
          type: string
          enum: [ping, http_check, ssl_check]
        uuid:
          type: string
          format: uuid

    ApplyReport:
      type: object
      properties:
        dry_run:
          type: boolean
        summary:
          type: object
          properties:
            create:
              type: integer
            update:
              type: integer
            unchanged:
              type: integer
            orphaned:
              type: integer
        changes:
          type: array
          items:
            $ref: '#/components/schemas/Change'
        orphaned:
          type: array
          items:
            $ref: '#/components/schemas/Orphan'

    ValidationErrorResponse:
      type: object
      description: >
        `errors` holds human-readable text and keeps its historical shape.
        `error_codes` mirrors it position for position with stable identifiers —
        that is what programmatic callers should read, because message wording
        changes over time. Keys are dotted for bundles ("monitors.3").
      properties:
        message:
          type: string
          example: A Ping monitor name is immutable. To use a different name, import the manifest as a new monitor.
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
          example:
            manifest: ["A Ping monitor name is immutable."]
        error_codes:
          type: object
          additionalProperties:
            type: array
            items:
              $ref: '#/components/schemas/ErrorCode'
          example:
            manifest: [name_immutable]

    ErrorCode:
      type: string
      description: >
        Stable identifier for a failure. New values are added over time; existing
        ones are never renamed or repurposed. Treat unknown values as generic
        failures rather than rejecting the response.
      enum:
        - manifest_version_unsupported
        - invalid_json
        - kind_invalid
        - name_invalid
        - name_charset_invalid
        - timeout_out_of_range
        - interval_out_of_range
        - grace_out_of_range
        - schedule_invalid
        - schedule_too_frequent
        - domain_invalid
        - url_invalid
        - method_invalid
        - check_timeout_invalid
        - http_assertion_required
        - kind_mismatch
        - name_immutable
        - name_already_exists
        - name_taken_by_other_kind
        - plan_limit_reached
        - bundle_invalid
        - monitors_missing
        - entry_not_object
        - duplicate_entry
        - delete_requires_confirm
        - config_as_code_disabled

    DeleteRequiresConfirmResponse:
      type: object
      properties:
        message:
          type: string
          example: Deleting a monitor is irreversible. Repeat the request with ?confirm=true, or delete it from the web panel.
        code:
          $ref: '#/components/schemas/ErrorCode'
        panel_url:
          type: string
          format: uri
          example: https://app.cronitorex.com/monitors/3b0043f4-a3c4-4a77-95c8-00cf56893aef/edit

    ConfigAsCodeDisabledResponse:
      type: object
      properties:
        message:
          type: string
          example: Config-as-code is not available on your plan. Manage monitors from the web panel or upgrade.
        code:
          $ref: '#/components/schemas/ErrorCode'
        pricing_url:
          type: string
          format: uri
          example: https://app.cronitorex.com/pricing

    SuccessResponse:
      type: object
      properties:
        status:
          type: string
          example: success

    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          example: "Missing required field: status"
