Skip to main content

No matching sections.

Alerting

How to get told when a monitor changes state, and how to stop being told during a deploy. This is usually the first real integration job after creating a monitor.

The model in one paragraph

Contacts are where a notification goes - an email address, a phone number, a webhook. Subscriptions are which monitor, which contact, which alert type - the wiring that decides who hears about what. Maintenance windows are when not to send - a scoped, time-boxed suppression that does not touch the wiring itself. Delete a maintenance window and every subscription it was suppressing is exactly as it was before.

Creating a contact and confirming it

GET /contact/type is the authoritative channel list - read it rather than hardcoding one, because creatable and confirmable differ per type and change independently of this page:

request
curl 'https://api2.host-tracker.com/contact/type' \
  -H 'Authorization: Bearer $TOKEN'
TypeCreatableConfirmableGateways
emailyesyes-
smsyesyesinfobip, twiliosms, acemount
voiceCallyesyestwiliovoice
http (webhook)yesno-
telegram, viber, facebook, googleChat, discord, webPushno - requiresRegistrationno-

The messenger types are not creatable through this endpoint at all - they are provisioned by linking a bot account, not by posting an address. http is a webhook; see Webhooks for its own guide. Everything below is about the three types a plain POST can create.

Create - and the code goes out in the same call

Creating a confirmable contact issues a confirmation code and sends it over that channel in the same request. Nothing separate has to be triggered to start confirmation:

request
curl -X POST 'https://api2.host-tracker.com/contact' \
  -H 'Authorization: Bearer $TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: contact-create-2026-08-07-01' \
  -d '{ "type": "email", "address": "[email protected]", "name": "On-call" }'
201 Created
{
  "id": "73ad6f36-867b-4cfc-a704-49a65d76172b",
  "type": "email",
  "name": "On-call",
  "address": "[email protected]",
  "confirmed": false,
  "overlimited": false,
  "alertDelay": 0,
  "groupedAlerts": true,
  "created": 1786122698,
  "updated": 1786122698,
  "confirmation": {
    "sent": true,
    "channel": "email",
    "expiresAt": 1786124498,
    "triesAllowed": 3
  }
}

Idempotency-Key is required here because the type is confirmable (see Jobs & idempotency) - a retried create would send a second code and, for SMS or voice, bill for it twice.

This step needs a human, and no API call substitutes for it. The code is delivered only on the contact's own channel - an inbox, a phone. There is no endpoint that reveals it, replays it, or lets you skip the wait. Build your onboarding flow around a person reading a code and typing it back, not around polling.

Confirm

request
curl -X POST 'https://api2.host-tracker.com/contact/73ad6f36-867b-4cfc-a704-49a65d76172b/confirmation/verify' \
  -H 'Authorization: Bearer $TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "code": "48213" }'

Three outcomes, each a distinct status - never a 200 with a status string to branch on:

OutcomeStatus
Correct code200 the confirmed contact
Wrong, expired, or attempts exhausted422 invalid_confirmation_code
Already confirmed409 contact_already_confirmed

A wrong code, verified live:

422 invalid_confirmation_code
{
  "type": "https://api2.host-tracker.com/problems/invalid-confirmation-code",
  "title": "The confirmation code is not valid.",
  "status": 422,
  "code": "invalid_confirmation_code",
  "errors": [
    { "pointer": "/code", "attemptsLeft": 2, "expiresAt": 1786124499, "reason": "wrong" }
  ]
}

You get 3 attempts and the code is valid for 30 minutes. Both budgets are visible in every response that touches them (attemptsLeft, expiresAt), so a client never has to guess how much room is left.

Resend

Needed only when the first send failed to arrive or the 30-minute window lapsed. A resend returns the still-valid code again rather than rotating it, so it never invalidates a code someone is already reading:

