Skip to main content

No matching sections.

API v2 quickstart

Five steps from nothing to a monitor that pushes its state changes to your own endpoint. Every request and response on this page is a real exchange with the API, not an illustration.

1Mint a token

v2 has no OAuth flow and no client registration. A token is a credential you create in the web UI and store: open your profile, go to the API tokens panel, choose the scopes the integration needs, and copy the token. It is shown once and is not stored on our side - if you lose it, mint another.

SettingWhat it does
ScopesWhat the token may do. Required - a token with no scope is refused. See below.
ExpirationA preset window or a specific date. Left alone, a token lasts ten years.
IP allow-listUp to ten exact addresses or from-to ranges. A call from anywhere else answers 403 ip_not_allowed. Empty means "from anywhere".
Treat a token as a password. It carries your account's authority for as long as it is valid, and there is no revoke-all lever - mint narrow tokens, one per integration, and give each only the scopes it uses.

Scopes: leaf and family

There are thirteen leaf scopes, each an action on a domain:

monitor:readmonitor:writecontact:readcontact:writesubs:readwebhook:readwebhook:writestatuspage:readstatuspage:writeaccount:readaccount:writecheck:readcheck:write

and seven family scopes, one per domain, which are the bare domain name:

monitorcontactsubswebhookstatuspageaccountcheck

  • A family satisfies every leaf in it: holding monitor passes a check for monitor:read and for monitor:write.
  • The reverse never holds, and one leaf never implies another: monitor:write does not satisfy monitor:read. If your integration both reads and writes monitors, grant both leaves - or the family.
  • A family scope widens by itself when its domain later gains an action. That is the trade-off for the convenience; prefer leaves when you want the grant pinned to exactly today's surface.

A call whose token lacks the scope answers 403 missing_scope, and the problem names both sides so you can fix it without guessing:

403 missing_scope
{
  "type": "https://api2.host-tracker.com/problems/missing-scope",
  "title": "The token does not carry the scope this operation requires.",
  "status": 403,
  "code": "missing_scope",
  "errors": [
    { "required": "monitor:write", "granted": ["monitor:read"] }
  ]
}

2First call

List your monitors. Any endpoint would do; this one needs only monitor:read and proves the token, the host and the header are all right.

request
curl 'https://api2.host-tracker.com/monitor?limit=1' \
  -H 'Authorization: Bearer YOUR_TOKEN'
200 OK
{
  "data": [
    {
      "id": "4e49d7a2-4ab5-45e2-b9f8-1d59f505ad45",
      "type": "http",
      "name": "Marketing site",
      "url": "https://example.info",
      "state": "down",
      "since": 1785670783,
      "enabled": true,
      "tags": [],
      "created": 1785670783,
      "updated": 1785670783,
      "slaTarget": null
    }
  ],
  "nextCursor": "eyJrIjoiMDAwMDAwMDAwMTc4NTU2NTY1NCIsImki…",
  "hasMore": true
}

That is the collection envelope. To walk the rest, send the nextCursor value back as cursor and stop when hasMore is false. Never construct a cursor yourself - it is opaque and tamper-evident: it carries a checksum over its own payload, so a cursor that was edited, truncated or handed to a build that no longer understands it is refused with 422 invalid_cursor rather than silently answering the wrong page. That checksum is not a signature - it uses no secret, and it is there to catch corruption, not a determined forger. It does not need to be more than that: every v2 query is re-scoped to your own account on the server, so the most a hand-made cursor can do is reorder results you were already entitled to see.

Every response carries x-request-id

Success or failure alike. Log it; it is the same value a 500 puts in its traceId, and it is what support needs to find your call.

response headers
HTTP/2 200
content-type: application/json; charset=utf-8
x-request-id: req_85499279fbda4d78919a1ea06766013b

Endpoints whose scope carries a countable quota also answer RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, and a 429 adds Retry-After. GET /account/quota reports the same numbers on demand, per scope, without spending anything.

3Pick where the check runs from

A monitor runs from one or more agent pools - named groups of monitoring locations. Ask the API which ones your account can use rather than hardcoding names; the list is account-shaped and it also tells you the minimum number of agents a monitor must be given.

request
curl 'https://api2.host-tracker.com/agent/pool' \
  -H 'Authorization: Bearer YOUR_TOKEN'
200 OK (abridged)
{
  "data": [
    {
      "id": "allworld",
      "name": "All world",
      "agents": { "net": 6, "waterfall": 4 },
      "children": ["easteurope", "northamerica", "russia", "westeurope"],
      "parents": []
    },
    {
      "id": "northamerica",
      "name": "North America",
      "agents": { "net": 1 },
      "children": [],
      "parents": ["allworld"]
    }
  ],
  "nextCursor": null,
  "hasMore": false,
  "summary": { "minAgents": { "default": 1 }, "defaults": {}, "presets": [] }
}
This endpoint defines no query parameters at all - not even limit. Sending one answers 422 unknown_parameter with an empty allowed list, which is the API saying "none are accepted here", not "I forgot to tell you". The whole pool list fits in one response by design.

4First monitor

The smallest useful http monitor. Only type and url are required; interval and locations.pools are worth sending explicitly, because the defaults are account settings you probably do not want to inherit silently.

request
curl -X POST 'https://api2.host-tracker.com/monitor' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: quickstart-monitor-1' \
  -d '{
    "type": "http",
    "url": "https://example.com",
    "name": "Docs quickstart monitor",
    "interval": 60,
    "locations": { "pools": ["westeurope"] }
  }'
