SignalWatch

Examples

Wire a page-change into whatever you already run

Everything the dashboard does is available over REST with an X-API-Key header. Full OpenAPI at /docs. Get a key by creating a free account; it is shown once on the dashboard.

1. Create a monitor with curl

Watch a competitor's pricing region every 15 minutes, ignore “updated N minutes ago” noise, email yourself and POST to a webhook.

curl -sS -X POST https://signalwatch.litework.me/api/v1/checks \
  -H "X-API-Key: $SIGNALWATCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Visualping pricing",
    "url": "https://visualping.io/pricing",
    "css_selector": "#pricing",
    "ignore_regex": "\\d+ (minutes|hours) ago",
    "min_change_ratio": 0.005,
    "ignore_order": true,
    "interval_seconds": 900,
    "notify_email": true,
    "webhook_url": "https://hooks.slack.com/services/T000/B000/XXXX"
  }'

Then fire a synthetic event so you can see the payload land before a real change happens:

curl -sS -X POST https://signalwatch.litework.me/api/v1/checks/123/test \
  -H "X-API-Key: $SIGNALWATCH_KEY"

History with diffs for a monitor:

curl -sS https://signalwatch.litework.me/api/v1/checks/123/events?limit=20 \
  -H "X-API-Key: $SIGNALWATCH_KEY"

2. The webhook payload

Generic HTTPS endpoints receive the raw event. Slack and Discord webhook URLs receive a formatted message with a fenced diff block instead.

POST /your/endpoint
Content-Type: application/json
User-Agent: SignalWatch/1.0
X-SignalWatch-Event: content.changed
X-SignalWatch-Delivery: 5f0c…
X-SignalWatch-Timestamp: 1758230400
X-SignalWatch-Signature: sha256=9b3e…

{
  "check_id": 123,
  "name": "Visualping pricing",
  "url": "https://visualping.io/pricing",
  "status_code": 200,
  "detected_at": "2026-09-18T21:00:00+00:00",
  "event": "content.changed",
  "content_hash": "def2…",
  "excerpt": "Starter $14/mo …",
  "change_ratio": 0.031,
  "diff": "--- before\n+++ after\n@@ -3 +3 @@\n-Starter $10/mo\n+Starter $14/mo"
}

Events: content.changed, check.down (with error), check.up, cert.expiring, and test. Real deliveries retry up to 8 times over about 4 hours on network errors, 429, and 5xx. A test alert tries 3 times immediately.

3. Verify the signature

Your webhook signing secret is on the dashboard (“Webhook signing secret”). The signature is HMAC-SHA256(secret, timestamp + "." + raw_body). Reject anything older than a few minutes to stop replays.

Python (Flask)

import hmac, hashlib, time, os
from flask import Flask, request, abort

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

@app.post("/signalwatch")
def hook():
    ts = request.headers.get("X-SignalWatch-Timestamp", "")
    sig = request.headers.get("X-SignalWatch-Signature", "")
    if abs(time.time() - int(ts or 0)) > 300:
        abort(400)
    expected = "sha256=" + hmac.new(SECRET, f"{ts}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401)
    event = request.get_json()
    if event["event"] == "content.changed":
        print(event["name"], "changed:\n", event["diff"])
    return "", 204

Node (Express)

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

const secret = process.env.SIGNALWATCH_WEBHOOK_SECRET;
const app = express();

app.post("/signalwatch", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.get("X-SignalWatch-Timestamp") ?? "";
  const sig = req.get("X-SignalWatch-Signature") ?? "";
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${ts}.`).update(req.body).digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString("utf8"));
  console.log(event.event, event.name, event.diff);
  res.sendStatus(204);
});

4. n8n / Make / Zapier

  1. Add a Webhook trigger node (POST). Copy its production URL.
  2. Paste that URL as the monitor's webhook_url in SignalWatch. Press Send test alert; the node receives the test event and you can map fields.
  3. Branch on {{$json.body.event}}. For content.changed, {{$json.body.diff}} holds the unified diff — post it to Slack, open a ticket, write a row to a sheet, or feed it to an LLM node for a one-line summary.
  4. Optional: add a Crypto node to verify X-SignalWatch-Signature as above.

The same shape works for Make (Custom webhook) and Zapier (Webhooks by Zapier → Catch Hook).

5. Slack and Discord in 30 seconds

Slack: create an Incoming Webhook for a channel (Slack → Apps → Incoming Webhooks), paste the https://hooks.slack.com/services/… URL into the monitor. Discord: channel settings → Integrations → Webhooks → New, paste the URL. Both get a headline, the URL, the changed percentage and a ```diff block. No paid tier, no app to install.

6. GitHub Actions: fail CI when an upstream page changes

Useful for “our docs say X, vendor changed X.” Runs on a schedule and reads the latest event.

name: upstream-drift
on:
  schedule: [{ cron: "0 */6 * * *" }]
  workflow_dispatch:
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - name: Fail if the vendor page changed in the last 6h
        env:
          KEY: ${{ secrets.SIGNALWATCH_KEY }}
          CHECK_ID: 123
        run: |
          latest=$(curl -sS "https://signalwatch.litework.me/api/v1/checks/$CHECK_ID/events?limit=1" -H "X-API-Key: $KEY")
          event=$(echo "$latest" | jq -r '.[0].event // "none"')
          when=$(echo "$latest" | jq -r '.[0].created_at // "1970-01-01T00:00:00Z"')
          age=$(( $(date +%s) - $(date -d "$when" +%s) ))
          if [ "$event" = "changed" ] && [ "$age" -lt 21600 ]; then
            echo "::error::Upstream page changed:"; echo "$latest" | jq -r '.[0].diff'; exit 1
          fi
          echo "no recent change ($event, ${age}s ago)"

7. Python client in ten lines

import httpx

class SignalWatch:
    def __init__(self, key: str, base="https://signalwatch.litework.me"):
        self.c = httpx.Client(base_url=base, headers={"X-API-Key": key}, timeout=30)
    def create(self, name, url, **opts):
        return self.c.post("/api/v1/checks", json={"name": name, "url": url, **opts}).raise_for_status().json()
    def events(self, check_id, limit=20):
        return self.c.get(f"/api/v1/checks/{check_id}/events", params={"limit": limit}).raise_for_status().json()
    def run_now(self, check_id):
        return self.c.post(f"/api/v1/checks/{check_id}/run").raise_for_status().json()

sw = SignalWatch("sw_…")
m = sw.create("Visualping pricing", "https://visualping.io/pricing", css_selector="main", notify_email=True)
print(sw.run_now(m["id"])["last_hash"])

Limits worth knowing

Get an API key — free OpenAPI reference