# Assertion language reference

The assertion language lets a Website (HTTP) or API monitor check more than "did the server answer 2xx": the status,
headers, body text, JSON/XML/HTML/YAML content, timings, redirects, DNS answers and TLS details. Each rule is one
line; all rules must pass for the check to pass, and a failing check names every rule that failed. This page is the
complete reference. For building rules in the editor, see
[Assertions and validation rules](/monitors/advanced/assertions/); for ready-made examples, see the
[recipes](#recipes) below.

## Where assertions apply

| Monitor type | Status |
|---|---|
| Website / HTTP (`http`) | Supported. |
| API (`api`) | Supported (same subjects as HTTP). |
| SNMP (`snmp`) | Rules can be saved against the SNMP subjects (`value`, `value.type`); evaluation is being rolled out. |
| Database, Counter | Use their own condition fields in the monitor editor; the same engine evaluates them. |
| Ping, Port | The language defines their subjects (below); rules cannot be saved on them yet. |

## Syntax

```
[label:] subject [not] predicate [value] [nocase]   # comment
```

- **One line = one rule.** All rules are ANDed; there is no `and`, `or` or parentheses.
- **Label** (optional): `homepage-ok: status eq 200` - the label is what a failure names.
- **`not`** negates: `body not contains "Error"`. `!=` is the same as `not eq`.
- **`nocase`** makes a text comparison case-insensitive: `body contains "welcome" nocase`. Text comparisons are
  case-sensitive by default.
- **A subject alone means `exists`**: `body.json` saves as `body.json exists`.
- `#` starts a comment. Identifiers are case-sensitive.

## Literals

| Literal | Example | Notes |
|---|---|---|
| Number | `200`, `1.5` | Comparing to a number parses the subject as a number; a value that does not parse fails the rule with a clear message. |
| String | `"OK"` | Always double-quoted. `"true"` and `"null"` are strings. |
| Boolean | `true`, `false` | Strict: matches a real JSON boolean only; equality only (`eq`, `ne`). `"true"` (quoted) matches the text. |
| Null | `null` | `eq null` tests for a JSON null; `exists` tests presence. |
| Size | `1kb` = 1024, `10kb`, `1mb` = 1048576 | Converted to plain numbers when saved. |
| Duration | `500ms`, `2s` = 2000, `1m`, `1h` | Milliseconds; converted when saved. |
| Range (in lists) | `[200..299]`, `[..299]`, `[500..]` | Ends are inclusive. |
| Document | `json("{\"ok\":true}")`, `xml("...")`, `yaml("...")` | Structural comparison with `eq` (exact) or `contains` (subset); checked when saved. |

The literal's type picks the comparison: `eq 200`, `eq "200"`, `eq true` and `eq "true"` are four different rules.

## Subjects - HTTP and API monitors

A subject reads a part of the check's result. Unqualified subjects refer to the **final** response (after
redirects).

| Subject | Type | What it reads |
|---|---|---|
| `status` | number | HTTP status of the final response. |
| `body` | text | The response body as text. |
| `body.size` | number (bytes) | Body size. |
| `body.hash` | text | A hash of the body - compare with `previous.body.hash` to detect changes. |
| `body.regex("pattern")` | text | Regex over the body; named groups `(?<name>...)` can be read with `bind("name")`. |
| `body.json`, `body.xml`, `body.yaml`, `body.html` | text | The body, **only if it really is that format** - otherwise not present. `body.json exists` = "is this JSON?". |
| `body.json.path("$.a.b")` | runtime-typed | JSONPath (RFC 9535) into a JSON body. |
| `body.yaml.path("$.a")` | runtime-typed | JSONPath into a YAML body. |
| `body.xml.path("//a")` | runtime-typed | XPath into an XML body. |
| `body.html.path("nav a")` | collection | CSS selector into an HTML body; each element's `text` by default. |
| `body.html.a`, `body.html.script`, `body.html.link` | collection | Links, scripts, link tags; elements have `url` (default, as written), `absolute` (resolved URL), `text`, `rel`. |
| `body.<format>.regex("...")` | text | Regex over the body, only when it is that format. |
| `header("Name")` | text / set | A response header (all values of a multi-valued header). |
| `setCookie` | collection | The `Set-Cookie` values. |
| `hstsHeader` | text | The Strict-Transport-Security header. |
| `url` | url | The final URL; `url.host`, `url.port`, `url.path`, `url.query`, `url.scheme`. |
| `url.original` | url | The requested URL (before redirects), with the same parts. |
| `time` | number (ms) | Total response time: connect + TLS + head + data (DNS excluded). |
| `time.connect`, `time.tls`, `time.head`, `time.data`, `time.dns` | number (ms) | The phases. `time.dns` is not part of `time`. |
| `dns.ips` | collection | The addresses the host resolved to. |
| `dns.server` | text | The DNS server that answered. |
| `conn.ip`, `conn.ip.family` | text | The address connected to, and its family. |
| `conn.failed`, `conn.failedIps` | number / collection | Addresses that failed before one succeeded. |
| `tls.protocol`, `tls.cipher` | text | The negotiated TLS version and cipher. |
| `cert.days`, `cert.days.left` | number | Certificate validity: total days and days left. |
| `cert.issuer`, `cert.subject`, `cert.serial` | text | Certificate fields. |
| `cert.san` | collection | Subject Alternative Names. |
| `requests` | collection | Every request the check made, in order, **including** the final one. Elements have `status`, `url`, `header("...")`, `setCookie`. |
| `redirects` | collection | Only the followed hops (`requests` minus the final one). |
| `redirectCount` | number | Number of redirects followed (`0` = answered directly). |
| `bind("name")`, `bind[0]` | text | A value captured by a named regex group earlier in the list. |