POST /contact/{id}/confirmation  ·  202 Accepted
{ "sent": true, "channel": "email", "expiresAt": 1786124499, "triesAllowed": 3 }
Resend has its own, tighter rate limit - one call per contact per 60 seconds, separate from Idempotency-Key. Calling it again immediately, verified live:
429 rate_limited  ·  retry-after: 60
{
  "type": "https://api2.host-tracker.com/problems/rate-limited",
  "title": "Too many requests - slow down.",
  "status": 429,
  "code": "rate_limited",
  "errors": [ { "limit": 1, "window": "60s", "retryAfter": 60 } ]
}

The response also carries a real Retry-After header, so a client can back off without parsing the body.

Subscriptions

An alert subscription is the wiring between ONE monitor and ONE contact - a set of the alert types (up / down / repeatedlyDown) that contact hears about for that monitor. It is addressed as a resource nested under BOTH parents, so you can read and write it from either side:

Do thisEndpoint
Set the pair's alert typesPUT /monitor/{monitorId}/alert/{contactId} (the monitor side is canonical for writes)
Read one pairGET /monitor/{monitorId}/alert/{contactId}  ·  mirror GET /contact/{id}/alert/{monitorId}
List a monitor's contacts / a contact's monitorsGET /monitor/{monitorId}/alert  ·  GET /contact/{id}/alert
Remove one pairDELETE /monitor/{monitorId}/alert/{contactId}
Remove ALL for a monitor / a contactDELETE /monitor/{monitorId}/alert  ·  DELETE /contact/{id}/alert

Set which alert types a contact hears about for a monitor

PUT is set-state and idempotent: the body's alertTypes is the EXACT desired set for the pair. Send it again with a different set and the pair is updated to match; there is no separate add/remove. At least one type is required - to remove the subscription entirely, use DELETE.

request
curl -X PUT 'https://api2.host-tracker.com/monitor/MONITOR_ID/alert/CONTACT_ID' \
  -H 'Authorization: Bearer $TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "alertTypes": ["down", "up"] }'
200 OK - the resulting subscription
{
  "contact": { "id": "CONTACT_ID", "type": "email", "name": "On-call" },
  "alertTypes": [ "down", "up" ],
  "created": 1786122813
}

An empty set is refused rather than silently clearing the pair:

PUT with alertTypes:[]  ·  422
{
  "code": "validation_failed",
  "detail": "At least one alert type is required; use DELETE to remove the subscription.",
  "errors": [ { "pointer": "/alertTypes", "reason": "empty" } ]
}

Remove a subscription

DELETE /monitor/{monitorId}/alert/{contactId} removes the pair. A pair that has no subscription answers 404 - so 200 always means a real removal happened, never a no-op reported as success.

Read and audit

Both sides carry the identifying projection of the OTHER end, so an audit needs no follow-up lookup: GET /monitor/{monitorId}/alert lists the contacts this monitor alerts (each with its type set), and the mirror GET /contact/{id}/alert lists the monitors that alert this contact - the same rows addressed from the other parent.

For the whole account at once there is a flat list, one row per (monitor, contact) pair: GET /alert, and GET /alert/{id} to read one pair by the als_-prefixed id those rows carry. Report subscriptions have the identical pair: GET /report and GET /report/{id} (rsub_…) - see Results & reports.

Because a subscription list filters by BOTH ends at once, the filters are entity-prefixed so a bare type is never ambiguous - narrow the related MONITOR with monitor.id, monitor.type, monitor.tag, monitor.url (+monitor.like) and monitor.q, and the related CONTACT with contact.id, contact.type, contact.confirmed and contact.q. So "which HTTP monitors do my email contacts get alerts for" is one call: GET /alert?monitor.type=http&contact.type=email.

The same rows can be grouped for you, each grouping a typed sibling route carrying the same filters: GET /alert/by-monitor returns one element per monitor with its subscribed contacts nested underneath (the monitor identity carried once, not repeated per contact), and GET /alert/by-contact is the mirror. Reports have the same pair (/report/by-monitor, /report/by-contact). The flat list stays the default (one row per pair).

