Skip to content

Webhooks

View as Markdown

A webhook pushes HostTracker events to your own HTTPS endpoint as they happen - a monitor going down or up, an incident opening or closing, a certificate or domain about to expire, a maintenance window ending, a monitor or contact being changed - so you do not have to poll the API. Every delivery is signed, so your endpoint can prove it came from HostTracker.

Webhooks are managed through the API and the tools built on it (SDKs, ht-cli, MCP, Terraform as hosttracker_webhook). There is no webhook page in the app. For a simple alert-only destination you can set up in the app, use a webhook alert contact instead (see the difference).

Field Type / allowed values Default Limits What it does for you
url https:// URL, publicly reachable - (required) 2,048 characters; one webhook per URL on an account Where deliveries are POSTed.
events Array of event names (see Events), at least one - (required) The 13 subscribable events Which events are sent. No wildcard - list each one.
scope Exactly one of {"all": true}, {"monitorIds": [...]}, {"tags": [...]} - (required) Up to 500 monitors Which monitors’ events are sent. A tags scope also picks up monitors tagged later. Contact events ignore scope.
name Text or null None 100 characters A label for you.
headers Array of {"header": "...", "value": "..."} None 20 headers; name 100 chars, value 1,000 chars; names starting with HT- or webhook- are refused Extra headers on every delivery, for example your own auth header.
secret Your own string (16-128 characters), or omit to have one generated; {"rotate": true} on update Generated (whsec_...) - The key deliveries are signed with. Shown in full only in the create answer and a rotate answer.
enabled true / false (update only) true - Pause deliveries, or re-enable after an automatic disable.

Read-only fields on the webhook: id, consecutiveFailures, disabledReason (deliveryFailure or gone), lastDeliveryAt, secret ({"set": true, "updatedAt": ..., "previousValidUntil": ...}), created, updated.

Scope webhook:write (and webhook:read to list and read them).

Terminal window
curl -X POST https://api2.host-tracker.com/webhook \
-H "Authorization: Bearer $HT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/hosttracker",
"events": ["monitor.down", "monitor.up", "incident.opened", "incident.closed"],
"scope": { "tags": ["prod"] },
"name": "Production alerts to our bus"
}'

The answer is 201 Created:

{
"id": "0c3c7b07-cecb-43dd-9b76-8516d3b9c771",
"url": "https://hooks.example.com/hosttracker",
"events": ["monitor.down", "monitor.up", "incident.opened", "incident.closed"],
"scope": { "tags": ["prod"], "monitorCount": 8 },
"name": "Production alerts to our bus",
"enabled": true,
"consecutiveFailures": 0,
"secret": { "set": true, "updatedAt": 1785712680, "value": "whsec_tPlGftcqGUhzFSMwQgQEMPPiBYChRnIP7ku03iytipE=" },
"created": 1785712681,
"updated": 1785712680
}

Store secret.value now - later reads show only {"set": true, ...}. If you lose it, rotate it.

With MCP, create_webhook does the same, but its arguments are flat: events is a comma-separated string, and the scope is chosen with monitorIds or tags (comma-separated; send at most one, or neither for the whole account) - there is no scope argument. The tool answer carries the secret once.

create_webhook(url="https://hooks.example.com/hosttracker", events="monitor.down,monitor.up",
tags="prod", name="Prod down/up")

update_webhook can replace the events and set a monitorIds scope; to switch an existing webhook to a tag scope or back to the whole account, use api_request with PATCH /webhook/{id} and a scope object.

Other operations:

