No matching sections.
Monitors
A monitor is the thing being checked - a URL, a host, a database, a device - and everything else in v2 hangs off it: results, incidents, alert subscriptions, reports. This guide covers the resource itself: reading it, creating it, discovering what a given type accepts, and the one rule that trips up almost every new integration - which types refuse a location.
The monitor resource
GET /monitor is lean by default. This is the whole row a list read gives you,
with no expand= at all:
curl 'https://api2.host-tracker.com/monitor?limit=1' \
-H 'Authorization: Bearer YOUR_TOKEN'
{
"data": [
{
"id": "13399a5e-4c6b-4f87-a303-4a526431db26",
"type": "http",
"name": "gm-doc-test-1786122527",
"url": "https://example.com/gm-doc-1786122527",
"state": "up",
"since": 1786122528,
"enabled": true,
"tags": [],
"updated": 1786122528,
"slaTarget": null
}
],
"nextCursor": null,
"hasMore": false
}
That is {id, type, name, url, state, since, enabled, tags, updated, slaTarget} and
nothing else - no settings, no credentials, no per-type configuration. There is no separate trimming knob
because the default is the trimmed projection. slaTarget is always
present (even when null) so a client can test it without first asking for the
expand that would populate it.
Ask for the rest with expand=settings. GET
/monitor/{id} - reading one monitor - defaults to expand=settings
already, because a single-monitor read is normally a configuration read:
{
"id": "13399a5e-4c6b-4f87-a303-4a526431db26",
"type": "http",
"name": "gm-doc-test-1786122527",
"url": "https://example.com/gm-doc-1786122527",
"state": "up",
"since": 1786122528,
"enabled": true,
"tags": [],
"updated": 1786122528,
"interval": 60,
"slaTarget": null,
"locations": { "pools": ["allworld"], "excludedAgents": [] },
"settings": { "followRedirect": true },
"attached": { "dnsbl": false, "sslExp": false, "domainExp": false, "webRisk": false },
"created": 1786122528
}
expand= is one vocabulary shared by both the list and the single read - only the
default set differs. The full list, and which of the two reads defaults to it:
| Value | Adds | Default on list? | Default on single? |
|---|---|---|---|
settings | interval, slaTarget's expand-gated siblings, locations, recheck, the type-discriminated settings object, attached (the boolean map), created. | no | yes |
attached | attachedResults - the sub-checks' actual results, not the on/off map. Same shape GET /monitor/{monitorId}/attached returns. | no | no |
subscription | This monitor's alert subscriptions, each with the contact's identifying projection. | no | no |
lastIncident | The most recent up/down transition. | no | no |
maintenance | The maintenance windows covering this monitor - each as its window definition (schedule, recurrence, state), the same shape GET /maintenance lists. A recurring window appears once, never expanded into occurrences. For independent paging/filtering of one monitor's windows, use GET /monitor/{monitorId}/maintenance instead. | no | no |
uptime | Uptime percent over the request's from/to window. | no | no |
spans | The up/down state spans intersecting the window. | no | no |
summary | An account-wide dashboard block on the envelope (counts by state/type/tag, top domains, total downtime). | no | no |
count | {total, matched} on the envelope. | no | no |
A present expand= replaces the defaults, it does not add to them -
that is what makes ?expand=uptime cost exactly what it says. Send several
comma-separated: ?expand=settings,subscription. An unknown value is
422 unknown_expand naming the ones that exist, never silently dropped.
url is the raw url exactly as you sent it - it is what round-trips on
PATCH. When normalisation changed what the executor actually dials, the row also
carries effectiveUrl, the normalised url under monitoring, immediately after
url; it appears only when the two differ and is never itself writable.
Creating a monitor
The true minimum for an http monitor is type and
url. In practice you also want to send interval and
locations.pools explicitly, because two account-shaped rules apply to both and a
silently-inherited default is rarely what you want:
intervalmust be one of the account's allowed values. It is not a free integer of seconds - accounts are entitled to a specific, small set of intervals, and a value outside it is422 invalid_intervalnaming what is allowed. Discover the set withGET /account, not by guessing:limits.intervals[]. See the quickstart for the exact refusal shape.locations.poolsneeds at least one entry when you sendlocationsat all - an empty array is refused rather than treated as "everywhere" (it used to widen silently, unpinning an existing monitor from its region with nothing in the response saying so). Discover valid pool ids withGET /agent/pool, covered in the quickstart's "pick where the check runs from" step.["allworld"]checks from everywhere and always exists.
A real minimal create, and the resource it returns in full - a write never leaves you needing a follow-up read:
curl -X POST 'https://api2.host-tracker.com/monitor' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"type": "http",
"url": "https://example.com/gm-doc-1786122527",
"interval": 60,
"locations": { "pools": ["allworld"] }
}'
{
"id": "13399a5e-4c6b-4f87-a303-4a526431db26",
"type": "http",
"name": "gm-doc-test-1786122527",
"url": "https://example.com/gm-doc-1786122527",
"state": "up",
"since": 1786122528,
"enabled": true,
"tags": [],
"updated": 1786122528,
"interval": 60,
"slaTarget": null,
"locations": { "pools": ["allworld"], "excludedAgents": [] },
"settings": { "followRedirect": true },
"attached": { "dnsbl": false, "sslExp": false, "domainExp": false, "webRisk": false },
"created": 1786122528,
"subscription": []
}
A create can also carry name, tags,
slaTarget, inline contacts[] and
alertSubscriptions[] in the same call - the whole "monitor plus who gets alerted"
setup in one request. That compound shape belongs to the alerting story; see
the quickstart and alerting lives elsewhere
below for why it is create-only.
?dryRun=true validates without writing. It answers the same 201 body a real
create would, with nothing persisted - useful for checking a body's shape before you commit to it,
especially for a type whose settings you are still learning.
The 14 types
Every v2 monitor has a type, and there are exactly 14 - every one of them
creatable, and every one with a complete, discoverable settings schema. (A 15th, rusRegBL,
existed on the legacy API and is discontinued: creating it answers 422
monitor_type_discontinued naming its replacement shape outright, {type: "http",
settings: {preset: "bl:ru"}} - a Russian-blacklist check is now an http
monitor with that preset, not a type of its own.)
| Type | What it checks | Takes locations? |
|---|---|---|
http | Downloads a page like a browser would; the host of all four attach faces below. | yes |
api | An http check plus response-content parsing and assertions. | yes |
ping | ICMP reachability. | yes |
port | TCP connect, optional TLS, optional banner match. | yes |
waterfall | Real-browser page-load budgets (requests, timing, CPU, memory). | yes |
tran | A scripted multi-step browser transaction (login, form submit, navigation) - up to 10 steps. | yes |
cntCheck | Keyword match against a real browser's rendered DOM - sees JS-rendered content an http check cannot. | yes |
domainExp | Domain registration expiry (whois/RDAP). | no |
sslExp | TLS certificate expiry. | no |
dnsbl | DNS-blacklist listing lookup. | no |
webRisk | Google Web Risk (phishing/malware) verdict for a url. | no |
counter | A customer-hosted probe reports one numeric metric (CPU/RAM/disk/connect time/perf counter); v2 evaluates a threshold against it. | no |
database | Connects to a customer database, optionally runs a query, compares the scalar result. | no |
snmp | Reads a numeric OID from network equipment over SNMP v1/v2c/v3. | no |
Four of the no-locations types - domainExp, sslExp,
dnsbl, webRisk - are dual-natured: each
is both a standalone creatable type (the rows above) and an attachable sub-check that rides on an
http monitor (dnsbl also attaches to
ping/port). The attach face is set through the parent
monitor's own settings.attached object, shown on the http
schema below - not through a second monitor.
Discovering a type's schema
Do not guess a type's fields from this page or from the UI - ask the API. Three endpoints, matching M9/M10 of the reference:
| Endpoint | Returns |
|---|---|
GET /monitor/type | The catalogue: all 14 rows, {type, label, creatable, attachable, minInterval, requiresPool, entitlement, presets}. |
GET /monitor/type/{type} | One type's catalogue row, plus its full JSON Schema (draft 2020-12) for settings, plus - for the four dual-natured types - the shape it takes when attached to a parent. |
GET /monitor/type/schema | A combined document: every type's schema in one oneOf, discriminated on type, with every shape's $defs in one namespace - for generating types once rather than fetching 14 separate schemas. |
The catalogue, abridged to the columns that decide how you build a request:
{
"data": [
{ "type": "http", "requiresPool": true, "minInterval": 10, "entitlement": null, "presets": ["bl:ru"] },
{ "type": "waterfall", "requiresPool": true, "minInterval": 600, "entitlement": "waterfall" },
{ "type": "snmp", "requiresPool": false, "minInterval": 10, "entitlement": "snmp" },
{ "type": "webRisk", "requiresPool": false, "minInterval": 43200, "entitlement": "attachedWebrisk" }
]
}
entitlement is null for the four types every package includes
(http, ping, port, and
api reads null too on this account); a non-null value names the package feature
gating it, so a 403 package_limit naming that same feature is not a surprise.
minInterval is per type - database and
waterfall floor at 10 minutes, the four attach-capable standalone types at 6-12
hours - and it is bound live from the same config the write path enforces, so it never drifts from the actual
refusal.
A worked example: reading a schema and building a valid body
counter is a good type to walk through because its shape genuinely depends on the
values you choose. Fetching its schema:
{
"monitorType": { "enum": ["aspnet4", "php", "custom"], "default": "custom" },
"counterType": { "enum": ["cpu", "ram", "disk", "port", "mssql", "mysql", "perfCounter"], "default": "cpu" },
"probeUrl": { "type": "string" },
"errorCondition": { "enum": ["no","eq","ne","gt","ls","ge","le","in","out","ine","oute","ine1","ine2","oute1","oute2"] },
"errorLevel1": { "type": "number" }
/* … category / name / instance / host / port / label / connectionString, each conditionally required */
}
The schema also says probeUrl is the dialed endpoint, distinct from the monitor's
own url - and the write path enforces that they must agree if you send both, so the
simplest working body sends probeUrl alone and lets it double as the display
identity:
curl -X POST 'https://api2.host-tracker.com/monitor' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"type": "counter",
"name": "gm-doc-counter",
"interval": 60,
"settings": {
"monitorType": "aspnet4",
"probeUrl": "gm-counter-host.example.com",
"counterType": "cpu",
"errorCondition": "gt",
"errorLevel1": 90
}
}'
{
"id": "a7a2a82b-bc51-42a5-961f-54738d252097",
"type": "counter",
"name": "gm-doc-counter-1786122691",
"url": "gm-counter-host-1786122691.example.com, CPU usage, %",
"state": "up",
"interval": 60,
"locations": { "pools": ["allworld"], "excludedAgents": [] },
"settings": {
"monitorType": "aspnet4", "counterType": "cpu",
"probeUrl": "gm-counter-host-1786122691.example.com",
"deploymentType": "manual", "errorLevel1": 90, "errorCondition": "gt"
}
}
Notice what the response confirms: url was derived from
probeUrl plus the chosen metric ("CPU usage, %") - the display identity and the
dialed endpoint are different fields precisely so neither is silently guessed from the other - and
locations.pools reads back as ["allworld"] even
though none was sent and none could be: that is simply the default label for "unpinned", the same one every
monitor shows, and it does not mean this check runs from the public fleet. Read on.
TLS policy & DNS resolution
Two settings groups on http, api and
port decide how the connection is made rather than what counts as a
passing response, and both are sold as package features. They are the same switches the web UI shows under
"TLS Handshake" and the DNS selector.
The four TLS handshake policies
By default a check completes the handshake and judges the response - a self-signed certificate, an expired
one or a 3DES cipher does not fail it. Each of these four booleans adds a handshake rule that fails the check
when it is broken. All four default to false and all four are always present on a
read, so a client can test the value rather than the key:
| Setting | Fails the check when… |
|---|---|
requireValidChain | the server does not present a complete, trusted chain - expired, self-signed, name-mismatched or mis-chained. |
requireStrongTls | the connection negotiates less than TLS 1.2 (the deprecated TLS 1.0/1.1 or SSL). |
blockWeakCiphers | the negotiated cipher suite is 128-bit or weaker. |
checkRevocation | the certificate has been revoked by its authority, verified online (CRL/OCSP) during the handshake. |
{ "settings": { "requireValidChain": true } }
The other three are untouched by that PATCH, as the merge rules above promise. Sending
false switches one off; there is no way to express "unset" separately from "off",
because off is what unset means here.
Choosing the resolver
By default each monitoring location resolves the host with its own resolvers, so resolution follows that
location's local view. Two settings override it, and they are alternatives rather than a pair:
publicDns pins resolution to public-DNS-filtered locations, and
dns takes up to four resolver IPs of your own.
Both groups are package features
403 package_limit, naming the feature in the problem's
feature member: sslPolicy for the first three TLS
switches, sslRevoke for checkRevocation (which needs
the SSL-policy entitlement as well as its own), dnsPublic and
dnsManual for the two resolver modes.
A value already stored is grandfathered: it reads back normally, and an update that does not
touch the setting is never refused because of it - only a write that turns the feature on. That is what lets
a monitor configured under an older package keep being edited. Check what your account is entitled to on
GET /monitor/type, which carries the account's own limits when a credential is
presented.
Types that take no locations
locations.pools for one of them is refused, not
ignored. This is a fresh rule and the single likeliest thing to trip up a new integration copying a
body between types.
snmp, database, counter and
the four standalone sub-check types (dnsbl, domainExp,
sslExp, webRisk) dispatch to Host-Tracker's own private
check infrastructure unconditionally - there is no location for you to choose, because there never was a
caller-selectable fleet in the first place. Sending a non-empty locations.pools for
one of these types is 422:
{
"type": "https://api2.host-tracker.com/problems/validation-failed",
"title": "The request body or query is not valid.",
"status": 422,
"code": "validation_failed",
"detail": "A snmp monitor always runs from a fixed internal check network, never from a caller-selected location, so `locations.pools` is not accepted for this type. Omit `locations` (or omit `locations.pools`) to create it.",
"errors": [ { "pointer": "/locations/pools", "reason": "pool_not_supported_for_type" } ]
}
The fix is exactly what the message says: omit locations entirely
when creating one of these seven types. Check requiresPool on the type's catalogue
row (§ above) before you build the request if you are writing a generic client over all 14 types, rather than
discovering the rule from a 422.
Updating: PATCH merge semantics
PATCH /monitor/{id} is a genuine partial update, member by member:
- An absent key means unchanged. Send only
{"name": "New name"}and every other field -tags,settings,locations- stays exactly as it was. nullmeans clear, for the fields that support it -slaTarget,cronSchedule, a credential insidesettings. Sendingnullis a deliberate instruction to blank the field, not a no-op.
Both halves, verified on the same monitor:
curl -X PATCH 'https://api2.host-tracker.com/monitor/13399a5e-…' \
-H 'Authorization: Bearer YOUR_TOKEN' -H 'Content-Type: application/json' \
-d '{"tags": ["gm-tag1", "gm-tag2"], "slaTarget": 99.5}'
→ {"tags": ["gm-tag1", "gm-tag2"], "slaTarget": 99.5, …}
-d '{"name": "gm-doc-test-renamed"}'
→ {"name": "gm-doc-test-renamed", "tags": ["gm-tag1", "gm-tag2"], "slaTarget": 99.5, …}
-d '{"slaTarget": null}'
→ {"slaTarget": null, …}
type is immutable once set - re-sending the same value is fine, sending a different
one is 422 type_immutable naming both. There is no route to change a monitor's type
after creation; delete it and create the replacement.
Deleting & the receipt
DELETE /monitor/{id} answers 200 with a receipt, not a
bare 204 - the cascade counts exist nowhere else, and the rows are gone by the time
you could otherwise ask:
{
"id": "13399a5e-4c6b-4f87-a303-4a526431db26",
"deleted": true,
"type": "http",
"name": "gm-doc-test-renamed",
"url": "https://example.com/gm-doc-1786122527",
"cascaded": {
"alertSubscriptions": 0,
"reportSubscriptions": 0,
"maintenanceSubscriptions": 0
}
}
A missing id and someone else's id answer the same 404 - the ambiguity is
deliberate, so a probe cannot use a 403 to confirm an id exists that is not
theirs.
Alerting lives elsewhere
alertSubscriptions (and reportSubscriptions) are
NOT accepted on PATCH. They are create-only, and a PATCH naming either is
refused outright:
{
"code": "validation_failed",
"errors": [
{ "pointer": "/alertSubscriptions", "reason": "unknown_member",
"allowed": ["attached", "contacts", "cronSchedule", "enabled", "fullLog", "interval", "locations", "name", "openStat", "recheck", "settings", "slaTarget", "tags", "type", "url"] }
]
}
The reason is that a PATCH body carrying a subscription array cannot say whether it
means "merge these in" or "replace the whole set" - two different operations with no way to tell them apart
from the wire shape alone. PUT /monitor/{monitorId}/alert/{contactId} (and its
/report twin) is the subscription-write surface - set-state for one monitor/contact
pair - instead of a PATCH-carried set. Inline contacts[] and
alertSubscriptions[] stay valid on POST /monitor
(create) - only the update door refuses them. See the alerting guide for subscription management once a
monitor already exists.
Tags, slaTarget, enabled, locations
| Member | Meaning |
|---|---|
tags | Free-form strings, no predefined vocabulary. A blank entry is refused, not filtered out. |
slaTarget | The SLA percent this monitor is measured against, 0-100. Always present on every read, even lean list rows, even when null. |
enabled | The configured on/off flag the resource publishes. A separately-effective concept from whether the monitor is currently checking - see state below. |
locations.pools | Named agent-pool ids. At least one entry when sent; discover valid ids with GET /agent/pool. Omit entirely for the seven no-locations types. |
locations.excludedAgents | Specific agent ids to exclude even within a selected pool. |
locations.fallback | geo | world | starve - what to do when too few agents in the selected pools are available. |
Filtering the list
GET /monitor filters by id,
type, tag, state,
enabled, url (+ like=true
for a substring of the address) and q (free text over name and address). Every
list-valued filter is ANY-OF - ?type=http,ping matches either - and separate
parameters AND together. Verified live, filtering the same monitor two different ways:
curl 'https://api2.host-tracker.com/monitor?tag=gm-tag1' -H 'Authorization: Bearer YOUR_TOKEN'
→ {"data": [{"id": "13399a5e-…", "name": "gm-doc-test-renamed", "tags": ["gm-tag1", "gm-tag2"]}]}
curl 'https://api2.host-tracker.com/monitor?type=http&state=up&q=gm-doc-test-renamed' -H 'Authorization: Bearer YOUR_TOKEN'
→ {"data": [{"id": "13399a5e-…", "name": "gm-doc-test-renamed"}]}
state is up / down /
paused / maintenance - a derived, effective status, not
the raw enabled flag.
A filter that is present but resolves to no tokens - ?id=, or a trailing comma
with nothing after it - is refused, not treated as "no filter" and not answered with an
empty page:
422 Unprocessable Content
{ "code": "validation_failed",
"errors": [ { "pointer": "/id",
"parameter": "id",
"reason": "empty" } ] }
The reason it refuses rather than returning nothing: you cannot tell a silently-empty page from a genuinely
empty account, and ?type=${filter} with an unset variable is the commonest
client bug there is. An absent parameter still means "no filter" and returns everything. The same
rule holds across every list on this surface - see
the query surface.
Ordering, projection, and the body twin
Three more parameters shape this list, and they are spelled the same way on every list in v2 - the query surface guide is the full rulebook, so only what is specific to monitors is here:
sort=name|state|type|interval|lastChange|url|created, each optionally suffixed:ascor:desc(?sort=name:desc). The default iscreated, newest first. There is noorder=parameter.tagsis not sortable.fields=keeps only the named top-level members of each row -?fields=id,name,state.idalways comes back whether you name it or not, andcreatedandupdatedare on the list row unconditionally, so?fields=id,createdreturns exactly what it names.POST /monitor/qtakes every one of these parameters as one JSON object instead of a query string, with the same member names and the same response - the door to use when you are filtering by a hundred ids.
updatedSince= is here too, but read
what a monitor's updated actually
moves on before you build a sync loop on it: creation, an up/down transition and an automatic
package-limit disable - not a rename, an interval change, a tag edit, or a manual pause.
Bulk & two-phase delete
Creating, updating or deleting many monitors at once runs as an asynchronous job, not inline - see the jobs guide for the 202 contract, polling, and per-item results.
| Endpoint | What it does |
|---|---|
POST /monitor/bulk | Create many monitors. Idempotency-Key is always required - a retry there duplicates rows at scale. |
POST /monitor/bulk-update | Patch many monitors, by an explicit id list or by a filter-based selection. |
POST /monitor/bulk-delete | Delete a filter-matched set. Two-phase: POST /monitor/bulk-delete-validate reports {matched, sample, truncated, max} with nothing changed, then the delete submits {filter, expectedCount} - and is refused with 409 selection_mismatch if the count moved. See the jobs guide's "two-phase destructive bulk" section for the exact shapes. |
A single-monitor delete never needs a preview - it names one id, and the receipt above already tells you exactly what was removed. The preview step exists for bulk-delete specifically because a filter's blast radius is not obvious until you see the count.
Full endpoint reference: the API reference. Getting started from zero: the quickstart. What every refusal shape looks like: errors.