Two scope rules worth knowing before you mint a token for this. The flat account-wide lists (/alert, /report) are the only operations requiring subs:read; every NESTED subscription read and write rides its parent's monitor:* or contact:*. And on the monitor-side reads the counterparty contact's address appears only when the token also carries contact:read - without that gate a monitor-only token could harvest every address in the account through them. The rest of the projection (id, type, name) is unaffected.

Bulk removal

There is no filter-based bulk-delete job. To clear a whole side in one call, use the scoped delete: DELETE /monitor/{monitorId}/alert removes every subscription on that monitor, and DELETE /contact/{id}/alert removes every subscription that would notify that contact. To MOVE a contact's subscriptions onto another (the old replace op), read the source side, PUT the same type sets under the new contact, then DELETE the old pairs.

Subscriptions are not settable on monitor PATCH

This is a recent, deliberate change - check it if you learned this API a while ago. PATCH /monitor/{id} and the bulk-update job refuse alertSubscriptions/reportSubscriptions outright. They are not silently ignored - the request is rejected before anything else in it is applied.
PATCH with alertSubscriptions  ·  422, verified live
{
  "code": "validation_failed",
  "errors": [ {
    "pointer": "/alertSubscriptions",
    "reason": "unknown_member",
    "allowed": ["attached", "contacts", "cronSchedule", "enabled", "fullLog", "interval", "locations", "name", "openStat", "recheck", "settings", "slaTarget", "tags", "type", "url"]
  } ]
}

The two places subscriptions really are writable:

WhereHow
POST /monitor (create)Inline alertSubscriptions[], applied in the SAME transaction as the monitor. Each entry names contactIds[] (existing contacts) and/or contactRefs[] (contacts the same request also creates).
PUT /monitor/{monitorId}/alert/{contactId}Everything after create - set, remove, or clear a whole side. Covered above.

A create with inline subscriptions, verified live - the response's subscription array proves the wiring happened, no follow-up read needed:

POST /monitor - inline alertSubscriptions  ·  201
{
  "id": "6fd3eb30-0646-4b8e-a02b-49ebecbbbf60",
  "type": "http", "name": "checkout", "url": "https://example.com",
  /* the rest of the monitor representation */
  "subscription": [
    { "alertType": "down", "contact": { "id": "b428dbe0-…", "type": "email", "name": "On-call" }, "created": 1786122813 }
  ]
}

So: wire alerting when you create the monitor, or manage it afterward through the nested subscription endpoints (PUT / DELETE /monitor/{monitorId}/alert/{contactId}). There is no third path.

Alert types and delays

GET /alert/type
{
  "data": [
    { "type": "up", "label": "Recovered" },
    { "type": "down", "label": "Down" },
    { "type": "repeatedlyDown", "label": "Still down" }
  ],
  "summary": { "alertDelays": [0, 3, 5, 15, 30, 60, 180, 360, 720, 1440] }
}

Three alert types, and that is the whole vocabulary for both a subscription's alertTypes[] and the notification log's kind (below). GET /alert/type is part of the anonymous reference tier - no token required, though a real token still works normally.

alertDelay is minutes, and it lives on the CONTACT, not the subscription. It is how long a failure must persist before that contact hears about it - set it with alertDelay on POST /contact or PATCH /contact/{id}, from the same ladder: [0, 3, 5, 15, 30, 60, 180, 360, 720, 1440] minutes. One contact, one delay, applied uniformly across every monitor it is subscribed to - there is no per-subscription override. Two contacts on the same monitor can legitimately hear about the same outage at different times: a pager contact at 0, an escalation contact at 15.

The notification log

What was actually sent, as opposed to what is wired to be sent. The log lives under the contact prefix - it is per-contact delivery data, so alert stays free to mean purely subscriptions:

request
curl 'https://api2.host-tracker.com/contact/notification?contact=CONTACT_ID&outcome=sent' \
  -H 'Authorization: Bearer $TOKEN'