Task Operation
List / read GET /webhook, GET /webhook/{id} (GET /webhook also returns the live event catalogue in summary.eventTypes)
Change url, events, scope, name, headers, enabled PATCH /webhook/{id} (only the members you send)
Rotate the secret PATCH /webhook/{id} with {"secret": {"rotate": true}}
Send a test delivery POST /webhook/{id}/test with optional {"event": "monitor.down"}
Delivery log GET /webhook/{id}/delivery
Redeliver one delivery POST /webhook/{id}/delivery/{deliveryId}/redeliver
Delete DELETE /webhook/{id} - pending deliveries are dropped and the secret cannot be recovered
Event Fires when data carries
monitor.down An alert-grade down transition, after recheck confirmation and any alert delay. monitor (id, name, url, type), state, occurredAt, failedAt, error (code, message, codename), recheck[] (what each location saw)
monitor.up The matching recovery. as above, plus firstFailedAt, lastFailedAt, failedChecks, downtimeSec
monitor.repeatedlyDown The “still down” reminder tier. as monitor.up (state down)
incident.opened A down episode opens - including one inside a maintenance window. incidentId, monitorId, start, cause, underMaintenance
incident.closed The episode resolves. the above plus end, durationSec, checkCount
monitor.created A monitor is created (one event per monitor, bulk included). the full monitor, as GET /monitor/{id} returns it
monitor.updated A monitor’s configuration changes (one per monitor, bulk included). the full monitor
monitor.deleted A monitor is deleted (one per monitor, bulk included). the delete receipt
maintenance.ended A maintenance window ends on schedule, or is cancelled while active. There is no “started” event. maintenanceId, name, from, to, endedAt, endedEarly, monitorIds
certificate.expiring An SSL certificate check crosses a warning threshold. monitorId, notAfter, daysLeft, issuer, threshold, endpoint
domain.expiring A domain expiry check crosses a warning threshold. monitorId, expiresAt, daysLeft, registrar, sharedDomain, threshold
contact.confirmed A contact becomes confirmed. contactId, type, address, confirmedAt, via (code or inherit)
contact.updated A contact’s configuration changes. the full contact, as GET /contact/{id} returns it

Two more events are callback-only: job.completed and job.progress are sent to the webhook a job-creating request names in "callback": {"webhookId": "..."}. Putting them in events is refused with 422 unknown_event_type. Full payload fields: Webhook events reference.

Every delivery is a POST with a JSON envelope:

{
"id": "d_e944b34a57924935968d32ea94a07832",
"event": "monitor.down",
"occurredAt": 1785672266,
"apiVersion": "v2",
"data": { }
}
  • id is the delivery id - the same on every retry and redelivery. Deduplicate on it.
  • occurredAt is when the event happened, not when this attempt was sent.
  • Members with no value are omitted; treat an absent member as null. New members may appear; ignore what you do not know.

Headers on every delivery:

Header Value
HT-Event The event name, so you can route before parsing.
HT-Delivery d_<32 hex> - same as the envelope id.
HT-Webhook The webhook’s id (what GET /webhook/{id} takes).
HT-Attempt Attempt number, starting at 1. A redelivery continues the count.
HT-Signature t=<unix seconds>,v1=<hex> - HostTracker’s signature.
webhook-id, webhook-timestamp, webhook-signature The same signature in Standard Webhooks form.

Your own configured headers are added too; they can never replace these.

Both signature schemes are on every delivery; use whichever suits your stack. Always verify against the raw body bytes as received - re-serialised JSON will not match.

HT-Signature

  1. Split the header on commas; take t and every v1 value (during a secret rotation there are two).
  2. Reject the delivery if t is more than 300 seconds from your clock.
  3. Compute HMAC-SHA256 over t + "." + rawBody, keyed with the whole secret string as UTF-8, including the whsec_ prefix. Encode as lowercase hex.
  4. Accept if it equals any v1 value (use a constant-time comparison).
