Skip to main content

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 null when there is none. Always present, explicitly null at the end - so a client can test nextCursor === null without 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 updatedSince on the next poll. See Delta sync below. Omitted entirely on a resource that doesn't support delta reads, rather than sent as null - the two are different claims.
GET /monitor?limit=2  ·  200 OK
{
  "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 with expand=count, and only on the lists that offer it (see the matrix).
Which collections actually carry 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.

Follow 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:

page 1
curl 'https://api2.host-tracker.com/monitor?limit=2' -H 'Authorization: Bearer YOUR_TOKEN'
→ data: [6cc03577…, 9ab7a30e…], hasMore: true
→ nextCursor: eyJrIjoibS5jcmVhdGVkOjA2MzkyMDkyMjU4MjAwMDAwMDAiLCJpIjoiOWFiN2EzMGUtMDhhMS00OWI1LTg3YTAtM2FkOTg1YWRiODE1IiwiZCI6ImYiLCJ2IjoxfQ.8738c718314f3cec
page 2 - the SAME nextCursor, passed back unchanged
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:

GET /monitor?limit=501  ·  422
{
  "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:

It is opaque and tamper-evident - a checksum, NOT cryptographically signed. The trailing hex segment is an FNV-1a checksum over the payload, so an edited or truncated token is detected and refused. It is not an HMAC and needs no secret key, and it is not meant to resist a determined forger - it doesn't need to be: every query on this surface is re-scoped server-side to the authenticated caller regardless of what the cursor says, so a hand-crafted cursor can only reorder or replay your own page. Nothing user-scoped - no filter, no user id, no entitlement - rides inside it. If you've read elsewhere that this token is "signed", that's wrong; this paragraph is the correction.

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.

a valid cursor with one character flipped in the checksum  ·  422
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" } ]
}
a made-up string, not a cursor at all  ·  422
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:

GET /monitor?updatedsince=0  ·  422 (wrong case)
{
  "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.

There is no 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:

GET /monitor?type=bogus  ·  422
{
  "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:

GET /monitor?type=  ·  422
{ "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):

ANY-OF: type=http,ping (repeated key or comma list, both work)
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
AND across parameters: type=http narrowed further by state=up
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.

A filter whose set is closed in code publishes that set as an 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.

the three legal spellings of one column
?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:

GET /monitor?sort=bogus  ·  422
{
  "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".

A cursor is bound to the sort it was issued under - column and direction. Replaying a 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 to settings, and GET /monitor/{monitorId}/result/{resultId} to metrics,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.

GET /monitor?limit=1  ·  the default (bare) list row
→ 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.

Blocks appear only for monitors you own. A publicly shared monitor's row keeps the bare {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:

GET /monitor?expand=bogus  ·  422
{
  "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.

GET /monitor?limit=1&expand=count
{ "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.

GET /monitor?limit=2&fields=id,name,state
{
  "data": [
    { "id": "6cc03577-…", "name": "Checkout API", "state": "up" },
    { "id": "9ab7a30e-…", "name": "Marketing site", "state": "down" }
  ],
  "nextCursor": "…", "hasMore": true
}

Three rules make it predictable:

  • id is always returned, named or not - so ?fields= on its own is the id-only row and fields=id is the leanest legal projection.
  • id is a no-op token, never a refusal. Sixteen of the rows this parameter applies to publish no id at 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 is 422 validation_failed reason: "empty" with the row's own members in allowed[], because a {} row says nothing at all.
  • The envelope is never touched. data, nextCursor, hasMore, syncCursor, count and summary are 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.

the same request, twice
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.

Cursors interoperate. A 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:

a real round trip
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
The four are NOT equally faithful, and the difference is the whole contract. Getting this wrong sends an integrator off to build a sync loop that silently misses real edits, so it is written out per resource rather than summarised as "edit-faithful".
  • Maintenance windows and webhooks are edit-EXACT. Every patch stamps the row's change marker, so updatedSince here really does mean "everything that changed", configuration included.
  • A monitor's updated is MAX(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 updated IS its creation instant. No edit of any kind moves it - not a rename, not an alertDelay change, not confirmed flipping true.
And none of the four reports a DELETION. A removed row simply stops appearing, which a page cannot distinguish from a filter that did not match it. There are no tombstones to walk, so a mirror built on 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.

Listsort columnsupdatedSinceexpand=count
GET /monitorname state type interval lastChange url created default createdyes - state-faithful onlyyes
GET /contactcreated name address default createdyes - creation instant onlyyes
GET /maintenancefrom created default fromyes - edit-exact-
GET /webhookcreated updated name urlyes - edit-exact-
GET /monitor/resulttime default monitor-yes
GET /monitor/incidenttime default monitor-yes
GET /statuspagecreated title slug--
GET /contact/groupname default created--
GET /monitor/{monitorId}/result and /incident--yes
GET /monitor/result/summary--yes
every other paged list---
Two feeds sort by 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:

GET /monitor/result?monitor=…&from=…&to=…  ·  a 40-day window  ·  422
{
  "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.