# Monitor kinds

A monitor is created with `POST /orgs/{org}/monitors` and a body like this:

```json
{
  "name": "Homepage",
  "kind": "http",
  "spec": { "url": "https://www.example.com/" },
  "interval_s": 60,
  "locations": { "mode": "regions", "regions": ["eu", "na"] },
  "failure_threshold": 2,
  "recovery_threshold": 2,
  "reminder_interval_s": 3600,
  "labels": ["prod"],
  "contact_ids": ["con_01j9..."],
  "paused": false
}
```

The common fields:

| Field | Required | Rules |
|---|---|---|
| `name` | yes | 1-100 characters |
| `kind` | yes | one of the nine kinds below; cannot change later |
| `spec` | yes | the kind's fields (below); unknown fields are rejected |
| `interval_s` | yes | `30`, `60`, `120`, `300`, `600`, `900`, `1800` or `3600`, and at least your plan's minimum (`below_plan_minimum`); `domain_expiry` must use `86400`; for `heartbeat`, the period between pings, `60`-`604800` (no plan minimum) |
| `locations` | yes, except `domain_expiry` and `heartbeat`, which must leave it out | `{"mode": "regions", "regions": [...]}` (1-6 of `eu`, `na`, `sa`, `as`, `oc`, `af`) or `{"mode": "locations", "codes": [...]}` (2-12 codes from `GET /locations`). Regions expand to their active locations, capped at your plan's maximum per monitor; more codes than the maximum are refused (`too_many_locations`). At least 2 locations after expansion (`too_few_locations`). Exactly 2 is accepted with the warning `two_locations`: with two, an outage seen from one location can never alert. Three or more is better |
| `failure_threshold` | no | 1-10, default 2: consecutive failed checks in a location before it counts as down |
| `recovery_threshold` | no | 1-10, default 2: consecutive good checks before it counts as up again |
| `degraded_after_ms` | no | `http_adv` only: 100-30000 and below the check's time budget; a slower response makes the monitor `degraded`. `null` (default) is off |
| `reminder_interval_s` | no | 900-86400: repeat the down notification while an incident stays open and unacknowledged; `null` (default) = no reminders |
| `labels` | no | at most 10, each 1-32 characters of `a-z 0-9 - _ .` |
| `contact_ids` | no | contacts to notify (at most 100); omitted = every contact marked `auto_attach` |
| `paused` | no | `true` creates the monitor paused |

