# 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](https://www.host-tracker.com/apidocs/v2).

## 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](/integrations/api-authentication/) |
| Plan | API access is a plan feature - see [which plans include the API](/integrations/rate-limits/#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](#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

```bash
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/pool
```

A 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`):

```bash
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](/reference/monitor-settings/).

## 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](#body-queries-post-q)).

## Writes

- **Create** answers `201 Created` with the full resource and a `Location` header. There is no need to read it back.
- **Update** (`PATCH`) changes only the members you send. An absent member is left alone; an explicit `null` clears
  a nullable member.
- **Delete** answers `200` with 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_failed` with reason
  `unknown_member` and the list of accepted members.
- **A monitor is unique per `(url, type)`.** Creating a second one answers `409 duplicate_monitor` with the
  existing monitor's id.
- **A monitor's `type` cannot change** after creation (`422 type_immutable`).

## Collections and paging

Every list answers the same envelope:

```json
{
  "data": [ { "id": "..." } ],
  "nextCursor": "eyJrIjoi...",
  "hasMore": true
}
```

- `limit` is 1-500, default 50. An out-of-range value is refused with `422 invalid_limit`, never clamped.
- To get the next page, send `nextCursor` back as `cursor`. Stop when `hasMore` is `false` (`nextCursor` is then
  `null`). 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
  the `sort` it was issued under.
- Some lists add `count` (`{total, matched}`) when you ask with `expand=count`, and a `summary` block with
  `expand=summary`.

### Filters

The query string is a **closed vocabulary**: each endpoint refuses what it does not define.

- An unknown parameter answers `422 unknown_parameter` with the full `allowed` list (names are case-sensitive:
  `updatedsince` is refused, `updatedSince` works).
- An unknown value answers `422 unknown_enum_value` with the allowed values.
- A parameter sent empty (`?type=`) is refused, never read as "no filter".
- A list-valued filter is ANY-OF (`type=http,ping` or `type=http&type=ping`); different parameters combine with AND.

Example: `GET /monitor?type=http,ping&state=down&tag=prod&sort=name`.

### sort

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

- `expand=a,b` adds blocks to each row. Lists default to bare rows; a single read defaults to the object's own
  detail (for `GET /monitor/{id}`, `settings`). Sending `expand` replaces the defaults. An unknown token answers
  `422 unknown_expand` with 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=monitor` embeds the monitor, and
  `monitor.settings`, `monitor.subscription`, `monitor.lastIncident`, `monitor.maintenance` embed its blocks.
- `fields=id,name,state` keeps only those top-level members of each row (`id` is always kept). An unknown name
  answers `422 unknown_field`.

### 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.

```bash
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

`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](/integrations/webhooks/) to `monitor.updated`, `monitor.deleted` and
`contact.updated`, and reconcile with a full list from time to time.

### 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](/incidents/reading-results/).

## Errors

Every failure, on every endpoint, is one shape:

```json
{
  "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 on `title` or `detail` (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-id` header; a `500 internal_error` repeats it as `traceId`. 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](/reference/error-codes/#rest-api-v2-error-codes).

## 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`, reason `different_body`.
- Same key while the first call still runs: `409 idempotency_key_conflict`, reason `in_flight`, with `Retry-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

Bulk operations, report generation, large copies and statistics resets answer `202 Accepted` with a job:

```
HTTP/2 202
Location: /job/be65c73e-4e1a-49d7-8ef9-ba9b11c8681c
Retry-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`, `result` and, 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 /job` lists recent jobs.
- Instead of polling, add `"callback": {"webhookId": "..."}` to the job-creating body to receive a `job.completed`
  [webhook](/integrations/webhooks/) (add `"on": "progress"` for interim `job.progress` deliveries too).
- `POST /job/{id}/cancel` stops future items; finished items are not undone. A finished job answers
  `409 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

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

- [Official SDKs](/integrations/sdks/) for TypeScript, Python, Go and .NET.
- [ht-cli](/integrations/cli/), one command per operation.
- [MCP server](/integrations/mcp/) for AI assistants.
- [Terraform provider](/integrations/terraform/).

## Related

- [API authentication, tokens and scopes](/integrations/api-authentication/)
- [Rate limits and quotas](/integrations/rate-limits/)
- [Webhooks](/integrations/webhooks/)
- [Error codes reference](/reference/error-codes/)
