How to Receive Webhooks from Healthchecks (Self-Hosted, Step by Step)
Receive Healthchecks up/down alerts as webhooks on your own server: variable-templated bodies, secret header authentication, the private-IP setting, Docker setup, and RabbitMQ routing, verified against real check flips.
By the end of this guide you will have a self-hosted endpoint that receives Healthchecks webhook alerts, authenticates every delivery with a secret header, and routes the up/down events to a log, RabbitMQ, or any other destination. Everything below was verified end to end against a real self-hosted Healthchecks instance and the real Core Webhook Module Docker image: a real check went down and came back up, and both alerts are the deliveries described here.
How Healthchecks webhooks work
Healthchecks monitors cron jobs and scheduled tasks: your job pings a unique URL, and when the pings stop (or a /fail ping arrives), the check flips to down and Healthchecks notifies its integrations. The Webhook integration gives you full control over the outgoing request, separately for down and up events:
- URL, HTTP method, and request body for down alerts
- URL, method, and body for up (recovery) alerts
- Request headers, applied to both
Bodies and URLs support variable substitution: $NAME (check name), $STATUS (down/up), $CODE (check UUID), $NOW, $TAGS, and more. A real down alert from this guide's verification run, with the body template {"event": "check_down", "check": "$NAME", "status": "$STATUS"}:
{"event": "check_down", "check": "nightly-backup", "status": "down"}
The request arrives with User-Agent: healthchecks.io and whatever Content-Type you set in the headers. There is no payload signing; authentication is the headers you configure, and the receiver compares the secret in constant time.
One behavior verified live: on a self-hosted instance, webhook targets on private IP addresses are refused with "Connections to private IP addresses are not allowed" unless the instance runs with INTEGRATIONS_ALLOW_PRIVATE_IPS=True.
Prerequisites
- Docker on any machine (your laptop is fine for the test run)
- A public HTTPS URL for production, unless Healthchecks and your receiver share a network. Two ways to get one:
- HTTPS for webhooks with nginx and Let's Encrypt if you have a public server
- Receiving webhooks through Cloudflare Tunnel if you do not want to open inbound ports
Step 1: Configure the receiver
Create a working directory with two files.
webhooks.json:
{
"healthchecks_events": {
"data_type": "json",
"module": "log",
"header_auth": {
"header_name": "X-Webhook-Token",
"api_key": "{$HC_WEBHOOK_TOKEN}",
"case_sensitive": true
}
}
}
connections.json:
{}
What each parameter does:
healthchecks_eventsis the webhook ID; it becomes the URL path (/webhook/healthchecks_events).module: logprints each authenticated alert to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.header_authrequires theX-Webhook-Tokenheader on every request and compares it in constant time.{$HC_WEBHOOK_TOKEN}pulls the token from an environment variable, so it never lives in a config file.
Generate a strong token (or use our webhook secret generator):
openssl rand -hex 32
Step 2: Run it
docker run -d --name webhook-gateway -p 8000:8000 \
-v "$PWD/webhooks.json:/app/webhooks.json:ro" \
-v "$PWD/connections.json:/app/connections.json:ro" \
-e HC_WEBHOOK_TOKEN="your-generated-token" \
spiderhash/webhook:latest
Confirm it is up:
docker logs webhook-gateway
# ... INFO: Application startup complete.
The receiver now answers on http://localhost:8000/webhook/healthchecks_events, with auto-generated API docs at http://localhost:8000/docs.
Step 3: Test it locally
Simulate exactly what a down alert sends:
cat > down_event.json <<'EOF'
{"event": "check_down", "check": "nightly-backup", "status": "down"}
EOF
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:8000/webhook/healthchecks_events \
-H "Content-Type: application/json" \
-H "X-Webhook-Token: your-generated-token" \
--data-binary @down_event.json
Expected result:
{"message":"200 OK"}
HTTP 200
Now prove the authentication works. A wrong token:
{"detail":"Invalid API key in header: X-Webhook-Token"}
HTTP 401
And a request with no token header at all:
{"detail":"Missing required header: X-Webhook-Token"}
HTTP 401
Those are the exact responses the gateway returns. docker logs webhook-gateway shows the accepted payload.
Step 4: Create the integration in Healthchecks
In Healthchecks: Integrations > Webhook > Add Integration:
- URL for "down" events:
https://your-domain/webhook/healthchecks_events, methodPOST, request body:
{"event": "check_down", "check": "$NAME", "status": "$STATUS"}
- URL for "up" events: same URL, method
POST, request body:
{"event": "check_up", "check": "$NAME", "status": "$STATUS"}
- Request headers (applied to both):
X-Webhook-Token: your-generated-token
Content-Type: application/json
Save, then assign the integration to your checks (each check's page lists integrations with on/off toggles).
Test the whole loop like the verification run did: ping the check once so it is up, then hit its /fail endpoint:
curl https://your-healthchecks-domain/ping/<check-uuid>
curl https://your-healthchecks-domain/ping/<check-uuid>/fail
The down alert lands within seconds; a subsequent successful ping delivers the up alert. Both appear in docker logs webhook-gateway with user-agent: healthchecks.io.
Troubleshooting
401 {"detail":"Invalid API key in header: X-Webhook-Token"}: the header value in the integration andHC_WEBHOOK_TOKENdiffer. They must be byte-identical.- "Connections to private IP addresses are not allowed" in the integration's log: the self-hosted instance refuses private targets by default. Set
INTEGRATIONS_ALLOW_PRIVATE_IPS=Trueon the Healthchecks container (verified: the identical integration delivered the moment the instance restarted with it), or use a public HTTPS URL. - Down fires but up never does: the up URL and body are configured separately; leaving them empty means no recovery notification. Fill both sides.
- The body arrives with literal
$NAME: variable substitution only covers the documented$VARIABLES; check spelling and case.
Going to production
Printing to logs is not a pipeline. Swap the module for a real destination; here is the RabbitMQ variant, verified end to end (a real recovery alert landed as a message in the healthchecks_events queue):
webhooks.json:
{
"healthchecks_events": {
"data_type": "json",
"module": "rabbitmq",
"connection": "rabbitmq_local",
"module-config": {
"queue_name": "healthchecks_events"
},
"header_auth": {
"header_name": "X-Webhook-Token",
"api_key": "{$HC_WEBHOOK_TOKEN}",
"case_sensitive": true
}
}
}
connections.json:
{
"rabbitmq_local": {
"type": "rabbitmq",
"host": "{$RABBITMQ_HOST:localhost}",
"port": "{$RABBITMQ_PORT:5672}",
"user": "{$RABBITMQ_USER:guest}",
"pass": "{$RABBITMQ_PASS:guest}"
}
}
The same pattern works for 17 other destinations: archive incident history to S3, publish to Kafka, or fan out to several at once with webhook chaining. Built-in rate limiting and retry handling cover the operational edges; when a shared outage flips dozens of checks at once, acknowledging fast and queueing durably keeps the alert trail complete. The full authentication reference, including header auth and 11 other methods, is in the docs.