Skip to main content

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-Delivery header and to webhook-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 a job.* callback it is the job. Null members are omitted, so treat an absent key as null rather than as an error.
Where the per-event payload shapes are written down. Not here - a table copied into a guide is a table that goes stale. The OpenAPI document publishes them under its top-level 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).
a real delivery body
{
  "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

HeaderValue
HT-EventThe event type - the same string as the envelope's event. Lets you route before parsing.
HT-Deliveryd_<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-AttemptAttempt counter, starting at 1. A redelivery continues the count rather than resetting it.
HT-Signaturet=<unix>,v1=<hex> - see below.
webhook-idStandard Webhooks. Same value as HT-Delivery.
webhook-timestampStandard Webhooks. Unix seconds - the same t that is inside HT-Signature.
webhook-signatureStandard 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:

GroupTypesAddressed to
Monitor statemonitor.down monitor.up monitor.repeatedlyDownwebhooks whose scope covers the monitor
Incidentsincident.opened incident.closedsame
Monitor lifecyclemonitor.created monitor.updated monitor.deletedsame
Maintenancemaintenance.endedsame
Expiry warningscertificate.expiring domain.expiringsame
Contactscontact.confirmed contact.updatedevery enabled webhook of the account subscribed to it
Two of these are worth knowing about specifically. 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 with callback: { "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 for callback: { "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.

Sign the raw body, not a re-serialization. Both schemes HMAC the exact bytes we sent. If your framework parses JSON before you get a chance to see it, capture the raw body first - re-serializing changes whitespace and key order, and the signature will never match.

HT-Signature

StepValue
Header formatt=<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.
KeyThe 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.)
AlgorithmHMAC-SHA256, digest rendered as lowercase hex.
FreshnessReject a delivery whose t is more than 300 seconds from your clock.
Python - HT-Signature
# 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)
Node - HT-Signature
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.

Python - with the standardwebhooks library
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:

StepValue
Signed string<webhook-id>.<webhook-timestamp>.<raw body> - the two header values and the body, period-separated.
KeyStrip the whsec_ prefix, then base64-decode the rest. The key is those raw bytes, not the string.
AlgorithmHMAC-SHA256, digest rendered as base64 (not hex).
Header formatv1,<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.
Node - Standard Webhooks by hand
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)));
}
The two keys really are different. 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 responseWhat we do
2xxDelivered. Done.
408, 429, any 5xx, TLS or network failureRetried on the ladder below. A Retry-After on your 429 is honoured when it is longer than the scheduled delay.
Any other 4xxPermanent. Not retried - a 400 now will be a 400 in two hours.
410 GoneDisables 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.

Attempt123456
Sent afterimmediately10 s60 s5 min30 min2 h

HT-Attempt tells you which one you are looking at, and HT-Delivery stays the same throughout.

Which events are retried. The ladder covers deliveries the monitoring engine raises - 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

request
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-Signature carries two v1= entries and webhook-signature two v1, 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.previousValidUntil tells you exactly when the old one stops signing.
A verifier that only checks the first signature will break on rotation. Iterate over all of them.

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.

The log is durable, and it is the webhook's own storage. A webhook is a resource with its own tables (see below), so a delivery that is still retrying really does say so, with the time of its next attempt, and a deploy on our side does not drop it. It is still history, though, with a retention window rather than a guarantee: reconcile against the API's current state, and use the log for diagnosis and replay.

To send a past delivery again, unchanged:

request
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 occurredAt still names the original event time. The signature and webhook-timestamp are recomputed for now - otherwise your freshness check would reject it.
  • HT-Delivery is the same as the first time, so a receiver that deduplicates correctly will see it as a repeat. That is deliberate.
  • HT-Attempt continues counting rather than restarting at 1.
  • It refuses with 422 and a reason when 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 Location pointing at an http:// URL is refused rather than followed: the attempt is recorded as failed with error: "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 same POST, the same body, the same signature and the same HT-Delivery id, plus any custom headers you configured - so a 2xx after 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.
The destination must be reachable from the public internet, and that is re-checked on the resolved address at delivery time and at every redirect hop. Loopback, RFC 1918 private (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.
Testing against the real endpoint. Because there is no way to relax certificate validation, point the webhook at a public HTTPS tunnel to your machine (ngrok, Cloudflare Tunnel, or any request-capture service) rather than at localhost. Then use 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, no events[] and no scope, and GET /webhook/{id} answers 404 for it. POST /webhook is the only door that registers a webhook.
  • Deleting a contact does not delete a webhook, and vice versa. GET /contact does not list your webhooks.
  • A webhook's name and headers are 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_limit with feature: "webhooks".
  • https is still required by POST /webhook and still not required by POST /contact - an http contact 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.)

the same webhook, one spelling
HT-Webhook: 0c3c7b07-cecb-43dd-9b76-8516d3b9c771
GET /webhook/0c3c7b07-cecb-43dd-9b76-8516d3b9c771   // the same row

The delete receipt

DELETE /webhook/{id}  ·  200 OK
{
  "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 2xx within 10 seconds; queue the work.
  • Answer 410 when you decommission the endpoint, so it stops cleanly.
  • Do not assume ordering. occurredAt is the event's own time - sort by it.