import hmac, hashlib, time
def verify_ht(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
t = next(v for k, v in parts if k == "t")
sigs = [v for k, v in parts if k == "v1"]
if abs(int(time.time()) - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)

Standard Webhooks

Pass the whole secret (with whsec_) to any Standard Webhooks library:

from standardwebhooks import Webhook
payload = Webhook("whsec_...").verify(raw_body, request_headers) # raises on a bad signature

By hand: the signed string is webhook-id + "." + webhook-timestamp + "." + rawBody; the key is the secret with whsec_ removed, then base64-decoded; the digest is HMAC-SHA256 in base64; the header holds space-separated v1,<base64> entries.

ht-cli webhooks verify --secret "$SECRET" --headers-file headers.txt < body.json checks a captured delivery, and the SDKs include a verifier. POST /webhook/{id}/test returns the signatureSent, which you can use as a test vector.

Answer with any 2xx quickly - each attempt times out after 10 seconds - and do the real work afterwards.

Your response What HostTracker does
2xx Delivered.
408, 429, any 5xx, a TLS or network failure Retryable (a Retry-After on your 429 is honoured when longer than the next delay).
Any other 4xx Permanent failure - not retried.
410 Gone Disables the webhook at once. Use it when you decommission an endpoint.

Retry ladder for events raised by the monitoring engine (monitor.down, monitor.up, monitor.repeatedlyDown, incident.*, certificate.expiring, domain.expiring, and maintenance.ended when a window ends on schedule): one attempt, then up to five retries after 10 s, 60 s, 5 min, 30 min and 2 h (about 2.6 hours in total, each delay with up to 20% jitter). While a delivery waits for a retry, the log shows it as pending with a nextRetryAt. The queue is durable, so HostTracker deploys do not lose retries.

Not retried: events caused by API calls (monitor.created, monitor.updated, monitor.deleted, contact.confirmed, contact.updated, maintenance.ended from an early cancel), job callbacks, test sends and redeliveries get one attempt. The state they announce is always readable from the API; use redelivery if you missed one.

Auto-disable. A webhook is switched off (enabled: false with a disabledReason) after 20 consecutive failed deliveries, after 24 hours in which every delivery failed, or at once on a 410. The account’s confirmed email contacts get one email (“Your HostTracker webhook has been disabled”) naming the URL, the reason and the last error. Fix the endpoint, then re-enable with PATCH /webhook/{id} and {"enabled": true} - this also resets the failure counter. Deliveries that failed while it was off are not replayed automatically.

GET /webhook/{id} (enabled, disabledReason, consecutiveFailures, lastDeliveryAt) is the reliable health signal to watch.

GET /webhook/{id}/delivery lists recent deliveries, one row per delivery with all its attempts:

  • Row fields: id, event, occurredAt, outcome (pending, delivered, failed, dropped), nextRetryAt, attempts[] (at, statusCode, latencyMs, error), payloadDigest (SHA-256 of the body), resourceId.
  • Filters: from, to, event, outcome (lists are any-of, e.g. outcome=failed,dropped). Cursor-paged.
  • Deliveries and their payloads are kept for 7 days.

POST /webhook/{id}/delivery/{deliveryId}/redeliver resends a past delivery: same body, same HT-Delivery id (so a deduplicating receiver sees a repeat), a fresh signature and timestamp, and the attempt count continues. It is refused with 422 when the webhook is disabled (webhook_disabled) or the payload was not kept (payload_not_retained - bodies over 32 KB are not stored).

POST /webhook/{id}/test sends a synthetic event (default monitor.down) through the real signed path and answers with the outcome, latency, any error and the signatureSent. It works on a disabled webhook too, so you can check a fix before re-enabling.

PATCH /webhook/{id} with {"secret": {"rotate": true}} returns the new secret once. For the next 24 hours every delivery is signed with both the new and the previous secret (two v1 entries); secret.previousValidUntil says when the old one stops. Deploy the new secret any time inside that window - as long as your verifier accepts any matching signature.

  • HTTPS only (422 invalid_url, reason scheme_not_allowed for http://), with a certificate that validates against the public trust chain. Self-signed certificates fail every delivery and eventually auto-disable the webhook.
  • Public destinations only. Loopback, private (10/8, 172.16/12, 192.168/16), carrier-NAT, link-local, IPv6 unique-local and cloud metadata addresses are refused (422 invalid_url, reason destination_not_allowed), and checked again on the resolved address at every delivery.
  • Redirects are followed up to 3 hops, only to https; each hop gets the full signed request.
  • To test against your own machine, use a public HTTPS tunnel (ngrok, Cloudflare Tunnel) rather than localhost.
  • 20 webhooks per account on every plan (403 package_limit, feature: "webhooks"). Webhooks do not count against your contact limit.
  • One webhook per URL: a second one for the same URL answers 409.
  • enabled: false is refused on create (422, reason not_on_create).
  • Your receiver should be idempotent (deduplicate on HT-Delivery) and quick (answer within 10 seconds).
  • Do not build a workflow that depends on a lifecycle event arriving - those are sent once. Reconcile with the API.
v2 webhook (this page) Webhook alert contact
Set up in API, SDKs, CLI, MCP, Terraform The app (Contacts) or POST /contact with type: "http"
Events 13 typed events incl. lifecycle and expiry Monitor alerts (down, up, still down) through its subscriptions
Payload Fixed, documented JSON envelope Default JSON/form, or your own template with [[token]] placeholders
Signed Yes (HMAC, two schemes) No
Retries Durable ladder for engine events Per the alert pipeline
HTTPS Required Not required

A webhook contact is set up per monitor subscription like any other alert channel - see Webhook alerts. Deleting one never affects the other, and GET /contact does not list v2 webhooks.