The log is indexed by contact, not by monitor. GET /contact/notification filters on contact, outcome and a from/to window - there is no monitor filter on this endpoint. Each row still names the monitor that caused it, so client-side grouping by monitor is possible; server-side filtering by it is not. To read one contact's log without passing the filter yourself, use GET /contact/{id}/notification; for per-(contact × outcome × day) delivery counts, use GET /contact/notification/summary.

outcome is the pipeline's own vocabulary, wider than a plain sent/failed split - sent, grouped (folded into a digest instead of sent alone), blocked, cancelled, superseded, billingFailed, insufficientBalance, noProfile, renderFailed, sendFailed, startingError. It is spelled outcome - on the filter and on every attempt - because it says how a delivery ended; state is this API's word for a lifecycle. (Both were spelled status until 2026-08-17; there is no alias, so ?status= is now 422 unknown_parameter.)

Each row is one alert - one contact, one instant - and its delivery attempts are nested in attempts[]. The per-attempt outcome, note, template and externalId live on each attempt, the same shape GET /contact/notification/{id} returns (minus the rendered subject/body):

200 OK  ·  one row per alert
{
  "id": "…", "sentAt": 1786122698, "kind": "down", "channel": "email",
  "contact": { /* who */ }, "monitor": { /* what it was about */ },
  "attempts": [ { "at": 1786122698, "outcome": "sent", "externalId": "…" } ]
}

An account whose monitors have never actually changed state has an empty log - not an error:

200 OK  ·  nothing delivered yet
{ "data": [], "nextCursor": null, "hasMore": false }

Read one notification in full when the list's summary fields are not enough to explain what a recipient actually received:

GET /contact/notification/{id}
{
  "id": "…", "sentAt": 1786122698, "kind": "down", "channel": "email",
  "contact": { /* who */ }, "monitor": { /* what it was about */ },
  "subject": "…", "body": "…",
  "attempts": [ /* every delivery attempt logged against this send */ ]
}

subject and body are the RENDERED content, exactly as the recipient saw it - useful for confirming a template change looked right, or for support diagnosing "I never got the alert" against what was actually attempted.

Maintenance windows

Suppress alerting (and, optionally, uptime statistics) over an explicit set of monitors for a bounded time - "pause everything for a deployment" in one call, with resume built in: the window just expires.

GET /maintenance is the one list whose updatedSince is genuinely edit-exact. Every patch - a pure rename, a reschedule, a change of covered monitors - stamps the window's change marker, so a delta poll here really does mean "everything that changed". That is not true of monitors or contacts; see Delta sync before you assume it is. The list sorts by from (the default, newest window start first) or created, each with an optional :asc / :desc suffix.
MemberRule
name, from, monitorIdsRequired. monitorIds must name at least one monitor - a bare filter is not accepted, same rule as subscriptions.
to or durationSecOne of the two is required, to say how long the window lasts.
timezoneOptional, defaults to UTC. IANA or Windows spelling both accepted - see below.
suppress: {alerts, stats}Optional - omitted defaults to {alerts:true, stats:false}, because suppressing alerts is the reason a maintenance window exists. If you send it explicitly, at least one of the two must be true.

Sending suppress with both flags false, verified live:

422 empty_selection
{
  "code": "validation_failed",
  "detail": "A window that suppresses neither alerts nor statistics does nothing.",
  "errors": [ { "pointer": "/suppress", "reason": "empty_selection", "allowed": ["alerts", "stats"] } ]
}

The timezone accepts either spelling; the read is always IANA

Send an IANA id (Europe/Kyiv) or a Windows id (FLE Standard Time) - either is accepted. The maintenance clock is evaluated in SQL (AT TIME ZONE), which only understands Windows zone names, so whichever spelling you send is normalised to that form at the boundary. GET always answers the IANA spelling, never the Windows one - one vocabulary on output, whatever you sent on input. ⚠ Because a Windows id can map back to several IANA zones (17 share W. Europe Standard Time), the read is not always the exact id you wrote: it is the shared zone's representative label. The clock and the daylight-saving rules are exactly preserved either way - only the label can be re-spelled.

