No matching sections.
Webhooks
Every event is an HTTPS POST of one envelope, signed twice - once in our own header scheme and once in Standard Webhooks format, so you can verify with an off-the-shelf library or with fifteen lines of your own.
The envelope
Five members, always the same five. The event-specific payload is data; everything
around it is envelope.
- id
- The delivery id, d_ followed by 32 hex characters. Identical to the
HT-Deliveryheader and towebhook-id. It is stable across retries and redeliveries - this is the value you deduplicate on. - event
- The event type, e.g.
monitor.down. Case-sensitive: monitor.Down is not a valid type and will never appear. - occurredAt
- When the event happened, in Unix seconds. Not when the delivery was attempted - a retry two hours later
still carries the original
occurredAt. - apiVersion
- Currently the literal
"v2". See Versioning. - data
- The event's own payload, and the only member that differs by event type. For the monitor lifecycle events
it is the full monitor representation - the same object
GET /monitor/{id}returns; for the alert kinds it is the alert record; for ajob.*callback it is the job. Null members are omitted, so treat an absent key as null rather than as an error.
webhooks object (one entry per event type, with the
data schema), and
GET /webhook's summary.eventTypes block carries the
live catalogue with a one-line description each. Branch on event, read the shape
from there, and treat unknown members as forward-compatible additions (see
Versioning).
{
"id": "d_e944b34a57924935968d32ea94a07832",
"event": "monitor.down",
"occurredAt": 1785672266,
"apiVersion": "v2",
"data": { /* this event's own payload - see the note below for where its shape is published */ }
}
Delivery headers
| Header | Value |
|---|---|
HT-Event | The event type - the same string as the envelope's event. Lets you route before parsing. |
HT-Delivery | d_<32 hex>. Stable across retries. Deduplicate on this. |
HT-Webhook | <dashed GUID> - which of your registered webhooks this delivery belongs to (the same id GET /webhook/{id} takes). |
HT-Attempt | Attempt counter, starting at 1. A redelivery continues the count rather than resetting it. |
HT-Signature | t=<unix>,v1=<hex> - see below. |
webhook-id | Standard Webhooks. Same value as HT-Delivery. |
webhook-timestamp | Standard Webhooks. Unix seconds - the same t that is inside HT-Signature. |
webhook-signature | Standard Webhooks. v1,<base64> - see below. |
Custom headers you configured on the webhook are added too, but they can never overwrite an
HT-* or webhook-* header.
Event catalogue
Thirteen types can be named in a webhook's events array:
| Group | Types | Addressed to |
|---|---|---|
| Monitor state | monitor.down monitor.up monitor.repeatedlyDown | webhooks whose scope covers the monitor |
| Incidents | incident.opened incident.closed | same |
| Monitor lifecycle | monitor.created monitor.updated monitor.deleted | same |
| Maintenance | maintenance.ended | same |
| Expiry warnings | certificate.expiring domain.expiring | same |
| Contacts | contact.confirmed contact.updated | every enabled webhook of the account subscribed to it |
maintenance.ended fires both when a window expires on its own schedule and when it
is cancelled early, which the payload marks with endedEarly: true - there is no
maintenance.started event. And
contact.updated exists because
GET /contact?updatedSince= structurally cannot show a contact edit (see
the query surface) - this event is how an integration hears about one.
Two more types exist but are addressed per request, not subscribed to - putting either in
events answers 422 unknown_event_type:
job.completed- delivered when a job you started withcallback: { "webhookId": … }reaches a terminal state. See Jobs.job.progress- a throttled interim report of the same job's counts, with no per-item results. Delivered only when the submitting request asked forcallback: { "webhookId": …, "on": "progress" }.
GET /webhook returns the authoritative catalogue in its
summary.eventTypes block, with a one-line description each. Read it rather than
hardcoding this table.
Verifying a delivery
Both schemes ride on every delivery - they are not an either/or setting. Pick whichever suits your stack and ignore the other headers.
HT-Signature
| Step | Value |
|---|---|
| Header format | t=<unix seconds>,v1=<hex digest>, comma-separated. There may be more than one v1= during a secret rotation. |
| Signed string | <t> + "." + <raw body> - the timestamp from the header, a literal period, then the body bytes. |
| Key | The whole secret string, verbatim, UTF-8 - including the whsec_ prefix. Do not strip it and do not base64-decode it. (Standard Webhooks does the opposite; that is the one thing to get right.) |
| Algorithm | HMAC-SHA256, digest rendered as lowercase hex. |
| Freshness | Reject a delivery whose t is more than 300 seconds from your clock. |
# raw_body: bytes exactly as received. header: the HT-Signature value.
import hmac, hashlib, time
def verify_ht(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
# dict() keeps only the last v1 when several are present, so re-collect them all.
signatures = [v for p in header.split(",")
for k, v in [p.split("=", 1)] if k.strip() == "v1"]
t = parts["t"].strip()
if abs(int(time.time()) - int(t)) > tolerance:
return False # stale or replayed
signed = t.encode() + b"." + raw_body # the whole secret, prefix included
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s.strip()) for s in signatures)
const crypto = require("crypto");
// rawBody: a Buffer of the exact bytes received. header: the HT-Signature value.
function verifyHt(rawBody, header, secret, toleranceSec = 300) {
const fields = header.split(",").map(p => p.trim().split("="));
const t = (fields.find(f => f[0] === "t") || [])[1];
const signatures = fields.filter(f => f[0] === "v1").map(f => f[1]);
if (!t || Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret) // whole secret string
.update(Buffer.concat([Buffer.from(t + "."), rawBody]))
.digest("hex");
return signatures.some(s =>
s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
Standard Webhooks
If you already use a Standard Webhooks library, this is the whole integration: paste the entire secret,
whsec_ prefix included, into the library's constructor. That prefix is what
those libraries expect, and they do the stripping and decoding themselves.
from standardwebhooks import Webhook
wh = Webhook("whsec_tPlGftcqGUhzFSMwQgQEMPPiBYChRnIP7ku03iytipE=") # the secret, verbatim
payload = wh.verify(raw_body, request_headers) # raises on a bad signature
If you would rather implement it, the four facts that matter:
| Step | Value |
|---|---|
| Signed string | <webhook-id>.<webhook-timestamp>.<raw body> - the two header values and the body, period-separated. |
| Key | Strip the whsec_ prefix, then base64-decode the rest. The key is those raw bytes, not the string. |
| Algorithm | HMAC-SHA256, digest rendered as base64 (not hex). |
| Header format | v1,<base64>, and space-separated when several are present. Note the separator inside an entry is a comma while entries are separated by spaces - the opposite way round from HT-Signature. |
const crypto = require("crypto");
function verifyStandard(rawBody, headers, secret, toleranceSec = 300) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > toleranceSec) return false;
// strip the prefix, then BASE64-DECODE - the key is bytes, not the string
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key)
.update(Buffer.concat([Buffer.from(id + "." + ts + "."), rawBody]))
.digest("base64");
// entries are SPACE separated; each is "v1,<base64>"
return String(headers["webhook-signature"]).split(" ")
.map(p => p.split(",")[1])
.some(s => s && s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
HT-Signature keys on the UTF-8 bytes of
the whole secret string, whsec_ and all. Standard Webhooks keys on the base64-decoded
bytes of what follows the prefix. Using one scheme's key with the other's algorithm is the single most common
way to get "signature never matches", and it fails silently.
Retries and auto-disable
A delivery succeeds on any 2xx. Answer quickly - the request times
out after 10 seconds - and do the real work asynchronously.
| Your response | What we do |
|---|---|
| 2xx | Delivered. Done. |
408, 429, any 5xx, TLS or network failure | Retried on the ladder below. A Retry-After on your 429 is honoured when it is longer than the scheduled delay. |
Any other 4xx | Permanent. Not retried - a 400 now will be a 400 in two hours. |
410 Gone | Disables the webhook immediately. It is the one status whose meaning is "stop sending" - use it when you decommission an endpoint. |
The ladder
One attempt, then up to five retries, roughly 2.6 hours end to end. Each delay carries up to ±20% jitter so a shared outage does not produce a synchronised stampede.
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| Sent after | immediately | 10 s | 60 s | 5 min | 30 min | 2 h |
HT-Attempt tells you which one you are looking at, and
HT-Delivery stays the same throughout.
monitor.down/up/repeatedlyDown,
incident.*, certificate.expiring,
domain.expiring, and maintenance.ended when a window
expires on its own schedule. The ladder is durable - it is a queue in our database, not a
timer in one process - so a deploy on our side does not drop your outstanding retries, and while a delivery is
still laddering it reads outcome: "pending" with a
nextRetryAt.
Deliveries caused by your own API calls (
monitor.created/updated/deleted,
contact.confirmed, contact.updated, and
maintenance.ended when a window is cancelled early), the per-request callbacks
(job.completed, job.progress) and the test/redeliver
endpoints are sent inline and are not retried: a single failed attempt is the whole story,
and GET /webhook/{id}/delivery is where you see it. The call that caused them
already told you it succeeded, and the state they announce is readable from the API - so do not build a
workflow that depends on a lifecycle event arriving. Either way,
POST /webhook/{id}/delivery/{deliveryId}/redeliver replays one you missed.
Auto-disable
A webhook is switched off - enabled: false, with a
disabledReason - when either holds:
- 20 consecutive failed deliveries, or
- 24 hours in which every delivery failed, or
- a single
410 Gone, which disables it at once.
We email the account's confirmed email contacts once, when it flips - subject "Your HostTracker webhook has been disabled", naming the URL, the reason and the last error. It is a once-per-transition message, not one per failure.
Re-enable with PATCH /webhook/{id} and
{"enabled": true} after fixing the endpoint. Nothing that failed while it was off
is replayed automatically - use redelivery for anything you need back.
GET /webhook/{id} is the authoritative health signal:
enabled, disabledReason,
consecutiveFailures and lastDeliveryAt. Poll it from
your own monitoring; it is always accurate, unlike the delivery log, which is best-effort.
Rotating the secret
curl -X PATCH 'https://api2.host-tracker.com/webhook/WEBHOOK_ID' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "secret": { "rotate": true } }'
The response reveals the new secret once, exactly like the original create did. Then:
- For the next 24 hours every delivery is signed with both secrets - the new
one first, the previous one second.
HT-Signaturecarries twov1=entries andwebhook-signaturetwov1,entries. - A verifier that accepts any matching entry (both snippets above do, and every Standard Webhooks library does) needs no coordination: deploy the new secret whenever you like inside the window.
- The response's
secret.previousValidUntiltells you exactly when the old one stops signing.
Redelivery and the delivery log
GET /webhook/{id}/delivery lists recent deliveries, filterable by
from/to, event and
outcome. The outcome vocabulary is four words -
pending (still on the retry ladder, with a
nextRetryAt), delivered,
failed, dropped - and like every closed filter on this
surface it is ANY-OF, so ?outcome=failed,dropped selects both. One row per
delivery, with every attempt in its attempts[]: the status code, the
latency and the error we saw, plus a payloadDigest.
To send a past delivery again, unchanged:
curl -X POST 'https://api2.host-tracker.com/webhook/WEBHOOK_ID/delivery/DELIVERY_ID/redeliver' \
-H 'Authorization: Bearer YOUR_TOKEN'
- The body is byte-identical to the original, so
occurredAtstill names the original event time. The signature andwebhook-timestampare recomputed for now - otherwise your freshness check would reject it. HT-Deliveryis the same as the first time, so a receiver that deduplicates correctly will see it as a repeat. That is deliberate.HT-Attemptcontinues counting rather than restarting at 1.- It refuses with
422and areasonwhen the webhook is disabled (webhook_disabled) or when the original payload was not retained (payload_not_retained- bodies over 32 KB are not stored).
Transport rules
- https only. An http:// URL is refused at registration time with
422 invalid_url,reason: "scheme_not_allowed". The body carries your monitor names, URLs and error text, and a secret sent in the clear is a secret the first hop reads. - The certificate must validate against the public trust chain. There is no permissive mode and no pinning option. A self-signed or incomplete-chain certificate produces "The SSL connection could not be established", which counts as a retryable failure and therefore eventually auto-disables the webhook.
- Redirects are followed for up to 3 hops, and every hop is re-checked for https. A
Locationpointing at an http:// URL is refused rather than followed: the attempt is recorded as failed witherror: "insecure_redirect"and is not retried, because a retry would only re-attempt the same disclosure. A hop we do follow gets the request in full - the samePOST, the same body, the same signature and the sameHT-Deliveryid, plus any custom headers you configured - so a2xxafter a redirect means the payload really arrived at the final URL. Only the scheme is constrained: a redirect to a different https host is followed, and that host receives your signed body and your custom headers - so redirect only to endpoints you control. Running out of hops records the last redirect response itself, which counts as a failed delivery.
10/8, 172.16/12,
192.168/16), carrier-NAT (100.64/10), link-local
(169.254/16, fe80::/10), IPv6 unique-local
(fc00::/7), ::1,
0.0.0.0/8 and cloud instance-metadata hostnames are all refused with
422 invalid_url + reason: "destination_not_allowed".
Exactly one environment relaxes it, and it is not one you can reach: a server running under a
Development host allows a private destination by default, so a consumer can be
built against a server on the developer's own machine. Production is always strict and it is
not an account setting - so treat the refusal as unconditional when writing a client. A url your local run
accepted may be refused by https://api2.host-tracker.com.
POST /webhook/{id}/test to drive it: it sends a synthetic
monitor.down through the real path and answers with the outcome, the latency and
the exact signatureSent - which is a ready-made vector for unit-testing your
verifier.
A webhook is its own resource
A webhook used to be an http contact wearing a webhook-shaped view -
one row, two doors. Since 2026-08-17 it is its own resource with its own storage, and the two doors are two
different things. If you integrated before that, this is the part that changed under you:
POST /contact {"type":"http"}creates a CONTACT, not a webhook. It receives alert deliveries in the legacy unsigned shape; it has no signing secret, noevents[]and noscope, andGET /webhook/{id}answers404for it.POST /webhookis the only door that registers a webhook.- Deleting a contact does not delete a webhook, and vice versa.
GET /contactdoes not list your webhooks. - A webhook's
nameandheadersare set and read through the webhook door. The contact door does not see them. - It no longer consumes a contact slot. The package's contact cap is irrelevant to it; the
only ceiling is 20 webhooks per account, answered as
403 package_limitwithfeature: "webhooks". httpsis still required byPOST /webhookand still not required byPOST /contact- anhttpcontact delivers over cleartext, which is one more reason to register integrations through the webhook door.
What its own storage buys you is concrete, not cosmetic: every delivery is recorded as one row carrying all of its attempts, the retry ladder is durable across our deploys, and a delivery that is still retrying says so with the time of its next attempt.
One id, one spelling
The HT-Webhook header on a delivery carries exactly the id the API returns
as WebhookView.id - the dashed GUID - so it pastes straight into
GET /webhook/{id}. (An earlier build sent a wh_-prefixed,
unhyphenated spelling; that is retired.)
HT-Webhook: 0c3c7b07-cecb-43dd-9b76-8516d3b9c771
GET /webhook/0c3c7b07-cecb-43dd-9b76-8516d3b9c771 // the same row
The delete receipt
{
"id": "0c3c7b07-cecb-43dd-9b76-8516d3b9c771",
"deleted": true,
"type": "webhook",
"url": "https://hooks.example.com/host-tracker",
"cascaded": {
"alertSubscriptions": 16, // monitors that stopped reaching this endpoint (0 for an all-scoped webhook)
"reportSubscriptions": 0, // structurally 0 - reports are not deliverable to a webhook
"pendingDeliveries": 0 // retries dropped by the delete
}
}
The cascaded counts are the reason a delete answers 200 with a body instead of a
bodiless 204: they exist nowhere else, and 16 monitors going quiet - or an outstanding retry disappearing - is
not something you want to discover later. type reads
"webhook"; it said "http" back when the row really was
an http contact.
Receiver checklist
- Read the raw body before any JSON parsing, and verify against those bytes.
- Accept any matching signature entry, not just the first - rotation depends on it.
- Reject deliveries whose timestamp is more than 300 seconds old.
- Deduplicate on
HT-Delivery. Retries and redeliveries reuse it, and at-least-once is the guarantee. - Answer
2xxwithin 10 seconds; queue the work. - Answer
410when you decommission the endpoint, so it stops cleanly. - Do not assume ordering.
occurredAtis the event's own time - sort by it.