`dns`, `conn`, `tls` and `cert` are groups - a rule must name a field inside them (`dns.ips`, not `dns`).

## Subjects - other check types

| Type | Subjects |
|---|---|
| Port (TCP) | `banner` (+ `banner.size`, `banner.hash`, `banner.regex(...)`), `time` (connect + TLS + read) with `time.connect`, `time.tls`, `time.dns`, `ip`, `dns.ips`, `dns.server`, `conn.failed`, and with TLS on: `tls.protocol`, `tls.cipher`, `cert.*` |
| Ping | `loss` (packet loss %), `replyCount`, `sentCount`, `time` (average RTT), `time.min`, `time.max`, `ttl`, `ip`, `dns.ips`, `dns.server`, `time.dns`; `reply` collection with `time`, `ttl`, `ip` per reply |
| Database | `scalar` (the single value), `rows` (collection), `rowCount`, `time.query` |
| Counter | `value` |
| SNMP | `value`, `value.type` |

## Predicates

| Predicate | Example | Meaning |
|---|---|---|
| `eq` / `==`, `ne` / `!=` | `status eq 200` | Equal / not equal. |
| `lt` / `<`, `le` / `<=`, `gt` / `>`, `ge` / `>=` | `time lt 2s` | Numeric comparison. |
| `contains` | `body contains "Add to cart"` | Text contains the value; over a collection, an element equals it; with a document literal, structural containment. |
| `startsWith`, `endsWith` | `url.path startsWith "/api"` | Text begins / ends with. |
| `matches` | `body matches "Order #[0-9]{6}"` | Regular expression, searched anywhere (dot matches newlines; 4 s timeout; pattern up to 1 KB). |
| `containsAny`, `containsAll` | `body containsAny ["In stock", "Pre-order"]` | Any / all of the listed strings. |
| `in` | `status in [200, 301, 302]`, `status in [200..299]` | The value is in the list or range; over a collection, every element is (subset). |
| `exists` | `body.json exists` | The subject is present (for a format namespace: the body is that format). |
| `isNumber` | `bind("id") isNumber` | The value parses as a number. |
| `unique` | `dns.ips unique` | A collection has no duplicates. (`x.unique`, with a dot, is the de-duplicating reducer.) |

`nocase` matters only for textual comparisons (`contains`, `startsWith`, `endsWith`, `matches`, `containsAny`,
`containsAll`, and `eq`/`in` with a string); elsewhere it is ignored with a warning.

## Shortcuts (sugar)

Shortcuts are expanded when a rule is saved; the editor shows what you typed.

| Shortcut | Means |
|---|---|
| `status isOk` | `status in [200..299]` |
| `status isRedirect` | `status in [300..399]` |
| `status isClientError` | `status in [400..499]` |
| `status isServerError` | `status in [500..599]` |
| `body isJson`, `body isXml`, `body isHtml` | The body is that format (`body.json exists` ...). |
| `header("Content-Type") isJson` (also `isXml`, `isHtml`, `isForm`) | The header names that media type family (including `+json`/`+xml` suffixes, ignoring parameters, case-insensitive). |
| `x isEmpty` | Empty (for a format namespace: not that format). |
| `x isNull` | `x eq null` |
| `x absent` | `x not exists` |
| `x between 10 and 20` | `x in [10..20]` |
| `bodySize`, `respTime` | `body.size`, `time` |

## Collections and reducers

