REST API v2
The REST API v2 is the same API the HostTracker app is built on: 182 operations across monitors, results and incidents, maintenance windows, contacts and subscriptions, reports, status pages, webhooks, jobs, account data and instant checks. This page is the working rulebook - enough to build correct requests without guessing. The endpoint-by-endpoint reference with every schema is the interactive API reference.
At a glance
Section titled “At a glance”| Base URL | https://api2.host-tracker.com - routes sit at the root (/monitor, /contact, …), with no version prefix |
| Auth | Authorization: Bearer <token>, token minted at Integrations -> API (/integrations/api). See API authentication |
| Plan | API access is a plan feature - see which plans include the API |
| Format | JSON in and out (Content-Type: application/json) |
| Time | Every timestamp is Unix seconds (UTC integers), in both directions. Durations are seconds unless a field says otherwise |
| Ids | Opaque strings (most are GUIDs). Never build one yourself |
| Errors | RFC 9457 application/problem+json with a stable code - see Errors |
| OpenAPI document | GET https://api2.host-tracker.com/openapi/v2.json (OpenAPI 3.1, anonymous) |
| Error code pages | https://api2.host-tracker.com/problems/{code} |
Your first calls
Section titled “Your first calls”export HT_TOKEN="your-api-token"
# 1. Who am I, what may I do? (scope account:read)curl https://api2.host-tracker.com/account -H "Authorization: Bearer $HT_TOKEN"
# 2. My monitors that are down (scope monitor:read)curl "https://api2.host-tracker.com/monitor?state=down&limit=20" -H "Authorization: Bearer $HT_TOKEN"
# 3. Where checks can run from (no token needed)curl https://api2.host-tracker.com/agent/poolA create example - an HTTP monitor that looks for a keyword and texts a new SMS contact when it goes down or
recovers (scopes monitor:write and contact:write):
curl -X POST https://api2.host-tracker.com/monitor \ -H "Authorization: Bearer $HT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: create-shop-monitor-1" \ -d '{ "type": "http", "url": "https://shop.example.com/", "name": "Shop home page", "interval": 60, "locations": { "pools": ["allworld"] }, "settings": { "keywords": "Add to cart" }, "contacts": [ { "ref": "oncall", "type": "sms", "address": "+15551234567" } ], "alertSubscriptions": [ { "contactRefs": ["oncall"], "alertTypes": ["down", "up"] } ] }'interval is in seconds and must be one your plan allows (GET /account lists them under limits.intervals).
locations.pools is required for location-based types - ["allworld"] means everywhere (GET /agent/pool lists
the pools). The new SMS contact is created unconfirmed and receives a confirmation code; it gets alerts once
confirmed. Monitor fields per type: monitor settings reference.
Resources and scopes
Section titled “Resources and scopes”| Area | Main operations | Scope |
|---|---|---|
| Monitors | GET/POST /monitor, GET/PATCH/DELETE /monitor/{id}, POST /monitor/{id}/copy, POST /monitor/bulk, /bulk-update, /bulk-delete (+ -validate twins), POST /monitor/{id}/reset-stats, POST /monitor/validate-cron, POST /monitor/resolve |
monitor:read / monitor:write |
| Monitor types | GET /monitor/type, GET /monitor/type/{type}, GET /monitor/type/schema |
none |
| Results | GET /monitor/result, GET /monitor/{id}/result, GET /monitor/{id}/result/{resultId}, .../snapshot, GET /monitor/result/summary, GET /monitor/{id}/span, GET /monitor/{id}/attached |
monitor:read |
| Incidents | GET /monitor/incident, GET /monitor/{id}/incident, GET /monitor/incident/{id}, GET /monitor/incident/{id}/check, POST /monitor/incident/{id}/comment |
monitor:read (comment: monitor:write) |
| Maintenance | GET/POST /maintenance, GET/PATCH/DELETE /maintenance/{id}, GET /monitor/{id}/maintenance |
monitor:read / monitor:write |
| Reports | POST /monitor/report (job), GET /monitor/report/{id}, GET /monitor/report/{id}/content; GET /report/type |
monitor:write to generate, monitor:read to fetch |
| Contacts | GET/POST /contact, GET/PATCH/DELETE /contact/{id}, confirmation, test, bulk; GET /contact/type |
contact:read / contact:write |
| Contact groups | GET/POST /contact/group, GET/PATCH/DELETE /contact/group/{id}, PUT /contact/{id}/group |
contact:read / contact:write |
| Notification log | GET /contact/notification, GET /contact/{id}/notification, GET /contact/notification/summary, POST /contact/notification/resend |
contact:read (resend: contact:write) |
| Alert subscriptions | GET/PUT/DELETE /monitor/{id}/alert/{contactId} (and the mirror under /contact/{id}/alert/{monitorId}), GET /alert, POST /alert/bulk; GET /alert/type |
monitor:* or contact:* by side; flat lists subs:read; bulk needs both writes |
| Report subscriptions | Same shape under /report and /monitor/{id}/report/{contactId} |
as above |
| Webhooks | GET/POST /webhook, GET/PATCH/DELETE /webhook/{id}, POST /webhook/{id}/test, GET /webhook/{id}/delivery, .../redeliver |
webhook:read / webhook:write |
| Status pages | GET/POST /statuspage, GET/PATCH/DELETE /statuspage/{id}, PUT /statuspage/{id}/component, incidents, timeline, templates, subscribers |
statuspage:read / statuspage:write |
| Jobs | GET /job, GET /job/{id}, POST /job/{id}/cancel, POST /job/{id}/resume |
the scope of the operation that created the job |
| Account | GET/PATCH /account, GET /account/quota, GET /account/usage, GET /account/member |
account:read / account:write |
| Instant checks | POST /check, GET /check, GET /check/{dbId}/{id}; GET /check/type, GET /check/device |
check:write / check:read |
| Locations | GET /agent, GET /agent/pool, GET /agent/ip |
none |
Every paged list also has a POST <path>/q twin that takes the same parameters as a JSON body (see
Body queries).
Writes
Section titled “Writes”- Create answers
201 Createdwith the full resource and aLocationheader. There is no need to read it back. - Update (
PATCH) changes only the members you send. An absent member is left alone; an explicitnullclears a nullable member. - Delete answers
200with a receipt naming what was removed, including anything that went with it (for example a monitor’s subscriptions). - Unknown members are refused, never ignored: a misspelled field answers
422 validation_failedwith reasonunknown_memberand the list of accepted members. - A monitor is unique per
(url, type). Creating a second one answers409 duplicate_monitorwith the existing monitor’s id. - A monitor’s
typecannot change after creation (422 type_immutable).
Collections and paging
Section titled “Collections and paging”Every list answers the same envelope:
{ "data": [ { "id": "..." } ], "nextCursor": "eyJrIjoi...", "hasMore": true}limitis 1-500, default 50. An out-of-range value is refused with422 invalid_limit, never clamped.- To get the next page, send
nextCursorback ascursor. Stop whenhasMoreisfalse(nextCursoris thennull). There is no page number or offset anywhere. - Cursors are opaque and checksummed. An edited or foreign cursor answers
422 invalid_cursor. A cursor is bound to thesortit was issued under. - Some lists add
count({total, matched}) when you ask withexpand=count, and asummaryblock withexpand=summary.
Filters
Section titled “Filters”The query string is a closed vocabulary: each endpoint refuses what it does not define.
- An unknown parameter answers
422 unknown_parameterwith the fullallowedlist (names are case-sensitive:updatedsinceis refused,updatedSinceworks). - An unknown value answers
422 unknown_enum_valuewith the allowed values. - A parameter sent empty (
?type=) is refused, never read as “no filter”. - A list-valued filter is ANY-OF (
type=http,pingortype=http&type=ping); different parameters combine with AND.
Example: GET /monitor?type=http,ping&state=down&tag=prod&sort=name.
One parameter: sort=<column> or sort=<column>:asc|desc. Unsuffixed, time columns read newest first and name
columns A to Z. There is no order= parameter.
| List | Sort columns | updatedSince |
expand=count |
|---|---|---|---|
GET /monitor |
name, state, type, interval, lastChange, url, created (default created) |
yes | yes |
GET /contact |
created, name, address |
yes | yes |
GET /maintenance |
from (default), created |
yes | - |
GET /webhook |
created, updated, name, url |
yes | - |
GET /monitor/result, GET /monitor/incident |
time (default), monitor |
- | yes |
GET /statuspage |
created, title, slug |
- | - |
GET /contact/group |
name, created |
- | - |
expand and fields
Section titled “expand and fields”expand=a,badds blocks to each row. Lists default to bare rows; a single read defaults to the object’s own detail (forGET /monitor/{id},settings). Sendingexpandreplaces the defaults. An unknown token answers422 unknown_expandwith the allowed tokens.- Monitor tokens:
settings,attached,subscription,lastIncident,lastResult,maintenance,uptime,spans,summary,count. - On rows derived from a monitor (results, incidents, maintenance windows),
expand=monitorembeds the monitor, andmonitor.settings,monitor.subscription,monitor.lastIncident,monitor.maintenanceembed its blocks. fields=id,name,statekeeps only those top-level members of each row (idis always kept). An unknown name answers422 unknown_field.
Body queries: POST …/q
Section titled “Body queries: POST …/q”Every paged list also answers at POST <path>/q with the same parameters as a JSON object - arrays for list
filters. Use it when a query string would be too long (hundreds of ids) or contains awkward characters. Cursors work
across both forms.
curl -X POST https://api2.host-tracker.com/monitor/q \ -H "Authorization: Bearer $HT_TOKEN" -H "Content-Type: application/json" \ -d '{ "type": ["http","ping"], "state": ["up"], "sort": "name", "limit": 100 }'Delta polling with updatedSince
Section titled “Delta polling with updatedSince”GET /monitor, GET /contact, GET /maintenance and GET /webhook also return a syncCursor. Send it back as
updatedSince to get only what changed. Be aware of what each one catches:
- Maintenance windows and webhooks: every edit.
- Monitors: creation, up/down changes and automatic plan-limit disabling only - not renames, setting changes, tag edits or manual pause/resume.
- Contacts: creation only.
- None of them report deletions.
To hear about the rest, subscribe a webhook to monitor.updated, monitor.deleted and
contact.updated, and reconcile with a full list from time to time.
Time windows
Section titled “Time windows”from and to (Unix seconds) select a time slice. Some endpoints cap the width and refuse a wider window with
422 invalid_range, reporting maxSpan in seconds: raw results allow at most 30 days per request (and 24 hours when
no monitor is named). Incident lists and uptime summaries are not capped the same way. Details:
Reading results.
Errors
Section titled “Errors”Every failure, on every endpoint, is one shape:
{ "type": "https://api2.host-tracker.com/problems/invalid-interval", "title": "The requested check interval is not allowed for this account.", "status": 422, "code": "invalid_interval", "errors": [ { "pointer": "/interval", "value": 300, "allowed": [60] } ]}- Branch on
code, never ontitleordetail(those may be reworded). errors[]says what to fix:pointer(a JSON Pointer into your body, or/<name>for a query parameter),value(what you sent),allowed,reason,expected,didYouMean, and per-code members. An absent member means “no information”, not null.- Every response carries an
x-request-idheader; a500 internal_errorrepeats it astraceId. Quote it to support. - Failures inside an async job use the same shape in
results[].error.
Three families of fix:
| Family | Statuses | What to do |
|---|---|---|
| Fix the request | 400, 405, 413, 415, 422 | Read errors[], change the payload, resend. Never retry blindly. |
| Fix the account state | 401, 402, 403, 404, 409 | A token, scope, plan limit, allow-list or conflicting resource must change first. |
| Wait and retry | 429, 500, 502, 503 | Honour Retry-After; otherwise back off with jitter. |
The full list of codes: Error codes reference.
Idempotency-Key
Section titled “Idempotency-Key”Send Idempotency-Key: <opaque string, max 255 chars> on a write, and a retry after a timeout is safe: the repeat
returns the first call’s stored response instead of doing the work again.
- Keys are scoped to your account and remembered for 24 hours. Use one key per logical operation (a UUID is ideal) and reuse it only for that operation’s retries.
- Same key, same body: the stored response, byte for byte, with the header
Idempotency-Replayed: true. - Same key, different body:
409 idempotency_key_conflict, reasondifferent_body. - Same key while the first call still runs:
409 idempotency_key_conflict, reasonin_flight, withRetry-After.
Required (without it: 400 idempotency_key_required):
| Operation | Why |
|---|---|
POST /monitor/bulk, /monitor/bulk-update, /monitor/bulk-delete, POST /monitor/{id}/reset-stats, POST /contact/bulk, /contact/bulk-delete, POST /monitor/report |
They answer before the work is done, so a timed-out request cannot tell you whether it landed. |
POST /statuspage/{id}/incident, POST /statuspage/{id}/incident/{incidentId}/timeline |
They notify subscribers; a keyless retry announces twice. |
POST /job/{id}/cancel, POST /job/{id}/resume |
Job control. |
POST /monitor with inline contacts, POST /contact for a type that gets a confirmation code (email, sms, voiceCall), POST /monitor/{id}/copy for more than 10 addresses |
A retry would resend paid confirmation codes or copy twice. |
Every other write accepts the header. Sending one on every write is always safe - the SDKs and ht-cli do it for
you.
Asynchronous jobs
Section titled “Asynchronous jobs”Bulk operations, report generation, large copies and statistics resets answer 202 Accepted with a job:
HTTP/2 202Location: /job/be65c73e-4e1a-49d7-8ef9-ba9b11c8681cRetry-After: 3
{ "jobId": "be65c73e-4e1a-49d7-8ef9-ba9b11c8681c", "accepted": 2 }Poll GET /job/{id} after the Retry-After seconds. The poll always answers 200; read state:
| State | Terminal | Meaning |
|---|---|---|
queued |
no | Accepted, not started. |
running |
no | In progress; progress.done / progress.total advance. |
succeeded |
yes | Every item succeeded. |
partial |
yes | Some items succeeded, some failed - retry only the failed items. |
failed |
yes | Every item failed. |
cancelled |
yes | Cancellation took effect. |
interrupted |
no | The server running it stopped; continue it with POST /job/{id}/resume. |
results[]has one row per item:index(position in your request),itemRef,status(pending,created,createdDisabled,updated,skipped,deleted,failed,cancelled),entityId,resultand, for a failed item,error(a full problem document).- A non-terminal poll carries a fresh
Retry-After; a terminal one carries none. - Jobs stay readable for 7 days (
expiresAt), then answer 404.GET /joblists recent jobs. - Instead of polling, add
"callback": {"webhookId": "..."}to the job-creating body to receive ajob.completedwebhook (add"on": "progress"for interimjob.progressdeliveries too). POST /job/{id}/cancelstops future items; finished items are not undone. A finished job answers409 job_not_cancellable.
Destructive bulk deletes are two-phase. Call POST /monitor/bulk-delete-validate with a filter to see
matched and a sample, then POST /monitor/bulk-delete with the same filter and "expectedCount" set to
matched. If the selection changed in between, the API answers 409 selection_mismatch and deletes nothing. The
same pair exists for contacts.
Anonymous endpoints
Section titled “Anonymous endpoints”Catalogues that describe the service rather than your account need no token: monitor, contact, alert, report and
instant-check types, device profiles, the settings schema, and the locations (/agent, /agent/pool,
/agent/ip). They have their own per-address rate limit. Sending a token is allowed: GET /monitor/type then adds
your plan’s limits per type, and GET /agent/pool adds your saved presets.
Tools that wrap the API
Section titled “Tools that wrap the API”- Official SDKs for TypeScript, Python, Go and .NET.
- ht-cli, one command per operation.
- MCP server for AI assistants.
- Terraform provider.

