NewIntroducing semantic snapshotsPair every capture with structured DOM data →

Blog

Monitor a web page for changes and get a webhook

All posts
Updated guidesmonitoringwebhooks

The first version of page-change monitoring is always a cron job: fetch the page, hash the HTML, compare with yesterday. It fires on every rotating ad, CSRF token and "3 minutes ago" timestamp, so within a week nobody reads the alerts. Then the change that mattered — a competitor's price, a clause in a supplier's terms, a product going out of stock — arrives on a day the alert channel is muted.

Useful monitoring compares what a person would notice, not the bytes. This guide sets that up with domscout monitors: a scheduled render of the page, a comparison of the parts you choose, and a signed webhook when one of them changes.

What a monitor compares

Each run renders the page in Chromium and compares it with the previous run on the checks you enable:

  • checkMarkdown (on by default): the page's readable content, extracted as Markdown. Markup churn that does not change the text does not count.
  • checkLinks (on by default): the set of links on the page.
  • checkTitle (on by default): the document title.
  • checkStatusCode (on by default): the HTTP status, so a page that starts returning 404 is a change.
  • checkDom (off by default): element structure and position, for layout changes the text does not show. Tune it with domIgnoreSelectors, domIgnoreAttributes, domIgnoreTextPatterns and domPositionTolerancePx.

Turn off the checks that are noisy for a given page. A pricing page's footer links change for reasons unrelated to price, so checkLinks: false is often the right call there.

Create a monitor

Monitors are available on Pro and above. Create one with POST /monitors:

bash
curl -sX POST https://api.domscout.io/monitors \
  -H "x-api-key: $DOMSCOUT_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "name": "Competitor pricing",
    "targetUrl": "https://example.com/pricing",
    "intervalMinutes": 60,
    "notifyWebhookUrl": "https://your-app.example.com/webhooks/domscout",
    "checkMarkdown": true,
    "checkLinks": false,
    "checkTitle": true,
    "checkStatusCode": true
  }'

The response contains the monitor and, because a webhook URL was set, a signing secret:

json
{
  "monitor": {
    "id": "MONITOR_ID",
    "name": "Competitor pricing",
    "targetUrl": "https://example.com/pricing",
    "intervalMinutes": 60,
    "status": "active",
    "nextRunAt": "2026-09-16T13:00:00.000Z"
  },
  "notifyWebhookSecret": "STORE_THIS_VALUE"
}

Store the secret now. No later API read returns notifyWebhookSecret. If you lose it, reveal it from the monitor in the dashboard, or call POST /monitors/{monitorId}/rotate-secret for a new one.

intervalMinutes defaults to 1440 (daily) and can be at most 43200 (30 days). The minimum depends on the plan:

  • Pro: up to 10 active monitors, checking at most every 60 minutes.
  • Business: up to 100, at most every 15 minutes.
  • Enterprise: up to 500, at most every 5 minutes.

Monitors are strict about their input in a way the rest of the API is not. A body with a field the API does not recognise is refused with 400 MONITOR_UNKNOWN_FIELD rather than silently ignored. That catches the expensive mistake: {"paused": true} is not a field, and a monitor that ignored it would keep running and billing. To pause, send PATCH /monitors/{monitorId} with {"status": "paused"}.

Test it without waiting an hour

POST /monitors/{monitorId}/run queues one extra check immediately without moving the schedule. Then read the outcome:

bash
curl -s https://api.domscout.io/monitors/MONITOR_ID/runs \
  -H "x-api-key: $DOMSCOUT_API_KEY"

Each run reports status, changeDetected, changedFields and the captureId of the saved capture. The first run has nothing to compare against, so a change is only possible from the second run on.

The webhook you receive

When a run detects a change, the API sends a POST to notifyWebhookUrl:

json
{
  "event": "monitor.changed",
  "deliveryId": "DELIVERY_ID",
  "timestamp": "2026-09-16T13:00:04.512Z",
  "payload": {
    "monitorId": "MONITOR_ID",
    "monitorRunId": "RUN_ID",
    "captureId": "CAPTURE_ID",
    "prevCaptureId": "PREVIOUS_CAPTURE_ID",
    "changedFields": ["markdown", "title"]
  }
}

changedFields names what changed, from title, status_code, markdown, links and dom. The two capture IDs let you fetch both versions with GET /capture/{captureId} and show a before and after.

Three headers come with it:

  • x-domscout-id: the delivery ID.
  • x-domscout-timestamp: an ISO 8601 UTC timestamp such as 2026-09-16T13:00:04.512Z. Not Unix seconds.
  • x-domscout-signature: a hex HMAC-SHA256 of the timestamp, a dot, and the raw request body, keyed with the monitor's secret.

Verify the signature

Always verify before acting on a webhook, and compute the HMAC over the body exactly as it arrived. Parsing the JSON and serialising it again can reorder keys or change whitespace, and the signature will not match.

Node.js (Express)

javascript
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.DOMSCOUT_MONITOR_SECRET;
const MAX_AGE_MS = 5 * 60 * 1000;

app.post("/webhooks/domscout", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("x-domscout-timestamp") ?? "";
  const received = req.get("x-domscout-signature") ?? "";

  const age = Math.abs(Date.now() - Date.parse(timestamp));
  if (!(age <= MAX_AGE_MS)) return res.status(400).send("stale or missing timestamp");

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${timestamp}.${req.body.toString("utf8")}`)
    .digest("hex");
  const valid = received.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!valid) return res.status(401).send("bad signature");

  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(204); // acknowledge first, then do the work
  handleChange(event.payload);
});

Python (Flask)

python
import hashlib
import hmac
import os
from datetime import datetime, timezone

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["DOMSCOUT_MONITOR_SECRET"].encode()

@app.post("/webhooks/domscout")
def domscout_webhook():
    timestamp = request.headers.get("x-domscout-timestamp", "")
    received = request.headers.get("x-domscout-signature", "")
    raw = request.get_data()

    try:
        sent = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    except ValueError:
        abort(400)
    if abs((datetime.now(timezone.utc) - sent).total_seconds()) > 300:
        abort(400)

    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(received, expected):
        abort(401)

    event = request.get_json()
    handle_change(event["payload"])
    return "", 204

The timestamp check is what makes a captured request useless to an attacker later. The signature alone would stay valid forever.

Delivery, retries and duplicates

A delivery that does not get a successful response is retried after 1, 5, 15 and 60 minutes, and every attempt is recorded. That means your endpoint can see the same event more than once, so make the handler idempotent: record payload.monitorRunId and skip a run you have already processed.

When something goes wrong on your side, you do not need to wait for the next change:

  • GET /webhooks/deliveries lists recent deliveries.
  • GET /webhooks/deliveries/{deliveryId} shows each attempt and the status your endpoint returned.
  • POST /webhooks/deliveries/{deliveryId}/replay sends it again, signed, without re-rendering the page.

If your endpoint redirects, end the redirect on the same origin you registered. The signature header is only sent to that origin, so a redirect to another host arrives unsigned, deliberately.

What it costs

Creating, reading, updating, pausing and deleting monitors is free. Each run, scheduled or on demand, is billed as one capture priced from the monitor's captureParams; a plain run is 1 credit. A page checked hourly is about 720 runs a month, so choose the interval from how quickly you need to know, not from how often the page might change. See pricing for plan quotas.

Next steps

  • The monitors overview summarises checks, history and plans.
  • Monitors are also manageable from the dashboard, with run history and diffs for each change.
  • For a one-off comparison instead of a schedule, capture the page twice with the Markdown endpoint and diff the results yourself.
Monitor a web page for changes and get a webhook | domscout