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:
curl 'https://api2.host-tracker.com/contact/type' \
-H 'Authorization: Bearer $TOKEN'
| Type | Creatable | Confirmable | Gateways |
|---|---|---|---|
email | yes | yes | - |
sms | yes | yes | infobip, twiliosms, acemount |
voiceCall | yes | yes | twiliovoice |
http (webhook) | yes | no | - |
telegram, viber, facebook, googleChat, discord, webPush | no - requiresRegistration | no | - |
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:
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" }'
{
"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.
Confirm
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:
| Outcome | Status |
|---|---|
| Correct code | 200 the confirmed contact |
| Wrong, expired, or attempts exhausted | 422 invalid_confirmation_code |
| Already confirmed | 409 contact_already_confirmed |
A wrong code, verified live:
{
"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:
{ "sent": true, "channel": "email", "expiresAt": 1786124499, "triesAllowed": 3 }
Idempotency-Key. Calling it again immediately, verified live:
{
"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 this | Endpoint |
|---|---|
| Set the pair's alert types | PUT /monitor/{monitorId}/alert/{contactId} (the monitor side is canonical for writes) |
| Read one pair | GET /monitor/{monitorId}/alert/{contactId} · mirror GET /contact/{id}/alert/{monitorId} |
| List a monitor's contacts / a contact's monitors | GET /monitor/{monitorId}/alert · GET /contact/{id}/alert |
| Remove one pair | DELETE /monitor/{monitorId}/alert/{contactId} |
| Remove ALL for a monitor / a contact | DELETE /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.
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"] }'
{
"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:
{
"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).
/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
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.
{
"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:
| Where | How |
|---|---|
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:
{
"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
{
"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:
curl 'https://api2.host-tracker.com/contact/notification?contact=CONTACT_ID&outcome=sent' \
-H 'Authorization: Bearer $TOKEN'
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):
{
"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:
{ "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:
{
"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.
| Member | Rule |
|---|---|
name, from, monitorIds | Required. monitorIds must name at least one monitor - a bare filter is not accepted, same rule as subscriptions. |
to or durationSec | One of the two is required, to say how long the window lasts. |
timezone | Optional, 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:
{
"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
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:
{
"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
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:
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:
{
"id": "f9ea4559-6cb7-4f55-928e-68cf7be6ea70", "deleted": true, "type": "maintenance",
"name": "db migration", "wasActive": false,
"cascaded": { "monitorSubscriptions": 2 }
}
{
"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.
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.