**Time budget.** Each check has a time budget, computed from its spec and
returned as `timeout_ms` on the monitor. It must be at most
`interval_s × 1000 − 10000` ms, so a 30-second monitor allows at most a
20-second budget (`out_of_range` on the spec's `timeout_ms` otherwise).

**Hosts and URLs.** A host is a hostname or an IP address. Hostnames are
stored in their ASCII (punycode) form, lower case. A hostname needs at least
two labels and may not end in `localhost`, `local`, `internal`, `lan`,
`home.arpa`, `localdomain`, `test`, `example`, `invalid` or `onion`. When
you save, the host is resolved: if every address is private, loopback,
link-local or otherwise reserved, the monitor is refused
(`target_not_public`); if it does not resolve at all it is accepted with the
warning `does_not_resolve`. Across your organisation, the checks of all
monitors with the same host may add up to at most 120 a minute
(`host_rate_exceeded`).

**`ip_family`** (every kind except `domain_expiry`): `auto` (default) uses
IPv6 and IPv4 as available (Happy Eyeballs; ICMP uses the first IPv4
address, or IPv6 when there is none), `ipv4` or `ipv6` only that family.
`ipv6` monitors can only use locations with IPv6 (`ipv6: true` in
`GET /locations`).

**Outcomes.** Every check gives `up`, `down`, `degraded` or `error`. `error`
means our probe could not run the check (it says nothing about your
target) and never alerts. `degraded` makes a monitor `degraded` only for
`http_adv`, `ssl_expiry`, `domain_expiry`, `imap` and `smtp`; for the other
kinds it counts as up. A failing check gives an `error_kind`, listed per kind
below.

Defaults are stored with the monitor, so a later change of a default never
changes your existing monitors. Responses return the spec with every
default filled in.

## `http`

A `GET` request. Up when the final status is 200-399 and, if `keyword` is
set, the keyword appears in the first 64 KiB of the body. Up to 5 redirects
are followed; TLS certificates are always verified.

| Field | Required | Default | Rules |
|---|---|---|---|
| `url` | yes | | `http` or `https`, at most 2048 bytes, no `user:pass@`, port 1-65535; a `#fragment` is dropped |
| `keyword` | no | `null` | 1-128 bytes, case-sensitive |
| `timeout_ms` | no | `10000` | 1000-30000 |
| `ip_family` | no | `auto` | `auto`, `ipv4`, `ipv6` |

```json
{ "kind": "http", "spec": { "url": "https://www.example.com/health", "keyword": "ok" } }
```

Failures: `dns_failure`, `blocked_address`, `connect_timeout`,
`connection_refused`, `connection_reset`, `host_unreachable`, `tls_error`,
`cert_expired`, `timeout`, `protocol_error`, `status_mismatch`,
`assertion_failed` (keyword missing), `too_many_redirects`,
`redirect_blocked`.

## `http_adv`

Any method, headers, body, authentication, expected statuses and assertions
on the response.

| Field | Required | Default | Rules |
|---|---|---|---|
| `url` | yes | | as for `http` |
| `method` | no | `GET` | `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` |
| `headers` | no | `[]` | at most 32, 8 KiB together; each `{"name", "value", "secret"}` (below) |
| `body` | no | `null` | at most 16 KiB; not allowed with `GET` or `HEAD`. Without a `Content-Type` header it is sent as `application/json` if it parses as JSON, else `text/plain; charset=utf-8` |
| `auth` | no | `{"type": "none"}` | `{"type": "basic", "username", "password"}` or `{"type": "bearer", "token"}` |
| `expected_status` | no | `["200-299"]` | 1-16 entries: `"200"`, `"200-299"` or `"2xx"`, codes 100-599 |
| `assertions` | no | `[]` | at most 20 (below); all must pass |
| `follow_redirects` | no | `true` | |
| `max_redirects` | no | `5` | 0-10 |
| `verify_tls` | no | `true` | `false` accepts any certificate chain |
| `cert_expiry_warn_days` | no | `14` | 1-90: `degraded` (`cert_expiring`) when the certificate expires sooner; `null` turns the warning off |
| `timeout_ms` | no | `10000` | 1000-30000 |
| `body_max_bytes` | no | `65536` | 1024-262144: how much of the body is read for assertions |
| `ip_family` | no | `auto` | |

**Headers.** `name` is an HTTP token of at most 128 bytes; `value` at most
4096 bytes without CR, LF or NUL. Names must be unique, except `Accept`,
`Cache-Control` and `X-` headers. You may not set `Host`, `Content-Length`,
`Transfer-Encoding`, `Connection`, `Keep-Alive`, `Upgrade`, `TE`, `Trailer`,
`Proxy-Authorization`, `Proxy-Connection`, or `Authorization` while `auth`
is not `none`. Without your own `User-Agent` the probe sends
`JustMonitorIt-Probe/<version> (+https://justmonitorit.com/probe; loc=<location>)`.

**Secrets.** `auth.password`, `auth.token` and the `value` of a header with
`"secret": true` are write-only. Responses show `"password_set": true`,
`"token_set": true` and `"secret": true, "value": null` instead. When you
update a monitor, omitting a secret or sending `null` keeps the stored one;
`""` clears it. A secret header's name must be unique.

**Assertions:**

| Assertion | Passes when |
|---|---|
| `{"type": "body_contains", "value": "..."}` | the value (1-256 bytes, case-sensitive) is in the body |
| `{"type": "body_not_contains", "value": "..."}` | the value is not in the body (only the part read, see `body_max_bytes`) |
| `{"type": "body_regex", "pattern": "..."}` | the regular expression (at most 512 characters, Rust `regex` syntax) matches the body |
| `{"type": "json_path", "path": "$.status", "op": "equals", "value": "ok"}` | the body is JSON and the value at `path` satisfies `op` |
| `{"type": "header", "name": "content-type", "op": "contains", "value": "json"}` | the response header satisfies `op` |

`json_path` paths use `$`, `.name`, `['name']` and `[index]` only (no
wildcards or filters), at most 256 characters. Its `op` is `exists`,
`not_exists`, `equals`, `not_equals`, `contains` (substring of a string, or
an element of an array), `lt` or `gt` (numbers). `exists` and `not_exists`
take no `value`; the others need one; equality is typed (`"1"` is not `1`).
`header` ops are `exists`, `not_exists`, `equals`, `contains` and `regex`;
the name is case-insensitive, the value comparison case-sensitive.

Slow responses: set the monitor's `degraded_after_ms` to make it `degraded`
(`slow_response`) when a response takes longer.

```json
{
  "kind": "http_adv",
  "spec": {
    "url": "https://api.example.com/v1/health",
    "method": "GET",
    "headers": [ { "name": "X-Api-Key", "value": "s3cret", "secret": true } ],
    "expected_status": ["200"],
    "assertions": [
      { "type": "json_path", "path": "$.status", "op": "equals", "value": "ok" },
      { "type": "header", "name": "content-type", "op": "contains", "value": "application/json" }
    ]
  },
  "degraded_after_ms": 1500
}
```

Failures: as `http`, plus `cert_expiring` (degraded) and `slow_response`
(degraded).

## `tcp`

Up when a TCP connection to the port opens. Nothing is sent.

| Field | Required | Default | Rules |
|---|---|---|---|
| `host` | yes | | hostname or IP address |
| `port` | yes | | 1-65535 |
| `timeout_ms` | no | `5000` | 500-30000 |
| `ip_family` | no | `auto` | |

```json
{ "kind": "tcp", "spec": { "host": "db.example.com", "port": 5432 } }
```

Failures: `dns_failure`, `blocked_address`, `connect_timeout`,
`connection_refused`, `host_unreachable`.

## `icmp`

Ping. Sends `count` echo requests; down when the share lost reaches
`loss_threshold_pct` (the default 100 means down only when every echo is
lost).

| Field | Required | Default | Rules |
|---|---|---|---|
| `host` | yes | | hostname or IP address |
| `count` | no | `3` | 1-10 |
| `interval_ms` | no | `500` | 200-1000, between echo requests |
| `timeout_ms` | no | `2000` | 500-10000, wait after the last request |
| `payload_bytes` | no | `56` | 16-1024 |
| `loss_threshold_pct` | no | `100` | 1-100 |
| `ip_family` | no | `auto` | |

The time budget is `3000 + (count − 1) × interval_ms + timeout_ms`.

```json
{ "kind": "icmp", "spec": { "host": "203.0.113.10", "count": 5, "loss_threshold_pct": 60 } }
```

Failures: `packet_loss`, `host_unreachable`, `dns_failure`,
`blocked_address`.

## `ssl_expiry`

Connects, completes a TLS handshake and checks the certificate. Down if the
handshake fails or the certificate has expired; `degraded`
(`cert_expiring`) when it expires within `cert_expiry_warn_days`. Use it for
TLS services that are not websites, or to watch a certificate on its own.

| Field | Required | Default | Rules |
|---|---|---|---|
| `host` | yes | | hostname (also sent as SNI) or IP address |
| `port` | no | `443` | 1-65535 |
| `cert_expiry_warn_days` | no | `14` | 1-90 |
| `verify_tls` | no | `true` | `false` checks only the expiry of whatever certificate is served |
| `timeout_ms` | no | `10000` | 1000-30000 |
| `ip_family` | no | `auto` | |

```json
{ "kind": "ssl_expiry", "spec": { "host": "www.example.com", "cert_expiry_warn_days": 21 } }
```

Failures: transport failures as `tcp`, `tls_error`, `cert_expired`,
`timeout`, `protocol_error`; `cert_expiring` (degraded).

## `domain_expiry`

Looks up the domain's registration at its registry over RDAP, once a day
(`interval_s` must be `86400`). Down when the registration has expired
(`domain_expired`) or the domain is not registered (`domain_not_found`);
`degraded` (`domain_expiring`) within `expiry_warn_days`. If the registry
cannot be reached, the result is `error` (`rdap_unavailable`) and you are
not alerted.

| Field | Required | Default | Rules |
|---|---|---|---|
| `domain` | yes | | the registered domain (`example.com`), not an IP address; its TLD must have an RDAP server (`unsupported_tld` otherwise, for example `ch` and `de`) |
| `expiry_warn_days` | no | `30` | 1-365 |
| `timeout_ms` | no | `10000` | 1000-30000 |

```json
{ "kind": "domain_expiry", "interval_s": 86400, "spec": { "domain": "example.com" } }
```

## `imap` and `smtp`

Opens a session with a mail server, without logging in or sending mail:
the greeting, `EHLO` (SMTP) or `CAPABILITY` (IMAP), optionally `STARTTLS`,
then a polite goodbye. With TLS, the certificate is checked as for
`ssl_expiry`.

| Field | Required | Default | Rules |
|---|---|---|---|
| `host` | yes | | |
| `port` | yes | | 1-65535 (IMAP usually 993 or 143; SMTP 25, 465 or 587) |
| `tls` | yes | | `implicit` (TLS from the start), `starttls` or `none` |
| `verify_tls` | no | `true` | forced to `true` with `tls: none` |
| `cert_expiry_warn_days` | no | `14` | 1-90, or `null` for no warning; forced to `null` with `tls: none` |
| `timeout_ms` | no | `10000` | 1000-30000 |
| `ip_family` | no | `auto` | |

```json
{ "kind": "smtp", "spec": { "host": "mx.example.com", "port": 587, "tls": "starttls" } }
```

SMTP on ports 25 and 465 runs only from probes whose provider allows
outbound mail ports; where there is none, another location stands in.

Failures: transport and TLS failures as `ssl_expiry`, `timeout`,
`protocol_error` (detail `greeting`, `ehlo`, `starttls_unsupported`,
`starttls_refused`, `capability` or `line_too_long`); `cert_expiring`
(degraded).

## `heartbeat`

Checks nothing itself: your job (a cron script, a backup, a queue worker)
requests the monitor's ping URL each time it runs, and the monitor goes
`down` (`heartbeat_missed`) when no ping arrives within `interval_s` plus
`grace_s`. The next ping brings it back `up`. A new heartbeat does not
alert until its first ping. `interval_s` is the period between pings
(`60`-`604800`); leave out `locations`, and the thresholds are `1`.

| Field | Required | Default | Rules |
|---|---|---|---|
| `grace_s` | no | `300` | 60-604800: how late a ping may be |

```json
{ "kind": "heartbeat", "interval_s": 86400, "spec": { "grace_s": 1800 } }
```

The response's `ping_url` is the URL to request, with `GET`, `HEAD` or
`POST` (a body is ignored), for example at the end of a crontab line:

```
0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 5 -o /dev/null https://dash.justmonitorit.com/ping/...
```

To report a failed run, request the same URL followed by `/fail`: the
monitor goes `down` at once (`heartbeat_failed`), and the next successful
ping brings it back `up`:

```
0 3 * * * if /usr/local/bin/backup.sh; then curl -fsS -m 10 --retry 5 -o /dev/null https://dash.justmonitorit.com/ping/...; else curl -fsS -m 10 --retry 5 -o /dev/null https://dash.justmonitorit.com/ping/.../fail; fi
```

`GET /orgs/{org}/monitors/{mon}/pings` lists the newest pings (100 are
kept): when each arrived, `success` or `fail`, the method, the client's
address and user agent.

Only members and above see `ping_url`; a viewer gets `null`. It answers
`200` once the ping counted, `404` for an unknown URL, and `429`
or `503` (with `Retry-After`) when it should be tried again. At most 60
pings a minute per URL count. The newest ping is the monitor's
`state.last_ping_at`. Anyone with the URL can ping the monitor, so keep it
private; to change it, delete the monitor and create a new one.

## Trying a spec first

`POST /orgs/{org}/monitors/preview-check` runs a spec once from one location
and returns the result without creating anything:

```json
{ "kind": "http", "spec": { "url": "https://www.example.com/" }, "location": "lon1" }
```

It applies the same validation as creating a monitor. A `heartbeat` has
nothing to try (`not_allowed`).