A value that is not a real zone at all is still refused:

timezone: "Kyiv"  ·  422 unknown_enum_value
{
  "type": "https://api2.host-tracker.com/problems/unknown-enum-value",
  "status": 422,
  "code": "unknown_enum_value",
  "detail": "Use an IANA or Windows time-zone id, for example Europe/Berlin - this field accepts either spelling on write and always returns the IANA form.",
  "errors": [ {
    "pointer": "/timezone", "value": "Kyiv",
    "reason": "unknown_timezone", "expected": "IANA time zone id"
  } ]
}

Send "Europe/Kyiv" or "FLE Standard Time" and either works; GET answers "Europe/Kyiv" either way.

A window's duration is real elapsed time

Three hours means three hours. A one-time window's length is real elapsed time, computed as an absolute instant (from + durationSec). If its span crosses a daylight-saving transition, it is still exactly as long as you asked for - it does not stretch or shrink to land on a particular wall-clock reading. This is a deliberate ruling, not an oversight: the alternative (ending when the local clock reads a specific time, so the real duration flexes by an hour across a transition) is equally defensible, and the API picked instant semantics. Plan deploy windows around it: a 3-hour window scheduled across a spring-forward or fall-back stays a 3-hour window.

Create a window - to in the response is exactly from + durationSec, verified live:

POST /maintenance  ·  201
curl -X POST 'https://api2.host-tracker.com/maintenance' \
  -H 'Authorization: Bearer $TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "db migration",
  "from": 1786126488,
  "durationSec": 10800,
  "monitorIds": ["MONITOR_A"],
  "timezone": "FLE Standard Time",
  "suppress": { "alerts": true, "stats": true }
}'
{
  "id": "f9ea4559-6cb7-4f55-928e-68cf7be6ea70", "name": "db migration",
  "from": 1786126488, "to": 1786137288, "durationSec": 10800,
  "timezone": "FLE Standard Time", "enabled": true, "state": "scheduled",
  "suppress": { "alerts": true, "stats": true },
  "monitorIds": ["MONITOR_A"], "created": 1786122888, "updated": 1786122888
}

state is scheduled / active / finished, computed from the window's own clock - it is not something you set.

Cancelling a window

DELETE /maintenance/{id} answers with a receipt, not a bare 204 - because whether alerting just resumed is exactly the fact a bodiless delete would hide:

a scheduled (not yet started) window, verified live
{
  "id": "f9ea4559-6cb7-4f55-928e-68cf7be6ea70", "deleted": true, "type": "maintenance",
  "name": "db migration", "wasActive": false,
  "cascaded": { "monitorSubscriptions": 2 }
}
a window that was ACTIVE right now, verified live
{
  "id": "dce5b057-b920-4633-b079-698fe11c1695", "deleted": true, "type": "maintenance",
  "name": "emergency patch", "wasActive": true,
  "cascaded": { "monitorSubscriptions": 1 }
}
wasActive tells you whether alerting just resumed immediately. false means the window had not started yet (or had already finished) - cancelling it changed nothing about what is being alerted on right now. true means the monitors it covered went back to normal alerting the instant this call succeeded. If you meant to extend a window instead of ending it, use PATCH /maintenance/{id} - a delete is not undoable.

cascaded.monitorSubscriptions is the count of (monitor × suppression-kind) rows removed with the window - a window covering 1 monitor with both alerts and stats suppressed cascades 2, not 1.

Related guides. New to the API? Start with Quickstart for auth and your first monitor. The http contact type used above is a webhook in disguise - Webhooks covers its delivery, retries and signing in depth. Jobs & idempotency explains what Idempotency-Key actually guarantees. Every problem document on this page follows the one shape Errors documents.