No matching sections.
Results, incidents & reports
Three endpoints answer "what did my monitors see": raw checks, a rolled-up uptime/SLA number (or a series of them), and the down-episodes those checks group into. A fourth turns any of that into a document someone can read without a token - a report.
Raw results
GET /monitor/result lists individual checks across one or more monitors:
curl 'https://api2.host-tracker.com/monitor/result?monitor=fd8fab57-c730-400e-bb1c-faf0d54964a4&limit=3' \
-H 'Authorization: Bearer YOUR_TOKEN'
{
"data": [
{
"id": "res_MXxmZDhmYWI1N2M3MzA0MDBlYmIxY2ZhZjBkNTQ5NjRhNHw3M3wxNzg2MTE3ODUw",
"monitorId": "fd8fab57-c730-400e-bb1c-faf0d54964a4",
"monitor": { "id": "fd8fab57-…", "name": "audit-rusbl", "url": "example.com", "type": "http" },
"at": 1786117850,
"durationSec": 3616,
"state": "up",
"checkNumber": 73,
"checkCount": 2,
"location": { "id": "413d3d3c-…", "country": "Russian Federation", "city": "Moscow", "ip": "194.34.239.36" },
"underMaintenance": false,
"hasSnapshot": false
}
/* … 2 more */
],
"nextCursor": "eyJrIjoiMDYzOTIxNjUxNzIyMDAwMDAwMCIsImkiOiJmZDhmYWI1N2M3MzA0MDBlYmIxY2ZhZjBkNTQ5NjRhNDowMDAwMDAwMDcxIiwiZCI6ImYiLCJ2IjoxfQ.a5f7a61b7b52eb8d",
"hasMore": true
}
Narrow which monitors to read with monitor= (ids),
url= (addresses, + like=true for a case-insensitive
substring) or q= (free text over name and address).
They are narrowing filters and they AND together - exactly the meaning they carry on
GET /monitor - and none of them is required:
monitor=a,b,c reads several monitors in one call (a comparison view over N monitors
is one request, not N), and sending no selection at all reads the account's own feed.
monitor= / url= / q= | Answer |
|---|---|
| All omitted | 200 - the account's whole raw-check feed. The window is capped at 24 hours in that case: an omitted from defaults to it, and a wider explicit window is 422 invalid_range with reason: "window_too_wide", never silently clipped. |
monitor= present, empty (?monitor=) | 422 validation_failed, reason: "empty" - like every present-but-empty filter on this surface. |
| Any present, with values | The usual read, narrowed to the monitors that match ALL the filters you sent and that you can see. |
GET /monitor. One vocabulary with two meanings is the kind of thing a client
gets right by accident - so they now mean the same thing in both places, and the account-wide read that used
to be 422 filter_required is legal, bounded by its window instead of by a
mandatory filter.
like= modifies url= and means nothing without it -
sending it alone is 422 validation_failed, reason:
"requires_url".
The other filters:
| Parameter | Meaning |
|---|---|
from / to | Unix seconds. Omitted from defaults to 30 days back from to; omitted to defaults to now. |
location | Agent ids, ANY-OF. Present-but-empty (?location=) is 422 validation_failed, reason: "empty" - it used to answer identically to being omitted, silently returning every location. |
state | up | down - the same two words a monitor's own state uses (see Monitors), so a value read off a monitor's state passes straight into this filter with no translation. The filter and the row member were both spelled status until 2026-08-17; the old spelling binds nothing now and is 422 unknown_parameter. Present-but-empty is 422 the same way location is. |
expand | monitor · monitor.settings · monitor.subscription · monitor.lastIncident · monitor.maintenance · metrics · recheck · count. Nothing is on by default - a list returns bare rows and every relation is explicit - and sending it replaces the defaults rather than adding to them. |
GET /monitor/{monitorId}/result is the same
filters/expand/paging with the selection fixed by the path - no monitor=/url=
to pass.
422
invalid_range with reason: "too_large" and a maxSpan
telling you the limit in seconds:
curl 'https://api2.host-tracker.com/monitor/result?monitor=fd8fab57-c730-400e-bb1c-faf0d54964a4&from=0&to=1786122567' \
-H 'Authorization: Bearer YOUR_TOKEN'
{
"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": 0, "to": 1786122567, "maxSpan": 2592000, "reason": "too_large" } ]
}
Why this endpoint - and only this one - has a width cap: it materializes every raw check row in the window across the whole monitor set, so cost scales with row count, not with how the answer is shaped. A month of minute-interval checks across a few hundred monitors is already a lot of rows. Two ways around it:
- Page the range in slices no wider than
maxSpan(30 days today - read the number off the refusal itself rather than hardcoding it, in case it moves) and walk forward. - Use
GET /monitor/result/summaryinstead if what you actually want is a number or a chart, not every row. It has no window-width cap - it is bounded differently, on how many buckets the window/bucket pair would produce, which does not grow with the raw check count.
One result in full
GET /monitor/{monitorId}/result/{resultId} reads one check in full detail. The id is
the opaque string R1's rows already carry - never construct one by hand, and it must belong to the monitor
named in the path (a mismatch is the same 404 an absent id gets). The single read
defaults to
expand=monitor,metrics,recheck (everything row-scoped) - a single-result read is
normally a "why did this fail" read, so it is the deep one:
{
"id": "res_MXxmZDhmYWI1N2M3MzA0MDBlYmIxY2ZhZjBkNTQ5NjRhNHw3M3wxNzg2MTE3ODUw",
"monitorId": "fd8fab57-c730-400e-bb1c-faf0d54964a4",
"at": 1786117850,
"durationSec": 3616,
"state": "up",
"checkNumber": 73,
"checkCount": 2,
"location": { "id": "413d3d3c-…", "country": "Russian Federation", "city": "Moscow", "ip": "194.34.239.36" },
"underMaintenance": false,
"hasSnapshot": false,
"metrics": {
"responseTime": 13, "dnsTime": 17, "connectTime": 1, "headTime": 12,
"dataTime": 0, "dataSize": 559, "targetIP": "8.47.69.0",
"httpStatus": 200
/* … the rest is null on this particular check: no TLS, no redirect, no assertion */
}
}
metrics is emitted as real JSON, not a quoted string holding JSON - the stored
result blob already is a JSON document, and a client should not have to parse it twice. Its exact members
depend on the monitor type: an HTTP check carries timing + certificate + assertion fields (most
null unless that feature applies to this particular check); other types carry
their own. A failed check additionally carries error and, when a recheck ran,
recheck.okLocations / recheck.failLocations - which
locations still saw it up, and which confirmed the failure, grouped by the exact error each saw.
GET the page snapshot
GET /monitor/{monitorId}/result/{resultId}/snapshot serves the page snapshot captured for that check, as
binary - an image, never base64 inside a JSON envelope. When a row's
hasSnapshot is true it also carries a ready-made
snapshotUrl, so a renderer never constructs the path itself. It is
cacheable (ETag + a one-year immutable
Cache-Control - a snapshot never changes once it exists) and missing is a plain
404:
{
"type": "https://api2.host-tracker.com/problems/not-found",
"status": 404,
"code": "not_found",
"errors": [ { "resource": "snapshot", "id": "res_MXxmZDhmYWI1N2M3MzA0MDBlYmIxY2ZhZjBkNTQ5NjRhNHw3M3wxNzg2MTE3ODUw" } ]
}
Uptime, SLA and series
GET /monitor/result/summary is the endpoint most integrations actually want: one call,
over any number of monitors, over any window, that answers both "what is my uptime" and "chart it" - because
a chart is just the aggregate repeated over smaller buckets, and both belong to one date vocabulary.
monitor=. Omitting it is
422 filter_required with reason: "required" - an
aggregate over an unnamed set of monitors is a number nobody can interpret, and the read is expensive enough
that an accidental account-wide one is worth refusing. Naming the monitors is the whole question here.
curl 'https://api2.host-tracker.com/monitor/result/summary?monitor=fd8fab57-c730-400e-bb1c-faf0d54964a4&bucket=none' \
-H 'Authorization: Bearer YOUR_TOKEN'
{
"data": [
{
"monitorId": "fd8fab57-c730-400e-bb1c-faf0d54964a4",
"monitor": { "id": "fd8fab57-…", "name": "audit-rusbl", "url": "example.com", "type": "http" },
"from": 1783530567,
"to": 1786122567,
"upSec": 210922,
"downSec": 0,
"maintenanceSec": 0,
"downSpans": 0,
"checkCount": 75,
"uptimePercent": 100.0000
}
],
"nextCursor": null,
"hasMore": false
}
uptimePercent is a percent (100.0000,
not 1.0) - render it directly, do not multiply by 100. It comes from one
stored-procedure call over your exact window rather than the four fixed-window figures the legacy stats
surface computes, so a caller no longer re-derives a percentage from raw span math.
bucket= - the aggregate vs. the series
bucket=none|hour|day|week|month. none (the default) is
the single aggregate row above; anything else returns one row per calendar-aligned bucket - the series a
chart draws, in the same shape and the same date vocabulary:
{
"data": [
{
"monitorId": "fd8fab57-c730-400e-bb1c-faf0d54964a4",
"from": 1785517767, "to": 1785542400,
"upSec": 24102, "downSec": 0, "maintenanceSec": 0, "downSpans": 0,
"checkCount": 6, "uptimePercent": 100,
"incidents": { "opened": 0, "restored": 0 }
},
{
"monitorId": "fd8fab57-c730-400e-bb1c-faf0d54964a4",
"from": 1785542400, "to": 1785628800,
"upSec": 0, "downSec": 0, "maintenanceSec": 0, "downSpans": 0,
"checkCount": 0,
"incidents": { "opened": 0, "restored": 0 }
}
],
"nextCursor": null, "hasMore": false
}
from lands mid-day and the second bucket has no
uptimePercent at all. Buckets are calendar-aligned in UTC starting from the
request's own from, which need not land on a boundary - so the first bucket can be
short. And uptimePercent is only present when the bucket has something to divide
by: a bucket with zero checks (a monitor that was paused, or simply had no interval
tick inside it) omits the member rather than reporting a misleading 0 or
100. Same reason expand=incidentCounts replaces the
default monitor expand here rather than adding to it - notice
monitor is absent above because expand= was set
explicitly.
A window/bucket pair that would produce too many rows is refused rather than silently truncated - a short series reading as "a quiet monitor" instead of "your request was too wide" is worse than an error.
slaTarget and the error budget
Each row is scored against that monitor's own slaTarget - the
value set on the monitor itself (Monitors). This is what stops a
tiered account (free vs. paid SLA commitments) from multiplying the call: one request across monitors on
different tiers, and each row is scored against its own number.
{
"monitorId": "fd8fab57-c730-400e-bb1c-faf0d54964a4",
"from": 1783530678, "to": 1786122678,
"upSec": 210922, "downSec": 0, "maintenanceSec": 0, "downSpans": 0,
"checkCount": 75, "uptimePercent": 100.0000,
"slaTarget": 99.99,
"slaMet": true,
"errorBudgetSecRemaining": 21
}
| Member | Meaning |
|---|---|
slaTarget | The monitor's own target, or the request's sla= override for that request only. Absent when neither applies. |
slaMet | Whether uptimePercent met slaTarget. |
errorBudgetSecRemaining | Downtime seconds still affordable in the window before the target is missed. Negative when already overspent. |
sla= is a percentage in (0, 100] - sla=150
is 422 validation_failed, reason: "out_of_range".
metrics= - typed timing series
metrics=responseTime,dns,connect,ttfb,transfer adds a series per requested metric,
one point per bucket, as {t, value, samples} - typed objects, not an untyped
array of pairs:
"metrics": {
"responseTime": { "t": 1784826681, "value": 22.697, "samples": 33 }
}
value is the mean over the bucket, or null when nothing
was sampled; samples is what tells a thin bucket from a genuinely flat one.
Because metrics= reads the stored per-check blob rather than a pre-aggregated
column, it is capped at 20 monitors per request - past that, 422 too_many_items.
Incidents
An incident is a down episode - a span of consecutive checks that saw the monitor down, from the transition that opened it to the one that closed it (or "still open" if it has not closed yet). Five endpoints:
| Endpoint | What it does |
|---|---|
GET /monitor/incident | List down-episodes across the account - monitor=/url= optional; omitted, this is your account's own incident feed. |
GET /monitor/{monitorId}/incident | One monitor's down-episodes, nested - same filters, selection fixed by the path. |
GET /monitor/incident/{id} | One episode, with the transitions that opened and closed it. |
GET /monitor/incident/{id}/check | The failing checks recorded inside the episode. |
POST /monitor/incident/{id}/comment | Annotate it and get the updated incident back. |
Incident ids are opaque inc_ strings, not GUIDs - an incident has
no id column of its own in storage, so the id encodes the monitor and the episode's start time. Treat it as a
token: address the incident with it, never parse it.
GET /monitor/incident
Unlike raw results, selection here is optional: monitor= (ids),
url= (addresses, + like=true for substring match) or
q= (free text over name and address)
narrows to specific monitors, and omitting them reads your account's own incident feed - every monitor you
own, bounded and cursor-paginated, not an uncapped scan. from/to
filter the window (no 30-day cap here - this endpoint counts episodes, not raw checks, so it does not carry
R1's cost profile), plus severity=minor|major|critical and
state=open|resolved. A test account with no down episodes in the window is a
legitimate, empty answer - not an error:
curl 'https://api2.host-tracker.com/monitor/incident?monitor=fd8fab57-c730-400e-bb1c-faf0d54964a4' \
-H 'Authorization: Bearer YOUR_TOKEN'
{ "data": [], "nextCursor": null, "hasMore": false }
severity is derived from duration, not stored - a client can
reproduce the classification rather than guess at it:
| Band | Duration |
|---|---|
minor | Under 5 minutes. |
major | 5 minutes to an hour. |
critical | An hour or more. |
An unknown severity value is 422 unknown_enum_value
naming the three allowed ones; present-but-empty is 422 validation_failed,
reason: "empty" - unlike monitor=, an empty
severity would widen the read to every severity, and that direction is
refused rather than silently accepted.
GET /monitor/incident/{id}
One episode with its timeline: the enter transition (when it went down) and, once resolved, the exit transition
(when it came back) - each with the location that saw it and the recheck detail behind it. The field list, as
published by API2's own OpenAPI document (GET /openapi/v2.json):
| Field | Meaning |
|---|---|
id | The opaque inc_ token. |
start / end | Unix seconds. For an open episode, end is the last moment it was observed down - present, not null, so duration math never special-cases "still running"; state is what says that. |
durationSec | How long the episode lasted (or has lasted so far, if still open). |
state | open | resolved. |
severity | minor | major | critical. |
cause | The error that opened the episode - same shape as a result's own error. |
underMaintenance | True when the opening transition happened inside a maintenance window. |
comment | The operator's annotation. Empty string when none - never absent, so a client can bind it directly. |
checkCount | Checks recorded inside the episode. |
timeline | Present on the single read only: [{resultId, kind: "enter"|"exit", at, location, error, recheck, underMaintenance}] - resultId is R2's own id, so a client deepens into the exact failing check without constructing anything. |
A bogus or absent id gets the same refusal as one on a monitor you cannot see - deliberately: the shape of the error must not let a caller learn which is true:
{ "type": "https://api2.host-tracker.com/problems/not-found", "status": 404, "code": "not_found", "errors": [ { "resource": "incident", "id": "inc_bogus" } ] }
GET /monitor/incident/{id}/check
The failing checks recorded inside the episode - R2-shaped rows, cursor-paginated, so you can walk a
long outage's log without pulling it all at once. This is a package-gated capability
(apiFullLog): an account without it gets
403 package_limit with feature: "apiFullLog" - checked
only after confirming the incident id itself resolves and is yours, so the refusal never leaks whether an id
you cannot read exists.
POST /monitor/incident/{id}/comment
Annotate an incident and get the updated incident back - no separate reload. Two things worth knowing before you send one:
- Owner-only, unlike the three reads above. A monitor shared via OpenStat grants reading its incidents to anyone, but not writing a comment on them - the same rule v1 states outright. A non-owner gets the identical 404 an absent id gets.
commentis required, and an omitted member is a real refusal, not a clear. To clear an existing comment you say so explicitly with""; simply not sending the member answers422naming it, checked before the id is even decoded:
{
"type": "https://api2.host-tracker.com/problems/validation-failed",
"status": 422,
"code": "validation_failed",
"errors": [ {
"pointer": "/comment",
"reason": "required",
"detail": "Send the comment to attach. To clear an existing one, send \"\"."
} ]
}
Send a comment on an incident that does not exist (or is not yours) and the answer is the ordinary
404, same as every other row here. Idempotency-Key is
accepted but not required - the write is naturally convergent, so a retry with or without one leaves the same
comment.
Reports
A report renders a document over a set of monitors and a time range - the same uptime/SLA figures
GET /monitor/result/summary answers as JSON, laid out as a PDF,
CSV, XML or HTML file someone can read without a token.
GET /report/type
{
"data": [ {
"type": "uptime",
"label": "Uptime and SLA report",
"formats": ["pdf", "csv", "xml", "html"],
"sections": ["state", "stats", "outages", "incidents", "log"],
"frequencies": ["daily", "weekly", "monthly", "quarterly", "yearly"]
} ],
"nextCursor": null, "hasMore": false
}
One type today - uptime - because that is what the rendering backend actually
produces; a catalogue advertising more would be a promise the backend cannot keep yet.
sections without a sections member in the request
default to ["stats"] alone.
POST /monitor/report
Rendering drives a headless browser and can take seconds, so this is a job:
202 {jobId, accepted}, same contract as everything else on the surface - see
Jobs & idempotency rather than re-reading it here.
curl -X POST 'https://api2.host-tracker.com/monitor/report' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: docs-report-1786122695' \
-d '{
"type": "uptime",
"monitorIds": ["fd8fab57-c730-400e-bb1c-faf0d54964a4", "c664264d-797f-443d-a067-a4743b173bef", "a05219bf-1f56-439a-8da1-6891ca161b49"],
"from": 1783530681, "to": 1786122681,
"format": "pdf", "sections": ["state", "stats"]
}'
{ "jobId": "574c8d09-e571-45ee-be4f-3bbb090e22cf", "accepted": 3 }
Sending the identical request again with the identical key replays the first 202
byte for byte, with Idempotency-Replayed: true - the same job, not a second
render.
monitorIds is explicit only - there is no filter-scoped report, because that would be
a filter-scoped read of the whole account. Naming an id you do not own is 422
naming which one, not a silently-narrower report:
{ "pointer": "/monitorIds/1", "value": "…", "reason": "unknown_monitor" }
Once terminal, the job's per-item result carries the report's own reference -
{reportId, type, format, sizeBytes, contentUrl, expiresAt} - so a caller does one
poll, not a poll followed by a separate metadata read. If the rendering backend cannot be reached, the item
fails with a full problem document, same as any other job-item failure:
{
"state": "failed",
"results": [ {
"index": 0, "itemRef": "uptime", "status": "failed",
"error": {
"type": "https://api2.host-tracker.com/problems/service-unavailable",
"status": 503,
"code": "service_unavailable",
"errors": [ { "reason": "renderer_busy", "retryAfterSeconds": 15 } ]
}
} ]
}
renderer_busy means retry shortly - the backend's own render pool is saturated, and
the identical request (same key) will succeed once it drains.
GET /monitor/report/{id} and /content
GET /monitor/report/{id} reads a generated report's metadata without fetching
the document: {id, type, format, range:{from,to}, monitorIds, sections, sizeBytes,
expiresAt, state, contentUrl}. sizeBytes is present only while the rendered bytes are
still cached - its absence is not "gone": the id carries every render input, so
GET /monitor/report/{id}/content just re-renders on a cache miss rather than failing.
There is no expired state value - past expiresAt the
id is a plain 404, because a resource you cannot fetch is not one whose state you
read. /content streams the binary document with
Content-Disposition set, from the same base URL and the same
monitor:read token - never a second host with its own login.
Report subscriptions
A scheduled-report subscription is the wiring between ONE monitor and ONE contact - a set of the delivery
frequencies (daily / weekly /
monthly / quarterly / yearly).
Like alert subscriptions it is nested under BOTH parents, and reports are Email-only.
| Do this | Endpoint |
|---|---|
| Set the pair's frequencies | PUT /monitor/{monitorId}/report/{contactId} (monitor side canonical) |
| Read one pair (+ mirror) | GET /monitor/{monitorId}/report/{contactId} · GET /contact/{id}/report/{monitorId} |
| List a monitor's / a contact's | GET /monitor/{monitorId}/report · GET /contact/{id}/report |
| Remove one pair / all for a side | DELETE /monitor/{monitorId}/report/{contactId} · DELETE /monitor/{monitorId}/report · DELETE /contact/{id}/report |
curl -X PUT 'https://api2.host-tracker.com/monitor/MONITOR_ID/report/CONTACT_ID' \
-H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' \
-d '{ "frequencies": ["daily", "weekly"] }'
{
"contact": { "id": "b428dbe0-…", "type": "email", "name": "Main" },
"frequencies": [ "daily", "weekly" ],
"created": 1786007382
}
The contact-side mirror (GET /contact/{id}/report/{monitorId}) carries
monitor in place of contact - the same row from the other
end. PUT is set-state: the frequency set becomes exactly what you send (at least one;
an empty set is 422, use DELETE). Deleting a pair that has
no subscription answers 404, so success always means a real change.
PUT naming a non-Email contact - SMS,
voice, a webhook - is 422 unsupported_report_channel, carrying the offending
contactType and supported: ["email"]. It does not run at
all; the refusal names exactly which contact.
There is no filter-based bulk-delete job: to clear a whole side in one call use
DELETE /monitor/{monitorId}/report (every report subscription on that monitor) or
DELETE /contact/{id}/report (every one that would send to that contact).
Paging
Every collection here - results, the summary series, incidents, report types, report subscriptions - is
cursor-paginated the same way: data / nextCursor /
hasMore. Full mechanics are in the paging guide; the one thing people actually get
wrong is treating the cursor as anything other than opaque. Here is a real walk over raw results,
limit=3:
curl 'https://api2.host-tracker.com/monitor/result?monitor=fd8fab57-c730-400e-bb1c-faf0d54964a4&limit=3' …
→ checkNumber 73, 72, 71 · nextCursor: "eyJrIjoiMDYzOTIxNjUxNzIyMDAwMDAwMCIsIm…" · hasMore: true
curl 'https://api2.host-tracker.com/monitor/result?monitor=fd8fab57-c730-400e-bb1c-faf0d54964a4&limit=3&cursor=eyJrIjoiMDYzOTIxNjUxNzIyMDAwMDAwMCIsIm…' …
→ checkNumber 69, 68, 66 · nextCursor: "eyJrIjoiMDYzOTIxNTI3Mjk4MDAwMDAwMCIsIm…" · hasMore: true
Every other parameter of the request - monitor=, limit=,
any filter - stays exactly as it was on page 1. A cursor that does not decode, or does not address this
collection, is 422 invalid_cursor - never a silent restart at page 1, which would
be indistinguishable from a walk that quietly looped.
Worked example: a monthly uptime report for three monitors
Start to end, in the fewest calls:
-
Request it.
curl -X POST 'https://api2.host-tracker.com/monitor/report' \ -H 'Authorization: Bearer YOUR_TOKEN' -H 'Content-Type: application/json' \ -H 'Idempotency-Key: YOUR_OWN_UUID' \ -d '{ "type": "uptime", "format": "pdf", "monitorIds": ["fd8fab57-…", "c664264d-…", "a05219bf-…"], "from": 1783530681, "to": 1786122681, "sections": ["state", "stats", "outages"] }' → 202 { "jobId": "…" } -
Poll the job (Jobs & idempotency) until it is
terminal. On success,
results[0].result.contentUrlis the download link - already absolute, already carrying the right id. -
Fetch the document.
curl 'https://api2.host-tracker.com/monitor/report/574c8d09-…/content' \ -H 'Authorization: Bearer YOUR_TOKEN' -o report.pdf
Three calls regardless of how many monitors the report covers, and the same three calls whether it renders in
50ms or 5s - the job contract is what makes the wait invisible to the calling code. If you only want the
numbers that would go in that report - not a document - skip all three and call
GET /monitor/result/summary with the same
monitor=/from/to
directly: one synchronous call, JSON back immediately.