Skip to main content

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:

request
curl 'https://api2.host-tracker.com/monitor/result?monitor=fd8fab57-c730-400e-bb1c-faf0d54964a4&limit=3' \
  -H 'Authorization: Bearer YOUR_TOKEN'
200 OK
{
  "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 omitted200 - 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 valuesThe usual read, narrowed to the monitors that match ALL the filters you sent and that you can see.
These three were a UNION SELECTION here until 2026-08-17, and a narrowing filter on 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:

ParameterMeaning
from / toUnix seconds. Omitted from defaults to 30 days back from to; omitted to defaults to now.
locationAgent 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.
stateup | 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.
expandmonitor · 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.
Already know the one monitor you want? GET /monitor/{monitorId}/result is the same filters/expand/paging with the selection fixed by the path - no monitor=/url= to pass.
There is a maximum window width on this endpoint - 30 days. Over it you get 422 invalid_range with reason: "too_large" and a maxSpan telling you the limit in seconds:
request over the cap
curl 'https://api2.host-tracker.com/monitor/result?monitor=fd8fab57-c730-400e-bb1c-faf0d54964a4&from=0&to=1786122567' \
  -H 'Authorization: Bearer YOUR_TOKEN'
422 invalid_range
{
  "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/summary instead 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:

200 OK  ·  a real check, metrics included by default
{
  "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:

a check with no snapshot  ·  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.

Unlike the raw feed above, this one still requires 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.
the aggregate  ·  bucket=none (the default)
curl 'https://api2.host-tracker.com/monitor/result/summary?monitor=fd8fab57-c730-400e-bb1c-faf0d54964a4&bucket=none' \
  -H 'Authorization: Bearer YOUR_TOKEN'
200 OK
{
  "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:

bucket=day, expand=incidentCounts  ·  the first 2 of 8 buckets
{
  "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
}
The first bucket's 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.

sla=99.99  ·  an ad-hoc override for this request only
{
  "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
}
MemberMeaning
slaTargetThe monitor's own target, or the request's sla= override for that request only. Absent when neither applies.
slaMetWhether uptimePercent met slaTarget.
errorBudgetSecRemainingDowntime 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, bucket=none
"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:

EndpointWhat it does
GET /monitor/incidentList down-episodes across the account - monitor=/url= optional; omitted, this is your account's own incident feed.
GET /monitor/{monitorId}/incidentOne 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}/checkThe failing checks recorded inside the episode.
POST /monitor/incident/{id}/commentAnnotate 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:

a monitor that has never gone down  ·  200 OK
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:

BandDuration
minorUnder 5 minutes.
major5 minutes to an hour.
criticalAn 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):

FieldMeaning
idThe opaque inc_ token.
start / endUnix 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.
durationSecHow long the episode lasted (or has lasted so far, if still open).
stateopen | resolved.
severityminor | major | critical.
causeThe error that opened the episode - same shape as a result's own error.
underMaintenanceTrue when the opening transition happened inside a maintenance window.
commentThe operator's annotation. Empty string when none - never absent, so a client can bind it directly.
checkCountChecks recorded inside the episode.
timelinePresent 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.
  • comment is 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 answers 422 naming it, checked before the id is even decoded:
POST …/comment {}  ·  422, on ANY id, even a bogus one
{
  "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

200 OK
{
  "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.

request  ·  Idempotency-Key strongly recommended
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"]
  }'
202 Accepted  ·  Location: /job/574c8d09-e571-45ee-be4f-3bbb090e22cf
{ "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:

GET /job/{id}  ·  terminal, renderer unreachable
{
  "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 thisEndpoint
Set the pair's frequenciesPUT /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'sGET /monitor/{monitorId}/report  ·  GET /contact/{id}/report
Remove one pair / all for a sideDELETE /monitor/{monitorId}/report/{contactId}  ·  DELETE /monitor/{monitorId}/report  ·  DELETE /contact/{id}/report
PUT - set the delivery frequencies (idempotent)
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"] }'
200 OK - the resulting subscription (monitor side)
{
  "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.

Report subscriptions are Email-only. A 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:

page 1
curl 'https://api2.host-tracker.com/monitor/result?monitor=fd8fab57-c730-400e-bb1c-faf0d54964a4&limit=3'→ checkNumber 73, 72, 71 · nextCursor: "eyJrIjoiMDYzOTIxNjUxNzIyMDAwMDAwMCIsIm…" · hasMore: true
page 2 - the SAME request, plus cursor=<page 1's nextCursor>, verbatim
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:

  1. Request it.
    1. POST /monitor/report
    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": "…" }
  2. Poll the job (Jobs & idempotency) until it is terminal. On success, results[0].result.contentUrl is the download link - already absolute, already carrying the right id.
  3. Fetch the document.
    3. GET the contentUrl from step 2
    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.