Skip to content

Receive webhooks

How-to guide
For:
Developers
In the app:
Alerts & sharing, then Alert channels

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.

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.

The signature header holds sha256= followed by the lowercase hex HMAC-SHA256 of this string, keyed with the signing secret:

Signed string
{timestamp header}.{raw request body}

To check a request, your endpoint:

  1. 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.
  2. 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.
  3. 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.
  4. Only then parses the JSON and acts on the event.
  1. Pick a secret of 32 to 255 characters and store it where your endpoint can read it, here in the HOSTREPUTE_WEBHOOK_SECRET environment variable. One way to make a 64-character secret:

    Terminal window
    openssl rand -hex 32
  2. Add a route that checks the signature and the timestamp before it trusts the body. Each example below answers 204 for a valid request and 401 for anything else, and listens on port 3000:

    webhook.mjs
    import { createHmac, timingSafeEqual } from 'node:crypto'
    import express from 'express'
    const TOLERANCE_MS = 5 * 60 * 1000
    export 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 express
    node webhook.mjs

    The 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.

  3. Deploy the endpoint at a public https address that follows the endpoint rules.

  4. 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_url and settings.webhook_secret).

  5. On the channel’s page, choose Send a test alert. Your endpoint receives an alert.test event.

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 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 id you’ve already handled. For a scan callback, use data.uuid.
  • Don’t redirect. HostRepute doesn’t follow redirects, so a 301 or 302 counts as a failed delivery.
SettingDefaultHow to change
Webhook alert channel, time your endpoint has to answer10 seconds, 5 of them to connectFixed
Webhook alert channel, attempts per alert3, 250 and 750 milliseconds apart, after a 4xx or 5xx status, a timeout or a connection errorFixed
Scan callback, time your endpoint has to answer30 seconds, 10 of them to connectFixed
Scan callback, attempts per check3, the second 30 seconds and the third 60 seconds after the one before, after a 429 or 5xx status, a timeout or a connection errorFixed
  • A webhook alert channel retries any 4xx or 5xx status, including your 401, 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 429 or 5xx status, a timeout or a connection error. Any other status, a 4xx or a redirect, ends delivery at once, and the check’s callback_status becomes failed. Each attempt carries a new timestamp and signature.

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:

  1. Keep the uuid of every check you start, and ignore callbacks for any other UUID.

  2. Answer the callback with 204 straight away.

  3. 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.

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, .local or .internal, or an IP address written in an unusual form such as 0x7f.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.