Collections (`requests`, `redirects`, `dns.ips`, `cert.san`, `setCookie`, `body.html.a`, query results, `reply`,
`rows`) compose four ways:

| Form | Result |
|---|---|
| `requests[0]` | One element (0-based index). |
| `requests.status` | A projection - the status of every element (still a collection). |
| `requests.where(current.url contains "cart")` | A filter - the elements matching the condition (still a collection). Name the element with `current.`; no nesting, one condition. |
| `requests.count`, `.min`, `.max`, `.sum`, `.avg`, `.first`, `.last`, `.unique` | A reducer (always last). |

Rules for collections:

- Text predicates are **existential**: `requests.url matches "/login"` passes if any element matches. `contains`
  over a collection is exact membership: `dns.ips contains "203.0.113.10"` does not match `203.0.113.100`.
- `not` means **none** match.
- A plain comparison of a whole collection to one value is refused because it is ambiguous. Say which you mean:
  `.first eq 5` (the first), `in [5]` (every element), or `.count` (how many).
- "For all": `x.where(condition).count eq x.count`. "There exists": `x.where(condition) exists`.
- On an empty collection `.count` and `.sum` are 0; `.min`, `.max`, `.avg`, `.first`, `.last` are null (so `exists`
  is false).

## Comparing with the previous check (plan feature)

| Form | Meaning |
|---|---|
| `previous.x` | The value of `x` in the previous check - `body.hash ne previous.body.hash` fails when the page changes. |
| `delta(x)` | The absolute change of `x` since the previous check. |
| `delta(x, s)` (also `ms`, `m`, `h`) | The rate of change per unit of time. |
| `delta2(x)` | The change of the change (second derivative). |

These need a previous value: the first check (two for `delta2`) is skipped as "warming" and counts as a pass. A
current value that cannot be read still fails at once. The previous-value family is a plan feature; rules using it
are refused on plans without it.

Rules can also compare two subjects: `url.host eq url.original.host`, `dns.ips eq previous.dns.ips`. Comparing a
subject with itself is refused.

## Assertion mode

| Monitor setting | API field | Meaning |
|---|---|---|
| Assertion rules | `settings.asserts` (rows) or `settings.assertsSource` (text) | Up to 20 rules; your plan may allow fewer. |
| Use assertions as the verdict | `settings.assertMode` (default `false`) | When on, the rules alone decide whether the response is up; the classic keyword and status fields are refused. Connection, DNS, TLS, timeout and redirect-loop failures still fail the check. When off, rules are checked in addition to the normal verdict. |

Send rules as text with `assertsSource` - one rule per line, shortcuts allowed - and they are parsed, validated
and stored:

```json
{ "settings": { "assertMode": true,
                "assertsSource": "status isOk\nbody.json.path(\"$.status\") eq \"ok\"\ntime lt 2s" } }
```

A rule that does not compile is refused with the line named. `assertsText` in responses shows the stored rules as
text. A failing check records the error type `AssertError` (codename `AssertFailed`) and lists each failed rule.

## Limits

- Rules per monitor: at most 20; your plan's own cap may be lower (`403 package_limit` when exceeded).
- Label up to 128 characters; text values up to 2,048; document literals up to 16,384; lists up to 64 items; regex
  up to 1 KB.
- When the body is larger than the monitor's maximum response size, a rule that passes by finding something is
  trusted, while one that depends on something being absent is reported as inconclusive.

## Recipes

| Rule | Catches |
|---|---|
| `status isOk` | Any non-2xx final response. |
| `status in [200, 301, 302]` | A URL that may redirect but must never error. |
| `body contains "Add to cart"` | A 200 page missing its key content. |
| `body.size gt 1kb` | A blank or truncated page that still returns 200. |
| `header("Content-Type") isJson` | An API that stops returning JSON. |
| `body.json.path("$.status") eq "ok"` | A health endpoint reporting a problem. |
| `body.json.path("$.items").count gt 0` | An API returning an empty list. |
| `time lt 2s` | A successful but slow response. |
| `redirectCount le 2` | A redirect chain that grew. |
| `url.host eq url.original.host` | A redirect to a different domain. |
| `cert.days.left gt 14` | A certificate close to expiry (on an HTTPS check). |
| `dns.ips eq previous.dns.ips` | DNS answers that changed (plan feature). |
| `body.hash eq previous.body.hash` | A page whose content changed (plan feature). |

## Related

- [Assertions and validation rules](/monitors/advanced/assertions/)
- [Error codes reference](/reference/error-codes/)
- [Monitor settings reference](/reference/monitor-settings/)
