No matching sections.
The query surface
Six parameters shape almost every read on the v2 API:
limit/cursor page it,
filters narrow it, sort orders it, expand adds to each
row, fields trims it, and updatedSince turns the whole
thing into a delta poll. They mean the same thing on every endpoint, so this page is the shared rulebook and
the per-resource guides don't repeat it. New here? Start with the
quickstart first.
Every one of them also travels in a JSON body: each paged list answers at
POST <its path>/q with the identical vocabulary - see
Body queries.
The envelope
Three members are on every collection response, always the same three:
- data
- The page's rows, in the requested sort order. Never missing - an empty page is
[], not an absent member. - nextCursor
- An opaque token for the next page, or
nullwhen there is none. Always present, explicitlynullat the end - so a client can testnextCursor === nullwithout first checking whether the field exists. - hasMore
- Equivalent to
nextCursor !== null, spelled as a boolean for a caller who would rather not compare against null.
A fourth member appears only where the resource supports it:
- syncCursor
- A delta high-water mark - what you send back as
updatedSinceon the next poll. See Delta sync below. Omitted entirely on a resource that doesn't support delta reads, rather than sent asnull- the two are different claims.
{
"data": [
{ "id": "6cc03577-5763-4fa6-aca0-b211bd4ae123", /* … the rest of the monitor's fields … */ },
{ "id": "9ab7a30e-08a1-49b5-87a0-3ad985adb815", /* … */ }
],
"nextCursor": "eyJrIjoibS5jcmVhdGVkOjA2Mzky…",
"hasMore": true,
"syncCursor": "eyJrIjoiMTc4NjEyMjcxOCIsImkiOiJzeW5jIiwiZCI6ImYiLCJ2IjoxfQ.e7dd558ff760889e"
}
And a sixth appears only when you ask for it:
- count
{ "total": …, "matched": … }- the account-wide total and how many rows this request's filters matched, across all pages. Present only withexpand=count, and only on the lists that offer it (see the matrix).
syncCursor. Exactly the four that accept
updatedSince: GET /monitor,
GET /contact, GET /maintenance and
GET /webhook. Every other list answers the same envelope with no
syncCursor - there is no delta read to resume. It is omitted on
those, not sent as null: the two are different claims. Don't build a poll loop
against a member that isn't there - and read Delta sync before you build one against a
member that is, because the four differ sharply in what they actually catch.
A handful of small, package-capped lists (webhooks, account members, agent pools, status pages, contact
groups) use the identical envelope shape for uniformity even though paging is not really expected - they
always answer nextCursor: null, hasMore: false. One
envelope, one client parser, everywhere. They still validate paging: a well-formed
limit is accepted on every list, and a malformed
limit or cursor is refused on every list - never
accepted-and-ignored, so a client cannot be lulled into thinking a bad cursor "worked" on the small
collections and then meet the refusal on the large ones.
Walking a page set
limit is 1 to 500, default 50 when omitted. There
is no page or offset parameter anywhere on
this surface - monitoring data changes under a paging client constantly, and an offset page
re-reads rows it already saw and skips rows inserted above the window. Every list is cursor-paged instead.
nextCursor; never construct or decode a cursor yourself. It is an
opaque token, not a value with a documented shape you can build from a monitor id and a timestamp. The one
contract is: pass back exactly what you were given.
A real walk, three pages of a fifteen-monitor account, limit=2:
curl 'https://api2.host-tracker.com/monitor?limit=2' -H 'Authorization: Bearer YOUR_TOKEN'
→ data: [6cc03577…, 9ab7a30e…], hasMore: true
→ nextCursor: eyJrIjoibS5jcmVhdGVkOjA2MzkyMDkyMjU4MjAwMDAwMDAiLCJpIjoiOWFiN2EzMGUtMDhhMS00OWI1LTg3YTAtM2FkOTg1YWRiODE1IiwiZCI6ImYiLCJ2IjoxfQ.8738c718314f3cec
curl 'https://api2.host-tracker.com/monitor?limit=2&cursor=eyJrIjoibS5jcmVhdGVkOjA2MzkyMDkyMjU4MjAwMDAwMDAiLCJpIjoiOWFiN2EzMGUtMDhhMS00OWI1LTg3YTAtM2FkOTg1YWRiODE1IiwiZCI6ImYiLCJ2IjoxfQ.8738c718314f3cec' \
-H 'Authorization: Bearer YOUR_TOKEN'
→ data: [8e71dd88…, c664264d…] - two DIFFERENT monitors, hasMore: true
→ nextCursor: eyJrIjoibS5jcmVhdGVkOjA2MzkyMDc4NjkyOTAwMDAwMDAiLCJpIjoiYzY2NDI2NGQtNzk3Zi00NDNkLWEwNjctYTQ3NDNiMTczYmVmIiwiZCI6ImYiLCJ2IjoxfQ.e7ca67d3b6eb5a90
...and a third call with that page's nextCursor returned two more monitors again,
still no repeats and no gaps, still hasMore: true (fifteen rows, page size two).
hasMore stops being true only once a page comes back short.
An out-of-range limit is refused, not clamped. A caller that asks
for 1000 and silently receives 500 cannot tell a clamped page from the true end of the collection:
{
"type": "https://api2.host-tracker.com/problems/invalid-limit",
"title": "The requested page size is outside the allowed range.",
"status": 422,
"code": "invalid_limit",
"errors": [ { "pointer": "/limit", "parameter": "limit", "value": 501, "min": 1, "max": 500 } ]
}
limit=0 answers the identical shape with "value": 0 -
zero is not a small page, it's a mistake.
What a cursor is (and isn't)
A cursor is a keyset position - the sort column's value plus a unique tiebreak id, base64url-encoded, with a checksum appended. That's it. Two things about it are worth stating precisely, because getting them wrong in either direction is easy:
What that means practically: a corrupted or foreign cursor is refused, never silently re-interpreted as page 1. Silently accepting a bad cursor would hand a caller page 1 while they believe they're on page 7 - worse than an error, because nothing tells them their walk restarted.
curl 'https://api2.host-tracker.com/monitor?cursor=eyJrIjoibS5jcmVhdGVkOjA2MzkyMDkyMjU4MjAwMDAwMDAiLCJpIjoiOWFiN2EzMGUtMDhhMS00OWI1LTg3YTAtM2FkOTg1YWRiODE1IiwiZCI6ImYiLCJ2IjoxfQ.XXXXXXXXXXXXXXXX' \
-H 'Authorization: Bearer YOUR_TOKEN'
{
"code": "invalid_cursor",
"status": 422,
"errors": [ { "pointer": "/cursor", "reason": "checksum mismatch - the cursor was modified or truncated" } ]
}
curl 'https://api2.host-tracker.com/monitor?cursor=not-a-real-cursor-at-all' -H 'Authorization: Bearer YOUR_TOKEN'
{
"code": "invalid_cursor",
"status": 422,
"errors": [ { "pointer": "/cursor", "reason": "cursor is not a valid opaque token" } ]
}
Both answer 422 invalid_cursor, not a 500 and not a quiet restart. See
Errors for the general problem+json shape.
Filtering: a closed vocabulary
The query string on this surface is closed. An endpoint refuses what it doesn't recognise
rather than ignoring it - because a silently-ignored filter and a correctly-applied one produce the identical
200, and the caller has no way to tell them apart except by noticing their results
are too big.
An unknown parameter name
422, naming every parameter the endpoint actually accepts. The check is case-sensitive - MVC's
query binder is case-insensitive by default, which is exactly the trap: ?updatedsince=
would silently bind if the surface didn't reject it, and it's the single most common way a client's
lowercase-by-convention language slips a wrong spelling past a lenient API. Here it doesn't:
{
"code": "unknown_parameter",
"status": 422,
"errors": [ {
"pointer": "/updatedsince",
"allowed": ["cursor", "enabled", "expand", "fields", "from", "id", "like", "limit", "q", "sort", "state", "tag", "to", "type", "updatedSince", "url"],
"didYouMean": "updatedSince"
} ]
}
A plain typo (?bogusParam=1) answers the same shape, minus
didYouMean when nothing close enough exists. The list is
allowed[] - the endpoint's whole parameter set, ordinal-sorted - so a
client never has to guess what a list takes: ask it wrong once and it tells you.
order= parameter anywhere on this surface - a direction is a
suffix on sort (sort=name:desc), never a second
parameter. Sending one is 422 unknown_parameter like any other unknown name. See
sort.
An unknown value
422, naming what the field actually accepts:
{
"code": "unknown_enum_value",
"status": 422,
"errors": [ {
"pointer": "/type", "value": "bogus",
"allowed": ["http", "waterfall", "ping", "port", "domainExp", "sslExp", "dnsbl", "webRisk", "counter", "cntCheck", "api", "database", "snmp", "tran"]
} ]
}
A parameter that is present but empty
This one earns its own paragraph. ?type= is 422, never a silent
empty page:
{ "code": "validation_failed", "status": 422,
"errors": [ { "pointer": "/type", "parameter": "type", "reason": "empty" } ] }
The same refusal happens on GET /contact?type= and on ?id=,
?tag= and every other list-valued filter, on every endpoint that has one - verified
on both of the above live. The reasoning: a caller genuinely cannot distinguish "the API silently returned
everything because it dropped my broken filter" from "your account truly has none of these". And the single
most common way this parameter arrives empty is not a hand-typed mistake - it's a template string with an
unset variable, ?type=${type} where type is
undefined. That is the commonest client bug there is, and this is what stops it
from silently becoming "list my whole account" instead of an error you'd notice in testing.
Combining filters
Every list-valued filter is ANY-OF within itself, and separate parameters
AND together. Verified live against a real account (fourteen monitors, two of them
http):
curl 'https://api2.host-tracker.com/monitor?type=http,ping&expand=count' -H 'Authorization: Bearer YOUR_TOKEN'
→ count: { total: 14, matched: 3 } - every http OR ping monitor
curl 'https://api2.host-tracker.com/monitor?type=http&state=up&expand=count' -H 'Authorization: Bearer YOUR_TOKEN'
→ count: { total: 14, matched: 1 } - the account has 2 http monitors, only 1 is up
→ swapping state=up for state=paused matches the OTHER one, not zero
Both spellings are one rule, not two conveniences: every closed-enum filter is an ANY-OF list,
repeats and comma lists bind identically, duplicates collapse, an unknown token is
422 unknown_enum_value naming that token, and present-but-empty is
422 reason: "empty". That covers
state, severity, location,
type, tag, kind,
outcome, event, country,
pool, capability, family
and contact. Naming a vocabulary's every value reads exactly the same as omitting
the filter.
enum; one that is
genuinely open publishes none. So state, type,
outcome, event, family,
severity and the job list's state arrive in the OpenAPI
document with their values; kind, country,
pool and location deliberately do not - an enum the
server does not enforce would make a generated client refuse values the API accepts.
Refusals on this surface share the general problem shape - see Errors for
the fields common to every 4xx.
sort
One parameter carries the column and the direction:
sort=<column> or sort=<column>:asc|desc.
There is no order=, no direction= and no
sortBy= - a second parameter for the direction is the shape where the two can
disagree, and the one that makes a cursor ambiguous.
Unsuffixed takes the column's natural direction, which is the one you would have asked for:
time-ish columns (created, updated,
lastChange, from, time)
read newest-first; name-ish columns read A→Z.
?sort=name // natural direction - A→Z for a name
?sort=name:asc // the same thing, said explicitly
?sort=name:desc // reversed
An unknown column or an unknown direction is 422 unknown_enum_value, and
allowed[] carries the whole accepted value space - every column
bare and every column with each suffix - so the fix is a copy-paste rather than a guess:
{
"code": "unknown_enum_value",
"status": 422,
"errors": [ {
"pointer": "/sort", "value": "bogus", "reason": "unknown_column",
"allowed": ["name", "state", "type", "interval", "lastChange", "url", "created",
"name:asc", "name:desc", "state:asc", "state:desc", /* … each column, each suffix … */]
} ]
}
A bad direction answers the same code with
reason: "unknown_direction".
sort=name cursor against sort=name:desc is refused
rather than served: re-ordering mid-walk cannot produce a coherent page set, and quietly serving one would
hand you rows you had already seen. Change the sort, start the walk again.
Not every list sorts, and that is a design statement rather than a gap. A per-monitor nested
read (/monitor/{monitorId}/result and its siblings) has the monitor in the path,
so ordering by monitor would say nothing; the notification, delivery, instant-check-history and job feeds are
cursor-ordered newest-first by design; and the reference catalogues keep catalogue order. All of them refuse
sort as an unknown parameter rather than accepting and ignoring it.
The matrix below names who sorts by what.
expand
expand is the one composition spelling on
this surface - a list of extra blocks to embed, allow-listed per endpoint. A token means the
same thing everywhere it is accepted; an endpoint only declares which tokens it takes. Reads are lean
by default on purpose: the heavier blocks - settings, subscriptions, uptime aggregates - are computed work a
caller who doesn't need them shouldn't pay for.
What the defaults are
One rule, three cases:
- A list returns bare rows - every list defaults to no expansions at all.
- A single read returns that object's own full detail - so
GET /monitor/{id}defaults tosettings, andGET /monitor/{monitorId}/result/{resultId}tometrics,recheck. - Relations are always explicit - no read has a related object on by default.
expand=a,b replaces the defaults; it does not add to them. So
asking for one block on an item read gives you that block and the bare row, not that block on top of
everything else.
→ id, name, url, type, state, enabled, tags, slaTarget, since, created, updated - and nothing heavier
Present and empty is the leanest row, not a mistake
?expand= with no value means "no blocks at all" and is answered, not refused - on an
item read that is how you ask for the bare row instead of its default detail. (It used to be indistinguishable
from an absent parameter, because an empty query value binds to null; presence is now read from the query
string itself.)
Repeats and comma lists are one selection
expand=a,b, expand=a&expand=b and
expand=a,b&expand=b all mean the same thing: every value is split on commas,
the results are unioned and duplicates collapse. The comma form is the published spelling because it is the
one every client can produce.
Blocks of the related monitor
On a monitor-derived row - a result, an incident, a maintenance window, an uptime bucket -
expand=monitor.<block> embeds one of the monitor's OWN blocks inside the
row's monitor object, in exactly the shape
GET /monitor publishes it:
monitor.settings, monitor.subscription,
monitor.lastIncident, monitor.maintenance.
monitor.<block> implies monitor, and an unknown
suffix is 422 unknown_expand whose allowed[] lists the
monitor. forms. monitor.uptime,
monitor.spans and monitor.attached are deliberately not
offered - they are aggregations over a window, not properties of the row.
{id, name, url, type} however you expand it.
An unknown token is refused
Never silently dropped - and the refusal names what this endpoint actually offers. Matching is case-insensitive and the response echoes the canonical spelling:
{
"code": "unknown_expand",
"status": 422,
"errors": [ {
"pointer": "/expand", "value": "bogus",
"allowed": ["settings", "attached", "subscription", "lastIncident", "maintenance", "uptime", "spans", "summary", "count"]
} ]
}
Each endpoint's own token set is published as an enum in the OpenAPI document
(style: form, explode: false), taken from the same declaration the endpoint parses -
so a generated client can only send tokens the endpoint accepts.
count and summary
Two envelope-scoped tokens where a list offers them: expand=count adds
{total, matched} across all pages, and
expand=summary adds that resource's account-wide aggregate block. Neither is row
data, so neither is affected by fields.
{ "data": [ /* 1 row */ ], "count": { "total": 14, "matched": 14 }, /* … */ }
Per-resource expand values are documented on each resource's own guide and in the
interactive reference. The matrix below says which lists
offer count.
fields
expand chooses what to ADD; fields chooses what to KEEP.
They are the two halves of one question, and they compose:
?fields=id,monitor&expand=monitor returns the id and the monitor block, and
nothing else.
fields=id,name keeps only the named top-level members of each row -
on every GET that returns a row, applied to each
data[] element of a list and to the root object of an item read.
{
"data": [
{ "id": "6cc03577-…", "name": "Checkout API", "state": "up" },
{ "id": "9ab7a30e-…", "name": "Marketing site", "state": "down" }
],
"nextCursor": "…", "hasMore": true
}
Three rules make it predictable:
idis always returned, named or not - so?fields=on its own is the id-only row andfields=idis the leanest legal projection.idis a no-op token, never a refusal. Sixteen of the rows this parameter applies to publish noidat all (an agent IP range, a monitor type, a per-monitor alert subscription, the account quota …). Naming it there is accepted and ignored rather than refused - on such a row, a mask that would keep NOTHING is422 validation_failedreason: "empty"with the row's own members inallowed[], because a{}row says nothing at all.- The envelope is never touched.
data,nextCursor,hasMore,syncCursor,countandsummaryare the paging protocol, not row data.
An unknown name is 422 unknown_field carrying
value and allowed[] (the row's own members), never
dropped. Names are matched case-sensitively. An operation with no row body - a write, a job
submission, a snapshot download - refuses fields as an unknown query parameter,
like any other name it does not define.
Body queries: POST …/q
Every paged list also answers at POST <its path>/q, taking
the same parameters as one JSON object instead of a query string. There is no second vocabulary and no short
codes: the member names ARE the GET's parameter names, and the response is the
same envelope, byte for byte.
curl 'https://api2.host-tracker.com/monitor?type=http,ping&state=up&sort=name&limit=2' \
-H 'Authorization: Bearer YOUR_TOKEN'
curl -X POST 'https://api2.host-tracker.com/monitor/q' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "type": ["http","ping"], "state": ["up"], "sort": "name", "limit": 2 }'
The mapping is mechanical: a list-valued filter is a JSON array, everything else is a string,
a number or a boolean. An omitted member and an explicit null both mean "not sent";
an empty array means it WAS sent empty, which every list filter refuses exactly as it refuses
an empty value on the query string.
nextCursor from the
GET can be sent as cursor in the
/q body and the other way round - it is one collection with two doors, not two
endpoints. The twins are why the document publishes 164 operations for 126 endpoints; each one is named
query<Noun> beside its list's list<Noun>.
Reach for it when a query string would not survive the trip: a hundred monitor ids, a filter value containing
& or #, or a proxy that truncates long URLs. The
query string stays the default - it is cacheable, loggable and pasteable, and those are real properties.
Delta sync with updatedSince
The efficient poll loop: read a collection once, then keep asking only "what changed" instead of re-fetching the
whole account on a timer. Send the previous response's syncCursor back as
updatedSince - it also accepts a plain Unix-seconds integer, but the cursor is what
you get handed, so there's rarely a reason to compute your own:
curl 'https://api2.host-tracker.com/monitor?limit=1' -H 'Authorization: Bearer YOUR_TOKEN'
→ syncCursor: eyJrIjoiMTc4NjEyMjg5MCIsImkiOiJzeW5jIiwiZCI6ImYiLCJ2IjoxfQ.e4b22a1efae63cf5
curl 'https://api2.host-tracker.com/monitor?updatedSince=eyJrIjoiMTc4NjEyMjg5MCIsImkiOiJzeW5jIiwiZCI6ImYiLCJ2IjoxfQ.e4b22a1efae63cf5' \
-H 'Authorization: Bearer YOUR_TOKEN'
→ data: [] , hasMore: false - nothing changed since the mark, and a FRESH syncCursor to poll with next
- Maintenance windows and webhooks are edit-EXACT. Every patch stamps the row's change
marker, so
updatedSincehere really does mean "everything that changed", configuration included. - A monitor's
updatedisMAX(created, lastStateChange, disableDate). It moves on creation, on an up/down transition, and on an automatic package-limit disable - and on nothing else. Not a rename, not an interval or settings change, not a tag edit, and not a manual pause or resume either. - A contact's
updatedIS its creation instant. No edit of any kind moves it - not a rename, not analertDelaychange, notconfirmedflipping true.
updatedSince alone grows stale rows forever.
The supported way to hear about what a poll cannot see is the webhook feed:
monitor.updated and monitor.deleted for a monitor's
configuration edits and removals, contact.updated for a contact's - see
Webhooks. A client mirroring an account therefore does three things:
polls updatedSince for the cheap stream, subscribes to those events for the
changes the poll structurally cannot carry, and reconciles against a full list from time to time.
What each list actually offers
sort, updatedSince and
expand=count are not universal, and no single rule predicts which
list has which. Rather than a convention sentence that would be wrong somewhere, here is the actual matrix -
every list that accepts cursor and is not in the table sorts by nothing, syncs by
nothing and counts by nothing.
| List | sort columns | updatedSince | expand=count |
|---|---|---|---|
GET /monitor | name state type interval lastChange url created default created | yes - state-faithful only | yes |
GET /contact | created name address default created | yes - creation instant only | yes |
GET /maintenance | from created default from | yes - edit-exact | - |
GET /webhook | created updated name url | yes - edit-exact | - |
GET /monitor/result | time default monitor | - | yes |
GET /monitor/incident | time default monitor | - | yes |
GET /statuspage | created title slug | - | - |
GET /contact/group | name default created | - | - |
GET /monitor/{monitorId}/result and /incident | - | - | yes |
GET /monitor/result/summary | - | - | yes |
| every other paged list | - | - | - |
monitor, and it means something specific. On
/monitor/result and /monitor/incident,
sort=monitor groups the page by monitor, and
monitor:desc reverses the MONITOR order only - the rows within a monitor stay
newest-first. time:asc is refused
(reason: "unsupported_direction"): a feed whose window is capped has to page from
the recent end.
Time and windows
Every timestamp on the v2 API is Unix seconds, UTC - never ISO-8601,
never milliseconds. from/to is the one window
vocabulary used across the surface for "give me the slice between these two instants".
Some endpoints cap how wide that window may be and refuse rather than truncating it silently. Results, for example, cap at 30 days:
{
"type": "https://api2.host-tracker.com/problems/invalid-range",
"title": "The requested time range is not valid.",
"status": 422,
"code": "invalid_range",
"detail": "A /monitor/result window may span at most 30 days. Narrow from/to, or page through the range in slices no wider than the max.",
"errors": [ { "from": 1782666841, "to": 1786122841, "maxSpan": 2592000, "reason": "too_large" } ]
}
maxSpan is in seconds (2,592,000 = 30 days here), so you can compute the largest
legal window programmatically instead of hardcoding it. Not every endpoint caps its window - check the
interactive reference for the ones that do.