EventDescription
alert.testSent when you send a test alert to a webhook channel, from the portal or the API. data.test is always true.
Sample payload
{
"id": "6f1d2c9a-3b7e-4f05-9a8c-2e4b7d1f0c53",
"event": "alert.test",
"occurred_at": "2026-10-27T14:05:09+00:00",
"team_id": 4182,
"alert": {
"uuid": "019a1f3c-5e27-7b84-9c1d-6f0a2e8b4d37",
"type": "webhook",
"frequency": "once_on_listed_delisted"
},
"title": "HostRepute Test Alert",
"severity": "info",
"summary": "HostRepute test alert webhook delivered successfully.",
"data": {
"message": "This is a test notification from HostRepute.",
"test": true
}
}
alert.blacklist_status_changedSent 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
{
"id": "b2e7c4a1-9d3f-4e58-a1c6-7f0d2b8e5a94",
"event": "alert.blacklist_status_changed",
"occurred_at": "2026-10-27T14:05:09+00:00",
"team_id": 4182,
"alert": {
"uuid": "019a1f3c-5e27-7b84-9c1d-6f0a2e8b4d37",
"type": "webhook",
"frequency": "once_on_listed_delisted"
},
"title": "Alert: 1 blocklisted, 1 delisted host(s)",
"severity": "critical",
"summary": "Alert: 1 blocklisted, 1 delisted host(s)",
"data": {
"blocklisted_count": 1,
"delisted_count": 1,
"monitored_at": "2026-10-27T14:00:42+00:00",
"hosts": [
{
"host_id": 90211,
"host_monitoring_id": 5530918,
"host": "203[.]0[.]113[.]25",
"is_blocklisted": true,
"listed_data_source_count": 2,
"data_source_count": 58,
"monitored_at": "2026-10-27T14:00:42+00:00"
},
{
"host_id": 90214,
"host_monitoring_id": 5530921,
"host": "mail[.]example[.]com",
"is_blocklisted": false,
"listed_data_source_count": 0,
"data_source_count": 58,
"monitored_at": "2026-10-27T14:00:47+00:00"
}
]
}
}
alert.reputation_issueSent 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
{
"id": "c81f5a2d-6e4b-4b97-8d03-1a9e7c5f2b60",
"event": "alert.reputation_issue",
"occurred_at": "2026-10-27T14:05:09+00:00",
"team_id": 4182,
"alert": {
"uuid": "019a1f3c-5e27-7b84-9c1d-6f0a2e8b4d37",
"type": "webhook",
"frequency": "once_on_listed_delisted"
},
"title": "SNDS IP status alert: 203.0.113.25 is blocked",
"severity": "critical",
"summary": "SNDS IP status alert: 203.0.113.25 is blocked",
"data": {
"lines": [
"The IP address 203.0.113.25 has been flagged by Microsoft SNDS.",
"Status: blocked",
"Reason: Blocked due to user complaints or other evidence of spamming",
"Recommended actions: check current Outlook/Hotmail traffic, slow sending from this IP, and investigate complaint or malware signals before resuming normal volume."
],
"url": "https://app.hostrepute.com/01JQ7ZK3M8V4XN2R5T9WBHCDEF/snds-integrations/019a0b6e-2f41-7c93-a8d5-0e7b3c9f1a62"
}
}
alert.certificate_status_changedSent 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
{
"id": "e4a9d7b2-1c5f-4a38-b6e0-9d2f8c3a7e15",
"event": "alert.certificate_status_changed",
"occurred_at": "2026-10-27T14:05:09+00:00",
"team_id": 4182,
"alert": {
"uuid": "019a1f3c-5e27-7b84-9c1d-6f0a2e8b4d37",
"type": "webhook",
"frequency": "once_on_listed_delisted"
},
"title": "Certificate Expiring Soon: www.example.com:443",
"severity": "warning",
"summary": "Certificate Expiring Soon: www.example.com:443",
"data": {
"lines": [
"Target: www.example.com:443 (HTTPS)",
"Status: Expiring Soon",
"Expires: 2026-11-10 08:14:33 UTC (14 days remaining)",
"Issuer: Let's Encrypt",
"Events: certificate_expiring"
],
"url": "https://app.hostrepute.com/01JQ7ZK3M8V4XN2R5T9WBHCDEF/certificates/019a12d8-7b3e-7f05-b1c4-9e6a2d8f0c3b"
}
}
alert.certificate_agent_offlineSent 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
{
"id": "7d3b1e9f-4a6c-4d82-9f15-c0e8a2b6d473",
"event": "alert.certificate_agent_offline",
"occurred_at": "2026-10-27T14:05:09+00:00",
"team_id": 4182,
"alert": {
"uuid": "019a1f3c-5e27-7b84-9c1d-6f0a2e8b4d37",
"type": "webhook",
"frequency": "once_on_listed_delisted"
},
"title": "Certificate agent offline: dc1-agent",
"severity": "warning",
"summary": "Certificate agent offline: dc1-agent",
"data": {
"lines": [
"Agent: dc1-agent",
"Status: offline",
"Last seen: 16 minutes ago",
"Monitors routed through this agent will not run scheduled checks until it reconnects."
],
"url": "https://app.hostrepute.com/01JQ7ZK3M8V4XN2R5T9WBHCDEF/certificates"
}
}
alert.certificate_agent_recoveredSent when a certificate agent that was marked offline contacts HostRepute again. Sent at most once per agent per day.
Sample payload
{
"id": "a5c0e8d4-2b7f-4e19-8c6a-3f1d9b7e0a28",
"event": "alert.certificate_agent_recovered",
"occurred_at": "2026-10-27T14:05:09+00:00",
"team_id": 4182,
"alert": {
"uuid": "019a1f3c-5e27-7b84-9c1d-6f0a2e8b4d37",
"type": "webhook",
"frequency": "once_on_listed_delisted"
},
"title": "Certificate agent recovered: dc1-agent",
"severity": "info",
"summary": "Certificate agent recovered: dc1-agent",
"data": {
"lines": [
"Agent: dc1-agent",
"Status: online",
"Scheduled certificate checks routed through this agent have resumed."
],
"url": "https://app.hostrepute.com/01JQ7ZK3M8V4XN2R5T9WBHCDEF/certificates"
}
}
alert.domain_expiry_status_changedSent 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
{
"id": "3e8f6b0c-7d2a-4c51-a9e4-6b1f0d8c2a97",
"event": "alert.domain_expiry_status_changed",
"occurred_at": "2026-10-27T14:05:09+00:00",
"team_id": 4182,
"alert": {
"uuid": "019a1f3c-5e27-7b84-9c1d-6f0a2e8b4d37",
"type": "webhook",
"frequency": "once_on_listed_delisted"
},
"title": "Domain Expiring Soon: example.net",
"severity": "warning",
"summary": "Domain Expiring Soon: example.net",
"data": {
"lines": [
"Domain: example.net",
"Status: Expiring Soon",
"Registrar: Example Registrar, Inc.",
"Expires: 2026-11-20 (24 days remaining)",
"Events: domain_expiring"
],
"url": "https://app.hostrepute.com/01JQ7ZK3M8V4XN2R5T9WBHCDEF/domain-expiry/019a14f2-0c9d-7a61-8e3b-5d2f7a1c9e04"
}
}
alert.delisting_attempt_reminder_dueSent when a delisting request marked submitted or waiting reaches its follow-up date. Sent once for each follow-up date.
Sample payload
{
"id": "9b4d2f7e-5a1c-4e63-b8d0-2c7a9f1e6b35",
"event": "alert.delisting_attempt_reminder_due",
"occurred_at": "2026-10-27T14:05:09+00:00",
"team_id": 4182,
"alert": {
"uuid": "019a1f3c-5e27-7b84-9c1d-6f0a2e8b4d37",
"type": "webhook",
"frequency": "once_on_listed_delisted"
},
"title": "Delisting follow-up due: 203.0.113.25",
"severity": "warning",
"summary": "Delisting follow-up due: 203.0.113.25",
"data": {
"lines": [
"Host: 203.0.113.25",
"Provider: b.barracudacentral.org",
"Status: submitted",
"Follow up by: Tue, Oct 27, 2026 9:00 AM",
"Reminder: reach out to the provider or update the attempt status."
],
"url": "https://app.hostrepute.com/01JQ7ZK3M8V4XN2R5T9WBHCDEF/delisting-attempts?status=submitted&attempt=019a2c47-8e1b-7d56-9a0f-3c6e1b8d4f29"
}
}
uptime.monitor_downSent 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
{
"id": "f0c7a3e9-8b2d-4f16-9a5c-1e6d4b0f8c72",
"event": "uptime.monitor_down",
"occurred_at": "2026-10-27T14:05:09+00:00",
"team_id": 4182,
"alert": {
"uuid": "019a1f3c-5e27-7b84-9c1d-6f0a2e8b4d37",
"type": "webhook",
"frequency": "once_on_listed_delisted"
},
"title": "🔴 Down: Checkout API",
"severity": "critical",
"summary": "Checkout API is Down.",
"data": {
"incident_uuid": "019a2f5b-6d0e-7c48-b3a9-1f7e4c2d8a50",
"monitor": "Checkout API",
"target": "https://shop.example.com/health",
"status": "down",
"last_error": "Expected HTTP 2xx-3xx, received 503.",
"started_at": "2026-10-27T13:58:02+00:00",
"resolved_at": null,
"duration_seconds": null
}
}
uptime.monitor_recoveredSent 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
{
"id": "1a6e9c4f-3d8b-4a27-b0f5-8e2c7d1a9b64",
"event": "uptime.monitor_recovered",
"occurred_at": "2026-10-27T14:21:13+00:00",
"team_id": 4182,
"alert": {
"uuid": "019a1f3c-5e27-7b84-9c1d-6f0a2e8b4d37",
"type": "webhook",
"frequency": "once_on_listed_delisted"
},
"title": "🟢 Recovered: Checkout API is back up",
"severity": "info",
"summary": "Checkout API has recovered and is reachable again.",
"data": {
"incident_uuid": "019a2f5b-6d0e-7c48-b3a9-1f7e4c2d8a50",
"monitor": "Checkout API",
"target": "https://shop.example.com/health",
"status": "down",
"last_error": "Expected HTTP 2xx-3xx, received 503.",
"started_at": "2026-10-27T13:58:02+00:00",
"resolved_at": "2026-10-27T14:20:47+00:00",
"duration_seconds": 1365
}
}
EventDescription
blacklist.scan.completedSent 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
{
"event": "blacklist.scan.completed",
"data": {
"uuid": "019a2f61-3a8c-7e19-8b4d-0c5f9e2a7d16",
"host": "203.0.113.25",
"status": "completed",
"result": {
"host": "203.0.113.25",
"data_sources": [
{
"data_source_id": 12,
"data_source_name": "b.barracudacentral.org",
"data_source_host": "b.barracudacentral.org",
"source_status": "completed",
"is_listed": true,
"details": [
{
"host": "25.113.0.203.b.barracudacentral.org",
"class": "IN",
"ttl": 900,
"type": "A",
"ip": "127.0.0.2"
}
],
"error": null
},
{
"data_source_id": 31,
"data_source_name": "bl.spamcop.net",
"data_source_host": "bl.spamcop.net",
"source_status": "completed",
"is_listed": false,
"details": [],
"error": null
},
{
"data_source_id": 27,
"data_source_name": "bl.mailspike.net",
"data_source_host": "bl.mailspike.net",
"source_status": "failed",
"is_listed": false,
"details": [],
"error": "The DNS lookup for bl.mailspike.net failed or timed out, so the listing status could not be determined."
}
],
"listed_count": 1,
"total_count": 3,
"failed_count": 1,
"skipped_count": 0,
"is_listed": true,
"relisted_detected": false
},
"started_at": "2026-10-27T14:02:11.000000Z",
"completed_at": "2026-10-27T14:02:19.000000Z"
}
}
certificate.scan.completedSent 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
{
"event": "certificate.scan.completed",
"data": {
"uuid": "019a2f64-9c2e-7b07-a6f1-4d8b0e3c5a92",
"host": "www.example.com",
"port": 443,
"protocol": "https",
"status": "completed",
"result": {
"status": "healthy",
"host": "www.example.com",
"port": 443,
"protocol": "https",
"sni": "www.example.com",
"checked_ip": "203.0.113.10",
"issuer": "Let's Encrypt",
"subject": "www.example.com",
"common_name": "www.example.com",
"sans": [
"www.example.com",
"example.com"
],
"serial_number": "05B6E1F3A2C47D98E0F1A3B5C7D9E1F3A5B7",
"fingerprint_sha256": "3F9A6C21D84E7B05A1C3E9F27D6B48A0C5E1F3B79D2A4C68E0B1D3F5A7C9E2B4",
"signature_algorithm": "RSA-SHA256",
"valid_from": "2026-08-29 06:14:22",
"expires_at": "2026-11-27 06:14:21",
"days_remaining": 31,
"chain_valid": true,
"hostname_valid": true,
"caa_valid": true,
"algorithm_secure": true,
"revocation_status": "unchecked",
"events": [],
"diagnostics": {
"caa": {
"valid": true,
"status": "missing",
"records": [],
"authorized_issuers": []
},
"revocation": {
"status": "unchecked",
"ocsp_url": null,
"crl_urls": [],
"reason": "No reachable revocation endpoints were published by the certificate.",
"checked_via": null
},
"public_key": {
"type": 0,
"bits": 2048
},
"tls": {
"protocol": "TLSv1.3",
"cipher_name": "TLS_AES_256_GCM_SHA384",
"cipher_version": "TLSv1.3",
"cipher_bits": 256
},
"pem": "-----BEGIN CERTIFICATE-----\nMIIFBTCCA+2gAwIBAgISBbT2Xn0q9cKx7vJ1y3m4pZ8wDQYJKoZIhvcNAQELBQAw\nMzELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxDDAKBgNVBAMT\n-----END CERTIFICATE-----\n",
"ip_results": [
{
"status": "healthy",
"host": "www.example.com",
"port": 443,
"protocol": "https",
"sni": "www.example.com",
"checked_ip": "203.0.113.10",
"issuer": "Let's Encrypt",
"subject": "www.example.com",
"common_name": "www.example.com",
"sans": [
"www.example.com",
"example.com"
],
"serial_number": "05B6E1F3A2C47D98E0F1A3B5C7D9E1F3A5B7",
"fingerprint_sha256": "3F9A6C21D84E7B05A1C3E9F27D6B48A0C5E1F3B79D2A4C68E0B1D3F5A7C9E2B4",
"signature_algorithm": "RSA-SHA256",
"valid_from": "2026-08-29 06:14:22",
"expires_at": "2026-11-27 06:14:21",
"days_remaining": 31,
"chain_valid": true,
"hostname_valid": true,
"caa_valid": true,
"algorithm_secure": true,
"revocation_status": "unchecked",
"events": [],
"diagnostics": {
"caa": {
"valid": true,
"status": "missing",
"records": [],
"authorized_issuers": []
},
"revocation": {
"status": "unchecked",
"ocsp_url": null,
"crl_urls": [],
"reason": "No reachable revocation endpoints were published by the certificate.",
"checked_via": null
},
"public_key": {
"type": 0,
"bits": 2048
},
"tls": {
"protocol": "TLSv1.3",
"cipher_name": "TLS_AES_256_GCM_SHA384",
"cipher_version": "TLSv1.3",
"cipher_bits": 256
},
"pem": "-----BEGIN CERTIFICATE-----\nMIIFBTCCA+2gAwIBAgISBbT2Xn0q9cKx7vJ1y3m4pZ8wDQYJKoZIhvcNAQELBQAw\nMzELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxDDAKBgNVBAMT\n-----END CERTIFICATE-----\n"
},
"error_message": null,
"checked_at": "2026-10-27 14:07:31"
}
],
"checked_all_resolved_ips": true
},
"error_message": null,
"checked_at": "2026-10-27 14:07:31",
"resolved_ips": [
"203.0.113.10"
],
"certificate_grade": "A",
"certificate_score": 100,
"certificate_grade_version": "ssl-grade-v1",
"certificate_grade_breakdown": {
"summary": "Certificate grade is A (100/100): strong certificate posture.",
"checks": [
{
"key": "trust_chain",
"label": "Trust chain",
"status": "pass",
"message": "The certificate chain validates against the local CA bundle.",
"score_delta": 0
},
{
"key": "hostname",
"label": "Hostname match",
"status": "pass",
"message": "The certificate covers the requested host.",
"score_delta": 0
},
{
"key": "expiry",
"label": "Expiration",
"status": "pass",
"message": "The certificate is valid for 31 more days.",
"score_delta": 0
},
{
"key": "algorithm",
"label": "Algorithm and key",
"status": "pass",
"message": "Signature and public key strength meet modern thresholds.",
"score_delta": 0
},
{
"key": "caa",
"label": "CAA policy",
"status": "info",
"message": "No CAA records were published. This is allowed, but CAA can reduce issuance risk.",
"score_delta": 0
},
{
"key": "ip_consistency",
"label": "Resolved IP consistency",
"status": "pass",
"message": "Resolved IP checks did not show certificate fingerprint drift.",
"score_delta": 0
},
{
"key": "negotiated_tls",
"label": "Negotiated TLS",
"status": "pass",
"message": "The observed handshake used a modern TLS protocol.",
"score_delta": 0
},
{
"key": "cipher_strength",
"label": "Cipher strength",
"status": "pass",
"message": "The observed cipher reports 256 effective bits.",
"score_delta": 0
}
],
"recommendations": []
}
},
"started_at": "2026-10-27T14:07:29.000000Z",
"completed_at": "2026-10-27T14:07:32.000000Z"
}
}
  • Send your endpoint a signed request yourself. This signs a small alert.test body 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 of BODY after SIG is computed and it answers 401.

  • Send a test from HostRepute. Send a test alert on the channel’s page, or POST /api/v1/alerts/{alert}/test, delivers an alert.test event that your endpoint accepts.

  • 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 of express.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 http URL, 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. failed means your endpoint answered with a non-retried status or kept failing; a failed check with no callback_status timed out before it could send one.