201 Created  ·  Location: /monitor/79c21af5-…
{
  "id": "79c21af5-0f43-4262-b7b6-841cd3b39b19",
  "type": "http",
  "name": "Docs quickstart monitor",
  "url": "https://example.com",
  "state": "up",
  "since": 1785712608,
  "enabled": true,
  "tags": [],
  "updated": 1785712608,
  "interval": 60,
  "slaTarget": null,
  "locations": { "pools": ["westeurope"], "excludedAgents": [] },
  "settings": { "followRedirect": true },
  "attached": { "dnsbl": false, "sslExp": false, "domainExp": false, "webRisk": false },
  "created": 1785712608,
  "subscription": []
}

A write returns the created resource in full, so there is no verification read to make. Note what the response tells you that the request did not: the type's default settings, the attached sub-checks that are available and currently off, and the canonical id - also given in the Location header.

Intervals are account-bounded. The same request with "interval": 300 answered 422 on the account these excerpts came from, and said exactly what was allowed:
422 invalid_interval
{
  "code": "invalid_interval",
  "status": 422,
  "errors": [ { "pointer": "/interval", "value": 300, "allowed": [60] } ]
}
Read errors[0].allowed and retry - never hardcode an interval you have not been told is permitted.
A monitor is unique on (url, type) - so running this step a second time is a 409, not a second monitor. That is the answer to "why did my quickstart work once", and the refusal is useful rather than just a no: it names the row you already have.
409 duplicate_monitor
{
  "code": "duplicate_monitor",
  "status": 409,
  "errors": [ {
    "existingId": "79c21af5-0f43-4262-b7b6-841cd3b39b19",
    "key": { "url": "https://example.com", "type": "http" }
  } ]
}
Follow existingId to adopt the monitor you already have, or change the url. Note the difference from a keyed retry: replaying the exact same request with the same Idempotency-Key replays the original 201 instead, with Idempotency-Replayed: true - the 409 is what a fresh request for an address you already monitor gets.
The Idempotency-Key above is optional here but always a good idea. On nine operations it is mandatory. See Jobs & idempotency for the required list and the replay semantics.

5First webhook

Register an https endpoint of your own and choose which events reach it. The scope member is required and decides which monitors the subscription covers - exactly one of {"all": true}, {"monitorIds": [...]} or {"tags": [...]}.

request
curl -X POST 'https://api2.host-tracker.com/webhook' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://hooks.example.com/host-tracker",
    "events": ["monitor.down", "monitor.up", "incident.opened"],
    "scope": { "all": true },
    "name": "Docs quickstart webhook"
  }'
201 Created  ·  Location: /webhook/0c3c7b07-…
{
  "id": "0c3c7b07-cecb-43dd-9b76-8516d3b9c771",
  "url": "https://hooks.example.com/host-tracker",
  "events": ["monitor.down", "monitor.up", "incident.opened"],
  "scope": { "all": true, "monitorCount": 8 },
  "name": "Docs quickstart webhook",
  "enabled": true,
  "consecutiveFailures": 0,
  "secret": {
    "set": true,
    "updatedAt": 1785712680,
    "value": "whsec_tPlGftcqGUhzFSMwQgQEMPPiBYChRnIP7ku03iytipE="
  },
  "created": 1785712681,
  "updated": 1785712680
}
secret.value is returned exactly once, here on the 201. Every later read answers only { "set": true, "updatedAt": … }. Store it now; if you lose it, rotate it. It is the key your endpoint verifies deliveries with.

What arrives

Every delivery is a POST of the same envelope, with the event's own payload under data. This is a real delivery captured at a receiving endpoint:

POST /host-tracker  ·  request headers
Content-Type: application/json; charset=utf-8
HT-Event: monitor.created
HT-Delivery: d_186bb95f7bf1494ba3d249fae486ce5f
HT-Webhook: a5fc0f43-0541-4d05-8af0-28b46f6ded6a
HT-Attempt: 1
HT-Signature: t=1785672303,v1=f1e2025a9964d762a9df07a3961063e08200c1bc0ce45b54372aa9f66bc7a0c5
request body
{
  "id": "d_186bb95f7bf1494ba3d249fae486ce5f",
  "event": "monitor.created",
  "occurredAt": 1785672303,
  "apiVersion": "v2",
  "data": {
    "id": "fb7d775c-a4ea-4f57-84b9-37ff98de34f8",
    "type": "http",
    "name": "bat-hooked",
    "url": "https://hooked.example.com",
    "state": "up",
    "since": 1785672301,
    "interval": 60,
    "locations": { "pools": ["westeurope"], "excludedAgents": [] }
    // … the rest of the monitor representation
  }
}

Two things to do before you trust a delivery: verify the signature, and deduplicate on HT-Delivery - that value is stable across every retry of the same delivery, so it is the idempotency key for your receiver. Both are covered in the webhooks guide.

Test it without waiting for an outage

request
curl -X POST 'https://api2.host-tracker.com/webhook/WEBHOOK_ID/test' \
  -H 'Authorization: Bearer YOUR_TOKEN'

It sends a synthetic monitor.down through the real delivery path and answers with what happened - including the signature it sent, so you can check your verifier against it. A failure is reported plainly rather than hidden:

200 OK
{
  "deliveryId": "d_98ccee49a7314948a00016215842575b",
  "event": "monitor.down",
  "latencyMs": 37,
  "signatureSent": "t=1785712688,v1=fd916924a11419701813d4be56de2fff7e221a0193ecca37876a49af9df6dfca",
  "outcome": "failed",
  "error": "The SSL connection could not be established, see inner exception."
}

That particular answer is what a self-signed certificate looks like from our side: deliveries require https with a certificate that validates against the public trust chain. See Webhooks for how to test locally without one.

Where to go next