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": "Acme pricing",
    "url": "https://acme.example/pricing",
    "css_selector": "#pricing",
    "ignore_regex": "\\d+ (minutes|hours) ago",
    "min_change_ratio": 0.005,
    "interval_seconds": 900,
    "notify_email": true,
    "webhook_url": "https://hooks.example/signalwatch"
  }'

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": "Acme pricing",
  "url": "https://acme.example/pricing",
  "status_code": 200,
  "detected_at": "2026-09-18T21:00:00+00:00",
  "event": "content.changed",
  "content_hash": "def2…",
  "excerpt": "Pro $15/mo Business $29/mo …",
  "change_ratio": 0.031,
  "diff": "--- before\n+++ after\n@@ -3 +3 @@\n-Pro $12/mo\n+Pro $15/mo"
}

Events: content.changed, check.down (with error), check.up, test. Deliveries retry three times on network errors, 429 and 5xx.

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("Stripe pricing", "https://stripe.com/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