Receive webhooks
HostRepute sends two kinds of webhook to your own HTTPS endpoints: the events of a webhook alert channel, and the callbacks of blocklist and certificate checks you start through the API. Both are signed the same way, and webhook signature verification is how your endpoint proves a request came from HostRepute and wasn’t changed on the way.
Two kinds of webhook
Section titled “Two kinds of webhook”| Webhook alert channel | Scan callback | |
|---|---|---|
| Sent to | The Endpoint URL of the channel | The callback_url of a blocklist or certificate check started through the API |
| Sent when | Something the channel follows changes, or you send a test | The check ends, completed or failed |
| Event header | X-HostRepute-Event |
X-Webhook-Event |
| Timestamp header | X-HostRepute-Timestamp, such as 2026-10-27T14:05:09+00:00 |
X-Webhook-Timestamp, such as 2026-10-27T14:05:09.123456Z |
| Signature header | X-HostRepute-Signature |
X-Webhook-Signature-V3 |
| Signing secret | The channel’s Signing secret, which you set | Your team’s webhook secret, which the portal doesn’t show yet |
| Other headers | Content-Type: application/json, User-Agent: HostRepute-Webhooks/1.0 |
Content-Type: application/json |
Both send a JSON POST. Both timestamps are ISO 8601 in UTC; only the format differs.
How the signature works
Section titled “How the signature works”The signature header holds sha256= followed by the lowercase hex HMAC-SHA256 of this string, keyed with the signing
secret:
{timestamp header}.{raw request body}To check a request, your endpoint:
- Reads the raw request body, byte for byte, before any JSON parsing. HostRepute escapes non-ASCII characters (
éis sent as\u00e9), and scan callbacks also escape slashes (https:\/\/). Parsing the JSON and encoding it again changes those bytes, and the signature no longer matches. - Checks that the timestamp is within 5 minutes of its own clock. HostRepute doesn’t enforce a window for you; the timestamp is part of the signed string, so an old request can’t be given a new one.
- Computes
sha256=plus the hex HMAC-SHA256 of{timestamp}.{body}with the secret, used as text exactly as it was entered (not hex- or base64-decoded), and compares it with the header in constant time. - Only then parses the JSON and acts on the event.
Receive webhook alert channel events
Section titled “Receive webhook alert channel events”-
Pick a secret of 32 to 255 characters and store it where your endpoint can read it, here in the
HOSTREPUTE_WEBHOOK_SECRETenvironment variable. One way to make a 64-character secret:Terminal window openssl rand -hex 32 -
Add a route that checks the signature and the timestamp before it trusts the body. Each example below answers
204for a valid request and401for anything else, and listens on port 3000:webhook.mjs import { createHmac, timingSafeEqual } from 'node:crypto'import express from 'express'const TOLERANCE_MS = 5 * 60 * 1000export function verifySignature(rawBody, timestamp, signature, secret) {const sentAt = Date.parse(timestamp ?? '')if (!Buffer.isBuffer(rawBody) || Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > TOLERANCE_MS) {return false}const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),)const received = Buffer.from(signature ?? '')return expected.length === received.length && timingSafeEqual(expected, received)}const app = express()// express.raw() keeps the body as the exact bytes HostRepute signed.app.post('/hostrepute/webhooks', express.raw({ type: 'application/json' }), (req, res) => {const valid = verifySignature(req.body,req.get('X-HostRepute-Timestamp'),req.get('X-HostRepute-Signature'),process.env.HOSTREPUTE_WEBHOOK_SECRET,)if (!valid) return res.sendStatus(401)const event = JSON.parse(req.body)console.log('verified', event.event, event.id)res.sendStatus(204)})app.listen(3000)Run it npm install expressnode webhook.mjswebhook.py import hashlibimport hmacimport osfrom datetime import datetime, timezonefrom flask import Flask, abort, requestTOLERANCE_SECONDS = 5 * 60def verify_signature(raw_body: bytes, timestamp: str | None, signature: str | None, secret: str) -> bool:if not timestamp or not signature:return Falsetry:sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))except ValueError:return Falseif sent_at.tzinfo is None or abs((datetime.now(timezone.utc) - sent_at).total_seconds()) > TOLERANCE_SECONDS:return Falsedigest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()return hmac.compare_digest(f"sha256={digest}".encode(), signature.encode())app = Flask(__name__)@app.post("/hostrepute/webhooks")def hostrepute_webhook():raw_body = request.get_data() # the exact bytes HostRepute signedif not verify_signature(raw_body,request.headers.get("X-HostRepute-Timestamp"),request.headers.get("X-HostRepute-Signature"),os.environ["HOSTREPUTE_WEBHOOK_SECRET"],):abort(401)event = request.get_json()print("verified", event["event"], event["id"])return "", 204Run it pip install flaskflask --app webhook run --port 3000webhook.php <?phpfunction verify_hostrepute_signature(string $rawBody, string $timestamp, string $signature, string $secret): bool{if ($timestamp === '' || $signature === '') {return false;}try {$sentAt = new DateTimeImmutable($timestamp);} catch (Exception) {return false;}if (abs(time() - $sentAt->getTimestamp()) > 5 * 60) {return false;}$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);return hash_equals($expected, $signature);}$secret = getenv('HOSTREPUTE_WEBHOOK_SECRET') ?: throw new RuntimeException('HOSTREPUTE_WEBHOOK_SECRET is not set');$rawBody = file_get_contents('php://input'); // the exact bytes HostRepute signedif (! verify_hostrepute_signature($rawBody,$_SERVER['HTTP_X_HOSTREPUTE_TIMESTAMP'] ?? '',$_SERVER['HTTP_X_HOSTREPUTE_SIGNATURE'] ?? '',$secret,)) {http_response_code(401);exit;}$event = json_decode($rawBody, true);error_log("verified {$event['event']} {$event['id']}");http_response_code(204);Run it (PHP's built-in server, for testing) php -S 127.0.0.1:3000 webhook.phpwebhook.go package mainimport ("crypto/hmac""crypto/sha256""encoding/hex""encoding/json""io""log""net/http""os""time")const tolerance = 5 * time.Minutefunc verifySignature(rawBody []byte, timestamp, signature, secret string) bool {sentAt, err := time.Parse(time.RFC3339, timestamp)if err != nil || time.Since(sentAt).Abs() > tolerance {return false}mac := hmac.New(sha256.New, []byte(secret))mac.Write([]byte(timestamp + "."))mac.Write(rawBody)expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))return hmac.Equal([]byte(expected), []byte(signature))}func main() {secret := os.Getenv("HOSTREPUTE_WEBHOOK_SECRET")if secret == "" {log.Fatal("HOSTREPUTE_WEBHOOK_SECRET is not set")}http.HandleFunc("POST /hostrepute/webhooks", func(w http.ResponseWriter, r *http.Request) {rawBody, err := io.ReadAll(r.Body) // the exact bytes HostRepute signedif err != nil || !verifySignature(rawBody, r.Header.Get("X-HostRepute-Timestamp"), r.Header.Get("X-HostRepute-Signature"), secret) {w.WriteHeader(http.StatusUnauthorized)return}var event struct {ID string `json:"id"`Event string `json:"event"`}if err := json.Unmarshal(rawBody, &event); err != nil {w.WriteHeader(http.StatusBadRequest)return}log.Println("verified", event.Event, event.ID)w.WriteHeader(http.StatusNoContent)})log.Fatal(http.ListenAndServe(":3000", nil))}Run it go run webhook.gowebhook.rb require "json"require "openssl"require "sinatra"require "time"TOLERANCE_SECONDS = 5 * 60def verify_signature(raw_body, timestamp, signature, secret)return false if timestamp.nil? || signature.nil?sent_at = Time.iso8601(timestamp)return false if (Time.now - sent_at).abs > TOLERANCE_SECONDSexpected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.".b + raw_body.b)OpenSSL.secure_compare(expected, signature)rescue ArgumentErrorfalseendpost "/hostrepute/webhooks" doraw_body = request.body.read # the exact bytes HostRepute signedvalid = verify_signature(raw_body,request.env["HTTP_X_HOSTREPUTE_TIMESTAMP"],request.env["HTTP_X_HOSTREPUTE_SIGNATURE"],ENV.fetch("HOSTREPUTE_WEBHOOK_SECRET"))halt 401 unless validevent = JSON.parse(raw_body)logger.info "verified #{event["event"]} #{event["id"]}"status 204endRun it gem install sinatra rackup pumaruby webhook.rb -p 3000The examples were tested with Node.js 24 and Express 5, Python 3.14 and Flask 3.1, PHP 8.4, Go 1.25, and Ruby 4.0 with Sinatra 4.2.
-
Deploy the endpoint at a public
httpsaddress that follows the endpoint rules. -
In HostRepute, open Alerts & sharing, then Alert channels, choose Add a channel, pick Webhook, and enter the Endpoint URL and the Signing secret. Set up alert channels covers the rest of the form. With the API, add the channel with
POST /api/v1/alerts(settings.webhook_urlandsettings.webhook_secret). -
On the channel’s page, choose Send a test alert. Your endpoint receives an
alert.testevent.
Every alert channel event has the same envelope: id, event, occurred_at, team_id, alert (the channel’s uuid,
type and frequency), title, severity, summary and data. The event tables further down this page, like the
webhook events reference, list what each event puts in data.
Answer quickly and handle repeats
Section titled “Answer quickly and handle repeats”- Answer with a 2xx status within 10 seconds for an alert channel event and 30 seconds for a scan callback. Do slow work after you answer, for example from a queue.
- Expect the same event more than once. When HostRepute doesn’t get a 2xx answer in time, it sends the request
again, even if your endpoint already handled it. An alert channel event is sent again with the same body, so skip an
idyou’ve already handled. For a scan callback, usedata.uuid. - Don’t redirect. HostRepute doesn’t follow redirects, so a
301or302counts as a failed delivery.
Retries and timeouts
Section titled “Retries and timeouts”| Setting | Default | How to change |
|---|---|---|
| Webhook alert channel, time your endpoint has to answer | 10 seconds, 5 of them to connect | Fixed |
| Webhook alert channel, attempts per alert | 3, 250 and 750 milliseconds apart, after a 4xx or 5xx status, a timeout or a connection error | Fixed |
| Scan callback, time your endpoint has to answer | 30 seconds, 10 of them to connect | Fixed |
| Scan callback, attempts per check | 3, the second 30 seconds and the third 60 seconds after the one before, after a 429 or 5xx status, a timeout or a connection error | Fixed |
- A webhook alert channel retries any
4xxor5xxstatus, including your401, so a request that fails your signature check arrives three times. After the third attempt HostRepute gives up on that alert for that channel. - A scan callback retries only a
429or5xxstatus, a timeout or a connection error. Any other status, a4xxor a redirect, ends delivery at once, and the check’scallback_statusbecomesfailed. Each attempt carries a new timestamp and signature.
Scan callbacks
Section titled “Scan callbacks”When you pass a callback_url to POST /api/v1/blacklist/check or
POST /api/v1/certificates/check, HostRepute sends one callback when the
check ends. The event is blacklist.scan.completed or certificate.scan.completed whether the check completed or
failed; data.status tells you which. The body is
{"event": …, "data": {"uuid", "host", "status", "result", "started_at", "completed_at"}}, and a certificate check adds
port and protocol to data.
To act on a callback safely without its signature:
-
Keep the
uuidof every check you start, and ignore callbacks for any other UUID. -
Answer the callback with
204straight away. -
Read the result from the status endpoint with your API token, and act on that response instead of the callback body:
Read the result the callback announced curl -s "https://app.hostrepute.com/api/v1/blacklist/status/$SCAN_UUID" \-H "Authorization: Bearer $HOSTREPUTE_TOKEN" \-H "Accept: application/json"For a certificate check, use
/api/v1/certificates/status/$SCAN_UUID.
A blocklist check that times out, or that HostRepute can’t queue, ends failed without a callback. Poll a check whose
callback hasn’t arrived: Run checks and wait for results shows how.
Endpoint rules
Section titled “Endpoint rules”HostRepute applies the same rules to a webhook channel’s URL and to a callback_url: when you save the channel or
start the check, and again before every request.
- The URL must use
https. Any port works; without one, HostRepute uses 443. - The host can’t be
localhost, a name ending in.localhost,.localor.internal, or an IP address written in an unusual form such as0x7f.0.0.1. - Every IPv4 and IPv6 address the host name resolves to must be public. Private, loopback, link-local, shared
(
100.64.0.0/10), multicast, reserved and documentation ranges are refused, and so is a name with no address at all. - HostRepute connects to the same address it checked, so a DNS change between the check and the request can’t point it at another server.
- Redirects aren’t followed.
To test an endpoint that runs on your own computer, expose it through a public HTTPS tunnel, or send it a signed request yourself as shown below.
Alert channel events
Section titled “Alert channel events”| Event | Description |
|---|---|
alert.test | Sent when you send a test alert to a webhook channel, from the portal or the API. data.test is always true.Sample payload |
alert.blacklist_status_changed | Sent when blocklist checks find domains or IPs that were newly listed or removed from a blocklist (with the Only when listed frequency, only new listings). Changes are batched into one request; severity is critical when any host is listed, and host names are defanged (. becomes [.]).Sample payload |
alert.reputation_issue | Sent for Microsoft SNDS alerts (IP status, filter result changes, complaint rate, spam trap hits) and Gmail Postmaster Tools alerts (spam rate, delivery errors, compliance issues). data.lines holds the alert text and data.url links to the related page in the portal.Sample payload |
alert.certificate_status_changed | Sent when a certificate check changes a monitor's status and finds an event that its certificate profile has turned on, such as expiring, expired or hostname mismatch. The same status and events are sent at most once per monitor per day.Sample payload |
alert.certificate_agent_offline | Sent when an active certificate agent has not contacted HostRepute for 15 minutes and is marked offline. Sent at most once per agent per day.Sample payload |
alert.certificate_agent_recovered | Sent when a certificate agent that was marked offline contacts HostRepute again. Sent at most once per agent per day.Sample payload |
alert.domain_expiry_status_changed | Sent when a domain expiry check changes a monitor's status and the domain is expiring soon, has expired or was renewed. The same status and events are sent at most once per monitor per day.Sample payload |
alert.delisting_attempt_reminder_due | Sent when a delisting request marked submitted or waiting reaches its follow-up date. Sent once for each follow-up date.Sample payload |
uptime.monitor_down | Sent once when an uptime monitor is confirmed down or degraded and an incident opens. data.status says which; resolved_at and duration_seconds are null.Sample payload |
uptime.monitor_recovered | Sent once when a monitor with an open incident is confirmed up again and the incident closes. data.status keeps the incident's status (down or degraded).Sample payload |
Scan callback events
Section titled “Scan callback events”| Event | Description |
|---|---|
blacklist.scan.completed | Sent to the callback_url of an API blocklist check (POST /api/v1/blacklist/check) when the scan finishes. When the scan fails, status is failed and result holds only an error message.Sample payload |
certificate.scan.completed | Sent to the callback_url of an API certificate check (POST /api/v1/certificates/check) when the scan completes, or fails after its retries. result is the full stored result, including the certificate PEM (shortened in this sample), the result for each resolved IP and the grade.Sample payload |
Check that it worked
Section titled “Check that it worked”-
Send your endpoint a signed request yourself. This signs a small
alert.testbody the way HostRepute does and sends it to the example endpoint. Use the same secret as your endpoint:Send a signed test request SECRET='your-signing-secret'BODY='{"id":"6f1d2c9a-3b7e-4f05-9a8c-2e4b7d1f0c53","event":"alert.test","title":"HostRepute Test Alert","data":{"test":true}}'TS=$(date -u +%Y-%m-%dT%H:%M:%S+00:00)SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"curl -i http://localhost:3000/hostrepute/webhooks \-H 'Content-Type: application/json' \-H 'X-HostRepute-Event: alert.test' \-H "X-HostRepute-Timestamp: $TS" \-H "X-HostRepute-Signature: $SIG" \--data-raw "$BODY"The endpoint answers
HTTP/1.1 204 No Content. Change one character ofBODYafterSIGis computed and it answers401. -
Send a test from HostRepute. Send a test alert on the channel’s page, or
POST /api/v1/alerts/{alert}/test, delivers analert.testevent that your endpoint accepts.
Troubleshooting
Section titled “Troubleshooting”- Every request fails the signature check. The body was parsed and encoded again before the check (in Express, put
express.raw()on the route instead ofexpress.json()), the secret has extra whitespace, or it was decoded from hex. Compare against the raw bytes with the secret exactly as you entered it. - Requests fail the timestamp check. Your server’s clock is off. Sync it with NTP.
- A test arrives three times. Your endpoint answered with a non-2xx status or took longer than 10 seconds, and HostRepute retried.
- Nothing arrives. Check the endpoint rules: an
httpURL, a redirect, or a host name that resolves to a private address is refused. A webhook channel that’s paused gets no alerts, but a test still goes out. - “The settings.webhook_secret field is required for this alert type.” HostRepute needs a signing secret for a new webhook channel. Enter one of 32 to 255 characters.
- A scan callback never arrived. Read the check’s
callback_status.failedmeans your endpoint answered with a non-retried status or kept failing; afailedcheck with nocallback_statustimed out before it could send one.

