How to Receive Webhooks from changedetection.io (Self-Hosted, Step by Step)

Receive changedetection.io change notifications as webhooks: the Apprise json:// target with a secret header, Docker setup, and RabbitMQ routing, verified against a real page-change cycle.

By the end of this guide you will have a self-hosted endpoint that receives change notifications from changedetection.io, authenticates every delivery with a secret header, and routes them to a log, RabbitMQ, or any other destination. Everything below was verified end to end against a real changedetection.io instance and the real Core Webhook Module Docker image: a real watched page changed, and the notification you see quoted here is the delivery that fired.

How changedetection.io sends webhooks

changedetection.io watches web pages and sends notifications through Apprise, which supports dozens of targets. The one that turns a change into a clean webhook is the json:// (HTTP POST) target: you add an Apprise URL to a watch (or to the global notification settings), and every detected change POSTs a JSON document:

{
  "version": "1.0",
  "title": "ChangeDetection.io Notification - https://example.com/pricing",
  "message": "https://example.com/pricing had a change.\n---\n(changed) price: 49 EUR\n(into) price: 59 EUR\n---",
  "attachments": [],
  "type": "info"
}

message carries the diff summary (customizable with the notification body template), and the request arrives with Content-Type: application/json and User-Agent: changedetection.io.

Authentication: there is no payload signing. What Apprise's HTTP targets do support is custom headers, added inline in the URL with ?+Header-Name=value. So the whole receiver-auth setup fits into one line:

json://your-domain/webhook/cdio_events?+X-Webhook-Token=your-generated-token

Use jsons:// for HTTPS endpoints (that trailing s is the TLS variant), and add :port after the host for nonstandard ports. The receiver compares the token in constant time.

Prerequisites

Step 1: Configure the receiver

Create a working directory with two files.

webhooks.json:

{
    "cdio_events": {
        "data_type": "json",
        "module": "log",
        "header_auth": {
            "header_name": "X-Webhook-Token",
            "api_key": "{$CDIO_WEBHOOK_TOKEN}",
            "case_sensitive": true
        }
    }
}

connections.json:

{}

What each parameter does:

  • cdio_events is the webhook ID; it becomes the URL path (/webhook/cdio_events).
  • module: log prints each authenticated notification to the container log. Zero dependencies for the first run; we swap in RabbitMQ at the end.
  • header_auth requires the X-Webhook-Token header on every request and compares it in constant time.
  • {$CDIO_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 CDIO_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/cdio_events, with auto-generated API docs at http://localhost:8000/docs.

Step 3: Test it locally

Simulate exactly what a change notification looks like. Save the payload from the top of this article as change_event.json, then:

curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:8000/webhook/cdio_events \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Token: your-generated-token" \
  --data-binary @change_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: Configure the notification in changedetection.io

Open a watch's Edit > Notifications tab (or Settings > Notifications to apply globally), and add to Notification URL List:

jsons://your-domain/webhook/cdio_events?+X-Webhook-Token=your-generated-token

Use the Send test notification button to fire a delivery immediately, then save. From now on every detected change on that watch POSTs the JSON payload; docker logs webhook-gateway shows it arriving with user-agent: changedetection.io.

Watches and notification URLs can also be managed via the API (POST /api/v1/watch with notification_urls, authenticated with the x-api-key from Settings), which is how this guide's verification run created its watch.

Troubleshooting

  • 401 {"detail":"Invalid API key in header: X-Webhook-Token"}: the token after ?+X-Webhook-Token= and CDIO_WEBHOOK_TOKEN differ. They must be byte-identical; if your token contains URL-special characters, regenerate it as plain hex.
  • No notification although the page changed: notifications fire on detected changes after a baseline exists; the first check only records the baseline. Also check the watch's error state in the list view.
  • Fetch blocked: resolves to a private/reserved IP address: this is about the watched URL, not the notification. changedetection.io refuses to fetch pages on private networks unless the container runs with ALLOW_IANA_RESTRICTED_ADDRESSES=true (verified live: the same watch started working the moment the instance restarted with this flag).
  • json:// vs jsons://: json:// is plain HTTP, jsons:// is HTTPS. A TLS endpoint behind the nginx or Cloudflare setup above needs jsons://.

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 change notification landed as a message in the cdio_events queue):

webhooks.json:

{
    "cdio_events": {
        "data_type": "json",
        "module": "rabbitmq",
        "connection": "rabbitmq_local",
        "module-config": {
            "queue_name": "cdio_events"
        },
        "header_auth": {
            "header_name": "X-Webhook-Token",
            "api_key": "{$CDIO_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 change 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; a watch list of hundreds of pages rechecking on a schedule is exactly when acknowledging fast and queueing durably pays off. The full authentication reference, including header auth and 11 other methods, is in the docs.

Subscribe to Free Webhook Tool

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe