[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #88 · 2026-11-09 · Example · Medium · for [monitoring](https://cdpfleet.com/monitoring), [ai agents](https://cdpfleet.com/ai-agents)

# A monitor end to end: create it, trigger a change, verify the signed webhook, read the diff and history

Watch one element of a page that changes on every load, trigger checks with \`POST …/run\`, receive the \`monitor.change\` webhook and verify its HMAC signature, check its diff against the API's history and snapshots, see what each check cost, then pause and delete the monitor. Timed from run to webhook, in five languages.

[Camoufox](https://cdpfleet.com/docs/browsers/camoufox)

Run on production on 2026-11-09: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

## The problem

Watching a page for changes sounds like a cron job and a `diff`, until you need it to load in a real browser, ignore the parts that change on every visit, keep the history, and tell your code (or your agent) the moment something real moved — with a request your endpoint can trust. A [monitor](https://cdpfleet.com/docs/monitors) does that on cdpfleet's side: it checks the page on a schedule, compares the text with the last snapshot and POSTs a signed `monitor.change` webhook with the diff. This case runs one for real on a page that is different on every load, so it can be made to change on demand, and checks every piece a receiver relies on: the signature, the delivery id, that the webhook's diff is the one the API keeps, and what the checks cost.

## What we used, and why

| What | Why |
| --- | --- |
| `POST /v1/monitors` | `{url, format: "text", selector: ".quote", remove: [".tags"], ignore: ["\\(about\\)"], every: "1h", deliver: {webhook: {url}}}`: compare only the quote and its author — `remove` drops the tags element before the text is taken, `ignore` deletes the "(about)" link label from the text. `every: "1h"` is the real schedule (15 minutes is the shortest on shared plans); the programs don't wait for it. |
| `POST /v1/monitors/{id}/run` | Check now: answers `202 queued` and the check starts at once. The first check stores a baseline, later ones report `unchanged` or `changed`. |
| `GET /v1/monitors/{id}` | Polled every 0.5 s: `checks`, `changes`, `last_status`, `last_error`, `last_run_at`, `next_run_at`. |
| `deliver.webhook` and `GET /v1/me/webhook-secret` | A `monitor.change` POST with headers `x-cdpfleet-event`, `x-cdpfleet-delivery` (a uuid, the same on every retry) and `x-cdpfleet-signature: t=…,v1=…` = HMAC-SHA256(whsec, `"<t>.<raw body>"`). The receiver in the programs is cdpfleet.com's own test inbox, which keeps the raw body and headers an hour and reads them back with GET; use your own public https endpoint. |
| `GET /v1/monitors/{id}/changes` and `/changes/{change_id}` | The history (newest first, with a diff preview), and one change in full: the diff, `added`/`removed` line counts, and links to the text before and after. |
| `PATCH /v1/monitors/{id}` | `{active: false}` pauses it: `next_run_at` becomes null, the history stays. |
| `GET /v1/me/sessions` | Each check is one scrape the Functions service runs as you; it has no `x-call-id` you can see, but it is in the session history as engine `function:scrape`, with its start, end and weight. |
| `DELETE /v1/monitors/{id}` | In a `finally` block, so a failed run doesn't leave a monitor checking every hour. |

## How it works

1.  Read the account's webhook secret and create the monitor with a webhook to a fresh inbox URL; check it is in `GET /v1/monitors`.
2.  Trigger a check and poll the monitor until it has run: the baseline. Trigger more checks (at most 5) until one reports `changed`, polling the webhook inbox at the same time.
3.  Verify the `monitor.change` delivery: the signature over the raw body (and that a body changed by one space fails), the timestamp, the delivery id, the event header.
4.  Fetch the same change from the API and compare: the diff byte for byte, the line counts; download the "after" text both from the API's link and from the webhook's `snapshot_url` and compare them.
5.  Read the history and the monitor's counters, then pause it with `PATCH`.
6.  Find each check's scrape in the session history and add up its thread-seconds.
7.  Delete the monitor and check it is gone (`404`).

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/88-monitor-end-to-end), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)

 

```node
// Node.js 18+, no packages. env: CDPFLEET_API_KEY (a paid plan: monitors aren't on Community)
import crypto from 'node:crypto';

const KEY = process.env.CDPFLEET_API_KEY;
const API = 'https://cdpfleet.com';
const PAGE = 'https://quotes.toscrape.com/random'; // a practice site: a different quote on every load
const MAX_RUNS = 5; // checks to trigger at most while waiting for a change (the same quote can come up twice)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const secs = (ms) => Math.round(ms / 100) / 10;
const since = new Date(Date.now() - 60000).toISOString();

async function api(method, path, body) {
  const res = await fetch(`${API}${path}`, {
    method,
    headers: { 'x-api-key': KEY, ...(body ? { 'content-type': 'application/json' } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  let json = null;
  try { json = JSON.parse(text); } catch { /* not JSON */ }
  return { status: res.status, json, text };
}

// x-cdpfleet-signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Check it against the raw
// bytes received, before parsing them, and refuse old timestamps (replays).
function verify(secret, header, rawBody, now = Date.now()) {
  const { t, v1 } = Object.fromEntries((header || '').split(',').map((kv) => kv.split('=')));
  if (!t || !v1) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(v1, 'hex');
  return given.length === expected.length && crypto.timingSafeEqual(given, expected) && Math.abs(now / 1000 - Number(t)) < 300;
}

// The webhook receiver here is cdpfleet.com's own test inbox: it keeps deliveries an hour, raw body and
// headers, and reads them back with GET. Use your own public https endpoint.
const hook = `${API}/e2e/hook/${crypto.randomBytes(18).toString('base64url')}`;
const inbox = async () => (await (await fetch(hook)).json()).items;
const secret = (await api('GET', '/v1/me/webhook-secret')).json.secret;

// 1. Create the monitor: only the quote element, as text; the tags dropped (remove) and the "(about)" link
// label taken out of the text (ignore) before comparing.
// every: "1h" is its own schedule; the program doesn't wait for it and triggers checks with POST …/run.
const spec = { url: PAGE, format: 'text', selector: '.quote', remove: ['.tags'], ignore: ['\\(about\\)'], every: '1h', deliver: { webhook: { url: hook } } };
const created = await api('POST', '/v1/monitors', spec);
if (created.status !== 201) throw new Error(`create ${created.status} ${created.text}`);
const id = created.json.monitor_id;
const out = { language: 'node', page: PAGE };
try {
  const listed = (await api('GET', '/v1/monitors')).json.monitors.some((m) => m.monitor_id === id);
  out.monitor = {
    create_status: created.status, every: created.json.every, format: created.json.format, selector: created.json.selector,
    remove: created.json.remove, ignore: created.json.ignore, webhook: created.json.deliver?.webhook?.url === hook, active: created.json.active, listed,
    first_check_due_seconds: secs(Date.parse(created.json.next_run_at) - Date.parse(created.json.created_at)),
  };

  // 2. Check now, and again until the page has changed. Poll the monitor and the inbox every 0.5 s.
  const checks = [];
  let change = null;
  for (let n = 1; n <= MAX_RUNS && !change; n++) {
    const tRun = Date.now();
    const run = await api('POST', `/v1/monitors/${id}/run`);
    let m;
    let tStatus = null;
    let item = null;
    let tHook = null;
    for (const end = Date.now() + 120000; Date.now() < end; await sleep(500)) {
      if (!tStatus) {
        m = (await api('GET', `/v1/monitors/${id}`)).json;
        if (m.checks >= n) tStatus = Date.now();
      }
      if (tStatus && m.last_status !== 'changed') break;
      if (!item) {
        item = (await inbox()).find((d) => d.event === 'monitor.change') || null;
        if (item) tHook = Date.now();
      }
      if (tStatus && item) break;
    }
    if (!tStatus) throw new Error(`check ${n}: no result after 120 s`);
    checks.push({
      n, run_status: run.status, status: m.last_status, error: m.last_error,
      seconds_to_status: secs(tStatus - tRun), check_seconds: secs(Date.parse(m.last_run_at) - tRun),
      t_run: tRun, last_run_at: m.last_run_at,
    });
    if (m.last_status === 'changed') change = { n, tRun, item, tHook };
  }

  // 3. The webhook: verify it, then compare it with the change as the API keeps it.
  const deliveries = await inbox();
  const d = change?.item;
  if (d) {
    const body = JSON.parse(d.body);
    const data = body.data || {};
    const t = Number(/t=(\d+)/.exec(d.signature || '')?.[1]);
    const full = (await api('GET', `/v1/monitors/${id}/changes/${data.change_id}`)).json;
    const before = full.before_url ? await (await fetch(full.before_url)).text() : null;
    const after = await (await fetch(full.after_url)).text();
    const snapshot = await (await fetch(data.snapshot_url)).text();
    const lines = data.diff.split('\n');
    out.webhook = {
      deliveries: deliveries.length, event_header: d.event, type: body.type,
      delivery_id_is_uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(d.delivery || ''),
      signature_valid: verify(secret, d.signature, d.body),
      tampered_body_rejected: !verify(secret, d.signature, d.body.replace('"added":', '"added": ')),
      monitor_id_matches: data.monitor_id === id, url: data.url, keys: Object.keys(data).sort(),
      seconds_run_to_seen: secs(change.tHook - change.tRun),
      seconds_run_to_signed: t - Math.floor(change.tRun / 1000),
      signed_within_1s_of_change: Math.abs(t * 1000 - Date.parse(data.at)) < 1000,
    };
    out.change = {
      after_runs: change.n, change_id: data.change_id, added: data.added, removed: data.removed,
      diff_bytes: Buffer.byteLength(data.diff), diff_lines: lines.length,
      removed_lines: lines.filter((l) => /^-(?!--)/.test(l)).map((l) => l.slice(1)),
      added_lines: lines.filter((l) => /^\+(?!\+\+)/.test(l)).map((l) => l.slice(1)),
      diff_has_ignored_text: /Tags:|\(about\)/.test(data.diff),
      api_diff_equal: full.diff === data.diff, api_counts_equal: full.added === data.added && full.removed === data.removed,
      snapshot_equals_after: snapshot === after, before_lines: before?.split('\n').length ?? null, after_lines: after.split('\n').length,
      after_text: after,
    };
  }

  // 4. The history: every change, newest first, with a preview of its diff.
  const history = (await api('GET', `/v1/monitors/${id}/changes`)).json;
  const m = (await api('GET', `/v1/monitors/${id}`)).json;
  out.history = {
    changes: history.changes.length, change_ids: history.changes.map((c) => c.change_id),
    newest_is_webhook_change: history.changes[0]?.change_id === out.change?.change_id,
    monitor_checks: m.checks, monitor_changes: m.changes, last_status: m.last_status,
    next_run_minutes_after_last_check: Math.round((Date.parse(m.next_run_at) - Date.parse(m.last_run_at)) / 60000),
  };

  // 5. Pause it (PATCH active: false): no more scheduled checks, history kept.
  const paused = await api('PATCH', `/v1/monitors/${id}`, { active: false });
  out.pause = { status: paused.status, active: paused.json?.active, next_run_at: paused.json?.next_run_at ?? null };

  // 6. What the checks cost. Each check is a scrape the service runs as you, billed like any call: it has no
  // x-call-id you can see, but it is in the account's session history as engine "function:scrape".
  await sleep(3000);
  const sessions = (await api('GET', `/v1/me/sessions?from=${encodeURIComponent(since)}&limit=200`)).json.sessions;
  out.billing = checks.map((c) => {
    const inside = sessions.filter((s) => s.engine === 'function:scrape' && Date.parse(s.started_at) >= c.t_run - 2000 && Date.parse(s.started_at) <= Date.parse(c.last_run_at))
      .sort((a, b) => Date.parse(a.started_at) - Date.parse(b.started_at));
    const s = inside[0]; // the first scrape to start after the run request
    return {
      check: c.n, sessions_in_window: inside.length, engine: s?.engine ?? null, weight: s?.weight ?? null,
      seconds: s?.ended_at ? secs(Date.parse(s.ended_at) - Date.parse(s.started_at)) : null,
      thread_seconds: s?.ended_at ? secs((Date.parse(s.ended_at) - Date.parse(s.started_at)) * s.weight) : null,
    };
  });
  out.checks = checks.map(({ t_run, last_run_at, ...c }) => c);
} finally {
  // 7. Delete it, whatever happened above.
  const del = await api('DELETE', `/v1/monitors/${id}`);
  const after = await api('GET', `/v1/monitors/${id}`);
  out.deleted = { status: del.status, deleted: del.json?.deleted === true, get_after: after.status };
}

out.measured_at = new Date().toISOString().slice(0, 10);
console.log(JSON.stringify(out, null, 2));
```

```python
# pip install requests
# env: CDPFLEET_API_KEY (a paid plan: monitors aren't on Community)
import hashlib
import hmac
import json
import os
import re
import secrets
import time
from datetime import datetime, timezone

import requests

KEY = os.environ["CDPFLEET_API_KEY"]
API = "https://cdpfleet.com"
PAGE = "https://quotes.toscrape.com/random"  # a practice site: a different quote on every load
MAX_RUNS = 5  # checks to trigger at most while waiting for a change (the same quote can come up twice)
HEADERS = {"x-api-key": KEY}
UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
since = datetime.fromtimestamp(time.time() - 60, timezone.utc).isoformat().replace("+00:00", "Z")

def api(method, path, body=None):
    return requests.request(method, API + path, headers=HEADERS, json=body, timeout=90)

def ms(iso):  # an API timestamp as epoch milliseconds
    return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000

def secs(m):
    return round(m / 1000, 1)

def now_ms():
    return time.time() * 1000

# x-cdpfleet-signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Check it against the raw
# bytes received, before parsing them, and refuse old timestamps (replays).
def verify(secret, header, raw_body):
    parts = dict(kv.split("=", 1) for kv in (header or "").split(",") if "=" in kv)
    if "t" not in parts or "v1" not in parts:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - int(parts["t"])) < 300

# The webhook receiver here is cdpfleet.com's own test inbox: it keeps deliveries an hour, raw body and
# headers, and reads them back with GET. Use your own public https endpoint.
hook = f"{API}/e2e/hook/{secrets.token_urlsafe(18)}"

def inbox():
    return requests.get(hook, timeout=30).json()["items"]

secret = api("GET", "/v1/me/webhook-secret").json()["secret"]

# 1. Create the monitor: only the quote element, as text; the tags dropped (remove) and the "(about)" link
# label taken out of the text (ignore) before comparing.
# every: "1h" is its own schedule; the program doesn't wait for it and triggers checks with POST …/run.
spec = {"url": PAGE, "format": "text", "selector": ".quote", "remove": [".tags"], "ignore": [r"\(about\)"], "every": "1h",
        "deliver": {"webhook": {"url": hook}}}
created = api("POST", "/v1/monitors", spec)
if created.status_code != 201:
    raise RuntimeError(f"create {created.status_code} {created.text}")
c = created.json()
mid = c["monitor_id"]
out = {"language": "python", "page": PAGE}
try:
    listed = any(m["monitor_id"] == mid for m in api("GET", "/v1/monitors").json()["monitors"])
    out["monitor"] = {
        "create_status": created.status_code, "every": c["every"], "format": c["format"], "selector": c["selector"],
        "remove": c["remove"], "ignore": c["ignore"], "webhook": (c.get("deliver") or {}).get("webhook", {}).get("url") == hook,
        "active": c["active"], "listed": listed,
        "first_check_due_seconds": secs(ms(c["next_run_at"]) - ms(c["created_at"])),
    }

    # 2. Check now, and again until the page has changed. Poll the monitor and the inbox every 0.5 s.
    checks = []
    change = None
    for n in range(1, MAX_RUNS + 1):
        t_run = now_ms()
        run = api("POST", f"/v1/monitors/{mid}/run")
        m, t_status, item, t_hook = None, None, None, None
        end = time.time() + 120
        while time.time() < end:
            if not t_status:
                m = api("GET", f"/v1/monitors/{mid}").json()
                if m["checks"] >= n:
                    t_status = now_ms()
            if t_status and m["last_status"] != "changed":
                break
            if not item:
                item = next((d for d in inbox() if d["event"] == "monitor.change"), None)
                if item:
                    t_hook = now_ms()
            if t_status and item:
                break
            time.sleep(0.5)
        if not t_status:
            raise RuntimeError(f"check {n}: no result after 120 s")
        checks.append({
            "n": n, "run_status": run.status_code, "status": m["last_status"], "error": m["last_error"],
            "seconds_to_status": secs(t_status - t_run), "check_seconds": secs(ms(m["last_run_at"]) - t_run),
            "t_run": t_run, "last_run_at": m["last_run_at"],
        })
        if m["last_status"] == "changed":
            change = {"n": n, "t_run": t_run, "item": item, "t_hook": t_hook}
            break

    # 3. The webhook: verify it, then compare it with the change as the API keeps it.
    deliveries = inbox()
    d = change and change["item"]
    if d:
        body = json.loads(d["body"])
        data = body.get("data") or {}
        t = int(re.search(r"t=(\d+)", d["signature"] or "").group(1))
        full = api("GET", f"/v1/monitors/{mid}/changes/{data['change_id']}").json()
        before = requests.get(full["before_url"], timeout=60).content.decode() if full.get("before_url") else None
        after = requests.get(full["after_url"], timeout=60).content.decode()
        snapshot = requests.get(data["snapshot_url"], timeout=60).content.decode()
        lines = data["diff"].split("\n")
        out["webhook"] = {
            "deliveries": len(deliveries), "event_header": d["event"], "type": body.get("type"),
            "delivery_id_is_uuid": bool(UUID.match(d["delivery"] or "")),
            "signature_valid": verify(secret, d["signature"], d["body"]),
            "tampered_body_rejected": not verify(secret, d["signature"], d["body"].replace('"added":', '"added": ', 1)),
            "monitor_id_matches": data.get("monitor_id") == mid, "url": data.get("url"), "keys": sorted(data),
            "seconds_run_to_seen": secs(change["t_hook"] - change["t_run"]),
            "seconds_run_to_signed": t - int(change["t_run"] // 1000),
            "signed_within_1s_of_change": abs(t * 1000 - ms(data["at"])) < 1000,
        }
        out["change"] = {
            "after_runs": change["n"], "change_id": data["change_id"], "added": data["added"], "removed": data["removed"],
            "diff_bytes": len(data["diff"].encode()), "diff_lines": len(lines),
            "removed_lines": [l[1:] for l in lines if re.match(r"^-(?!--)", l)],
            "added_lines": [l[1:] for l in lines if re.match(r"^\+(?!\+\+)", l)],
            "diff_has_ignored_text": bool(re.search(r"Tags:|\(about\)", data["diff"])),
            "api_diff_equal": full["diff"] == data["diff"],
            "api_counts_equal": full["added"] == data["added"] and full["removed"] == data["removed"],
            "snapshot_equals_after": snapshot == after,
            "before_lines": len(before.split("\n")) if before is not None else None, "after_lines": len(after.split("\n")),
            "after_text": after,
        }

    # 4. The history: every change, newest first, with a preview of its diff.
    history = api("GET", f"/v1/monitors/{mid}/changes").json()
    m = api("GET", f"/v1/monitors/{mid}").json()
    out["history"] = {
        "changes": len(history["changes"]), "change_ids": [x["change_id"] for x in history["changes"]],
        "newest_is_webhook_change": bool(history["changes"]) and history["changes"][0]["change_id"] == out.get("change", {}).get("change_id"),
        "monitor_checks": m["checks"], "monitor_changes": m["changes"], "last_status": m["last_status"],
        "next_run_minutes_after_last_check": round((ms(m["next_run_at"]) - ms(m["last_run_at"])) / 60000),
    }

    # 5. Pause it (PATCH active: false): no more scheduled checks, history kept.
    paused = api("PATCH", f"/v1/monitors/{mid}", {"active": False})
    pj = paused.json()
    out["pause"] = {"status": paused.status_code, "active": pj.get("active"), "next_run_at": pj.get("next_run_at")}

    # 6. What the checks cost. Each check is a scrape the service runs as you, billed like any call: it has no
    # x-call-id you can see, but it is in the account's session history as engine "function:scrape".
    time.sleep(3)
    sessions = requests.get(f"{API}/v1/me/sessions", headers=HEADERS, params={"from": since, "limit": 200}, timeout=60).json()["sessions"]
    out["billing"] = []
    for ch in checks:
        inside = sorted((s for s in sessions if s["engine"] == "function:scrape"
                         and ch["t_run"] - 2000 <= ms(s["started_at"]) <= ms(ch["last_run_at"])), key=lambda s: ms(s["started_at"]))
        s = inside[0] if inside else None  # the first scrape to start after the run request
        dur = ms(s["ended_at"]) - ms(s["started_at"]) if s and s.get("ended_at") else None
        out["billing"].append({
            "check": ch["n"], "sessions_in_window": len(inside), "engine": s and s["engine"], "weight": s and s["weight"],
            "seconds": secs(dur) if dur is not None else None,
            "thread_seconds": secs(dur * s["weight"]) if dur is not None else None,
        })
    out["checks"] = [{k: v for k, v in ch.items() if k not in ("t_run", "last_run_at")} for ch in checks]
finally:
    # 7. Delete it, whatever happened above.
    deleted = api("DELETE", f"/v1/monitors/{mid}")
    gone = api("GET", f"/v1/monitors/{mid}")
    out["deleted"] = {"status": deleted.status_code, "deleted": deleted.json().get("deleted") is True, "get_after": gone.status_code}

out["measured_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
print(json.dumps(out, indent=2, ensure_ascii=False))
```

```java
// Java 21. Maven: com.google.code.gson:gson:2.11.0
// env: CDPFLEET_API_KEY (a paid plan: monitors aren't on Community)
import com.google.gson.*;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.*;
import java.util.*;
import java.util.regex.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final String API = "https://cdpfleet.com";
  static final String PAGE = "https://quotes.toscrape.com/random"; // a practice site: a different quote on every load
  static final int MAX_RUNS = 5; // checks to trigger at most while waiting for a change (the same quote can come up twice)
  static final Pattern UUID_RE = Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
  static final HttpClient HTTP = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
  static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create();

  record Res(int status, JsonObject json, String text) {}

  static Res send(String method, String url, JsonObject body, boolean withKey) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(120));
    if (withKey) b.header("x-api-key", KEY);
    if (body != null) b.header("content-type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(body.toString()));
    else b.method(method, HttpRequest.BodyPublishers.noBody());
    HttpResponse<byte[]> r = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofByteArray());
    String text = new String(r.body(), StandardCharsets.UTF_8);
    JsonObject json;
    try { json = JsonParser.parseString(text).getAsJsonObject(); } catch (Exception e) { json = new JsonObject(); }
    return new Res(r.statusCode(), json, text);
  }
  static Res api(String method, String path, JsonObject body) throws Exception { return send(method, API + path, body, true); }
  static String get(String url) throws Exception { return send("GET", url, null, false).text(); }
  static double num(JsonObject o, String k) { return o != null && o.has(k) && !o.get(k).isJsonNull() ? o.get(k).getAsDouble() : 0; }
  static String str(JsonObject o, String k) { return o != null && o.has(k) && !o.get(k).isJsonNull() ? o.get(k).getAsString() : null; }
  static JsonObject obj(JsonObject o, String k) { return o != null && o.has(k) && o.get(k).isJsonObject() ? o.getAsJsonObject(k) : new JsonObject(); }
  static double secs(double ms) { return Math.round(ms / 100) / 10.0; }
  static double ms(String iso) { return iso == null ? 0 : Instant.parse(iso).toEpochMilli(); } // an API timestamp
  static JsonArray strings(List<String> list) { JsonArray a = new JsonArray(); list.forEach(a::add); return a; }

  // x-cdpfleet-signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Check it against the raw
  // bytes received, before parsing them, and refuse old timestamps (replays).
  static boolean verify(String secret, String header, String rawBody) throws Exception {
    Map<String, String> parts = new HashMap<>();
    for (String kv : (header == null ? "" : header).split(",")) { int i = kv.indexOf('='); if (i > 0) parts.put(kv.substring(0, i), kv.substring(i + 1)); }
    if (!parts.containsKey("t") || !parts.containsKey("v1")) return false;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    byte[] expected = mac.doFinal((parts.get("t") + "." + rawBody).getBytes(StandardCharsets.UTF_8));
    byte[] given;
    try { given = HexFormat.of().parseHex(parts.get("v1")); } catch (Exception e) { return false; }
    return MessageDigest.isEqual(expected, given) && Math.abs(Instant.now().getEpochSecond() - Long.parseLong(parts.get("t"))) < 300;
  }

  public static void main(String[] args) throws Exception {
    String since = Instant.now().minusSeconds(60).toString();
    // The webhook receiver here is cdpfleet.com's own test inbox: it keeps deliveries an hour, raw body and
    // headers, and reads them back with GET. Use your own public https endpoint.
    byte[] tok = new byte[18];
    new SecureRandom().nextBytes(tok);
    String hook = API + "/e2e/hook/" + Base64.getUrlEncoder().withoutPadding().encodeToString(tok);
    String secret = str(api("GET", "/v1/me/webhook-secret", null).json(), "secret");

    // 1. Create the monitor: only the quote element, as text; the tags dropped (remove) and the "(about)" link
    // label taken out of the text (ignore) before comparing.
    // every: "1h" is its own schedule; the program doesn't wait for it and triggers checks with POST …/run.
    JsonObject spec = JsonParser.parseString("""
        {"url": "%s", "format": "text", "selector": ".quote", "remove": [".tags"], "ignore": ["\\\\(about\\\\)"], "every": "1h",
         "deliver": {"webhook": {"url": "%s"}}}""".formatted(PAGE, hook)).getAsJsonObject();
    Res created = api("POST", "/v1/monitors", spec);
    if (created.status() != 201) throw new RuntimeException("create " + created.status() + " " + created.text());
    JsonObject c = created.json();
    String id = str(c, "monitor_id");
    JsonObject out = new JsonObject();
    out.addProperty("language", "java");
    out.addProperty("page", PAGE);
    try {
      boolean listed = false;
      for (JsonElement m : api("GET", "/v1/monitors", null).json().getAsJsonArray("monitors")) listed |= id.equals(str(m.getAsJsonObject(), "monitor_id"));
      JsonObject mon = new JsonObject();
      mon.addProperty("create_status", created.status());
      for (String k : List.of("every", "format", "selector")) mon.add(k, c.get(k));
      mon.add("remove", c.get("remove"));
      mon.add("ignore", c.get("ignore"));
      mon.addProperty("webhook", hook.equals(str(obj(obj(c, "deliver"), "webhook"), "url")));
      mon.add("active", c.get("active"));
      mon.addProperty("listed", listed);
      mon.addProperty("first_check_due_seconds", secs(ms(str(c, "next_run_at")) - ms(str(c, "created_at"))));
      out.add("monitor", mon);

      // 2. Check now, and again until the page has changed. Poll the monitor and the inbox every 0.5 s.
      List<JsonObject> checks = new ArrayList<>();
      List<double[]> windows = new ArrayList<>(); // [run request, last_run_at] per check
      int changeN = 0;
      double changeRun = 0, tHook = 0;
      JsonObject item = null;
      for (int n = 1; n <= MAX_RUNS; n++) {
        double tRun = System.currentTimeMillis();
        Res run = api("POST", "/v1/monitors/" + id + "/run", null);
        JsonObject m = null;
        double tStatus = 0;
        for (long end = System.currentTimeMillis() + 120000; System.currentTimeMillis() < end; Thread.sleep(500)) {
          if (tStatus == 0) {
            m = api("GET", "/v1/monitors/" + id, null).json();
            if (num(m, "checks") >= n) tStatus = System.currentTimeMillis();
          }
          if (tStatus != 0 && !"changed".equals(str(m, "last_status"))) break;
          if (item == null) {
            for (JsonElement d : JsonParser.parseString(get(hook)).getAsJsonObject().getAsJsonArray("items")) {
              if ("monitor.change".equals(str(d.getAsJsonObject(), "event"))) { item = d.getAsJsonObject(); tHook = System.currentTimeMillis(); break; }
            }
          }
          if (tStatus != 0 && item != null) break;
        }
        if (tStatus == 0) throw new RuntimeException("check " + n + ": no result after 120 s");
        JsonObject ch = new JsonObject();
        ch.addProperty("n", n);
        ch.addProperty("run_status", run.status());
        ch.add("status", m.get("last_status"));
        ch.add("error", m.get("last_error"));
        ch.addProperty("seconds_to_status", secs(tStatus - tRun));
        ch.addProperty("check_seconds", secs(ms(str(m, "last_run_at")) - tRun));
        checks.add(ch);
        windows.add(new double[] { tRun, ms(str(m, "last_run_at")) });
        if ("changed".equals(str(m, "last_status"))) { changeN = n; changeRun = tRun; break; }
      }

      // 3. The webhook: verify it, then compare it with the change as the API keeps it.
      JsonArray deliveries = JsonParser.parseString(get(hook)).getAsJsonObject().getAsJsonArray("items");
      String changeId = null;
      if (item != null) {
        String raw = str(item, "body");
        JsonObject body = JsonParser.parseString(raw).getAsJsonObject();
        JsonObject data = obj(body, "data");
        changeId = str(data, "change_id");
        Matcher tm = Pattern.compile("t=(\\d+)").matcher(str(item, "signature"));
        long t = tm.find() ? Long.parseLong(tm.group(1)) : 0;
        JsonObject full = api("GET", "/v1/monitors/" + id + "/changes/" + changeId, null).json();
        String before = str(full, "before_url") != null ? get(str(full, "before_url")) : null;
        String after = get(str(full, "after_url"));
        String snapshot = get(str(data, "snapshot_url"));
        String diff = str(data, "diff");
        String[] lines = diff.split("\n", -1);
        List<String> removed = new ArrayList<>(), added = new ArrayList<>();
        for (String l : lines) {
          if (l.startsWith("-") && !l.startsWith("---")) removed.add(l.substring(1));
          if (l.startsWith("+") && !l.startsWith("+++")) added.add(l.substring(1));
        }
        List<String> keys = new ArrayList<>(data.keySet());
        Collections.sort(keys);
        JsonObject wh = new JsonObject();
        wh.addProperty("deliveries", deliveries.size());
        wh.add("event_header", item.get("event"));
        wh.add("type", body.get("type"));
        wh.addProperty("delivery_id_is_uuid", UUID_RE.matcher(Objects.toString(str(item, "delivery"), "")).matches());
        wh.addProperty("signature_valid", verify(secret, str(item, "signature"), raw));
        wh.addProperty("tampered_body_rejected", !verify(secret, str(item, "signature"), raw.replaceFirst("\"added\":", "\"added\": ")));
        wh.addProperty("monitor_id_matches", id.equals(str(data, "monitor_id")));
        wh.add("url", data.get("url"));
        wh.add("keys", strings(keys));
        wh.addProperty("seconds_run_to_seen", secs(tHook - changeRun));
        wh.addProperty("seconds_run_to_signed", t - (long) changeRun / 1000);
        wh.addProperty("signed_within_1s_of_change", Math.abs(t * 1000 - ms(str(data, "at"))) < 1000);
        out.add("webhook", wh);
        JsonObject chg = new JsonObject();
        chg.addProperty("after_runs", changeN);
        chg.addProperty("change_id", changeId);
        chg.add("added", data.get("added"));
        chg.add("removed", data.get("removed"));
        chg.addProperty("diff_bytes", diff.getBytes(StandardCharsets.UTF_8).length);
        chg.addProperty("diff_lines", lines.length);
        chg.add("removed_lines", strings(removed));
        chg.add("added_lines", strings(added));
        chg.addProperty("diff_has_ignored_text", Pattern.compile("Tags:|\\(about\\)").matcher(diff).find());
        chg.addProperty("api_diff_equal", diff.equals(str(full, "diff")));
        chg.addProperty("api_counts_equal", num(full, "added") == num(data, "added") && num(full, "removed") == num(data, "removed"));
        chg.addProperty("snapshot_equals_after", snapshot.equals(after));
        if (before != null) chg.addProperty("before_lines", before.split("\n", -1).length); else chg.add("before_lines", JsonNull.INSTANCE);
        chg.addProperty("after_lines", after.split("\n", -1).length);
        chg.addProperty("after_text", after);
        out.add("change", chg);
      }

      // 4. The history: every change, newest first, with a preview of its diff.
      JsonArray changes = api("GET", "/v1/monitors/" + id + "/changes", null).json().getAsJsonArray("changes");
      List<String> ids = new ArrayList<>();
      for (JsonElement e : changes) ids.add(str(e.getAsJsonObject(), "change_id"));
      JsonObject m = api("GET", "/v1/monitors/" + id, null).json();
      JsonObject hist = new JsonObject();
      hist.addProperty("changes", ids.size());
      hist.add("change_ids", strings(ids));
      hist.addProperty("newest_is_webhook_change", !ids.isEmpty() && ids.get(0).equals(changeId));
      hist.add("monitor_checks", m.get("checks"));
      hist.add("monitor_changes", m.get("changes"));
      hist.add("last_status", m.get("last_status"));
      hist.addProperty("next_run_minutes_after_last_check", Math.round((ms(str(m, "next_run_at")) - ms(str(m, "last_run_at"))) / 60000));
      out.add("history", hist);

      // 5. Pause it (PATCH active: false): no more scheduled checks, history kept.
      JsonObject patch = new JsonObject();
      patch.addProperty("active", false);
      Res paused = api("PATCH", "/v1/monitors/" + id, patch);
      JsonObject pause = new JsonObject();
      pause.addProperty("status", paused.status());
      pause.add("active", paused.json().get("active"));
      pause.add("next_run_at", paused.json().has("next_run_at") ? paused.json().get("next_run_at") : JsonNull.INSTANCE);
      out.add("pause", pause);

      // 6. What the checks cost. Each check is a scrape the service runs as you, billed like any call: it has no
      // x-call-id you can see, but it is in the account's session history as engine "function:scrape".
      Thread.sleep(3000);
      JsonArray sessions = api("GET", "/v1/me/sessions?limit=200&from=" + URLEncoder.encode(since, StandardCharsets.UTF_8), null).json().getAsJsonArray("sessions");
      JsonArray billing = new JsonArray();
      for (int i = 0; i < checks.size(); i++) {
        double[] w = windows.get(i);
        List<JsonObject> inside = new ArrayList<>();
        for (JsonElement e : sessions) {
          JsonObject s = e.getAsJsonObject();
          double st = ms(str(s, "started_at"));
          if ("function:scrape".equals(str(s, "engine")) && st >= w[0] - 2000 && st <= w[1]) inside.add(s);
        }
        inside.sort(Comparator.comparingDouble(s -> ms(str(s, "started_at"))));
        JsonObject b = new JsonObject();
        b.addProperty("check", i + 1);
        b.addProperty("sessions_in_window", inside.size());
        JsonObject s = inside.isEmpty() ? null : inside.get(0); // the first scrape to start after the run request
        b.add("engine", s == null ? JsonNull.INSTANCE : s.get("engine"));
        b.add("weight", s == null ? JsonNull.INSTANCE : s.get("weight"));
        if (s != null && str(s, "ended_at") != null) {
          double dur = ms(str(s, "ended_at")) - ms(str(s, "started_at"));
          b.addProperty("seconds", secs(dur));
          b.addProperty("thread_seconds", secs(dur * num(s, "weight")));
        } else { b.add("seconds", JsonNull.INSTANCE); b.add("thread_seconds", JsonNull.INSTANCE); }
        billing.add(b);
      }
      out.add("billing", billing);
      JsonArray checksOut = new JsonArray();
      checks.forEach(checksOut::add);
      out.add("checks", checksOut);
    } finally {
      // 7. Delete it, whatever happened above.
      Res del = api("DELETE", "/v1/monitors/" + id, null);
      Res gone = api("GET", "/v1/monitors/" + id, null);
      JsonObject d = new JsonObject();
      d.addProperty("status", del.status());
      d.addProperty("deleted", del.json().has("deleted") && del.json().get("deleted").getAsBoolean());
      d.addProperty("get_after", gone.status());
      out.add("deleted", d);
    }
    out.addProperty("measured_at", LocalDate.now(ZoneOffset.UTC).toString());
    System.out.println(GSON.toJson(out));
  }
}
```

```csharp
// .NET 9, no packages. env: CDPFLEET_API_KEY (a paid plan: monitors aren't on Community)
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
const string Api = "https://cdpfleet.com";
const string Page = "https://quotes.toscrape.com/random"; // a practice site: a different quote on every load
const int MaxRuns = 5; // checks to trigger at most while waiting for a change (the same quote can come up twice)
var uuidRe = new Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
var since = DateTime.UtcNow.AddSeconds(-60).ToString("o");

async Task<(int Status, JsonObject Json, string Text)> Send(HttpMethod method, string url, JsonNode? body = null, bool withKey = true)
{
    var req = new HttpRequestMessage(method, url);
    if (withKey) req.Headers.Add("x-api-key", key);
    if (body != null) req.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
    var res = await http.SendAsync(req);
    var text = Encoding.UTF8.GetString(await res.Content.ReadAsByteArrayAsync());
    JsonObject json;
    try { json = JsonNode.Parse(text)!.AsObject(); } catch { json = new JsonObject(); }
    return ((int)res.StatusCode, json, text);
}
async Task<(int Status, JsonObject Json, string Text)> CallApi(HttpMethod method, string path, JsonNode? body = null) => await Send(method, Api + path, body);
async Task<string> Get(string url) => (await Send(HttpMethod.Get, url, withKey: false)).Text;
double Num(JsonNode? n) => n is JsonValue v && v.TryGetValue<double>(out var d) ? d : 0;
string? Str(JsonNode? n) => n is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
double Secs(double ms) => Math.Round(ms / 100, MidpointRounding.AwayFromZero) / 10;
double Ms(JsonNode? iso) => Str(iso) is string s ? DateTimeOffset.Parse(s).ToUnixTimeMilliseconds() : 0; // an API timestamp
double NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
JsonNode? Copy(JsonNode? n) => n?.DeepClone();

// x-cdpfleet-signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Check it against the raw
// bytes received, before parsing them, and refuse old timestamps (replays).
bool Verify(string secret, string? header, string rawBody)
{
    var parts = (header ?? "").Split(',').Select(kv => kv.Split('=', 2)).Where(p => p.Length == 2).ToDictionary(p => p[0], p => p[1]);
    if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1)) return false;
    var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
    byte[] given;
    try { given = Convert.FromHexString(v1); } catch { return false; }
    return CryptographicOperations.FixedTimeEquals(expected, given) && Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) < 300;
}

// The webhook receiver here is cdpfleet.com's own test inbox: it keeps deliveries an hour, raw body and
// headers, and reads them back with GET. Use your own public https endpoint.
var hook = $"{Api}/e2e/hook/{Convert.ToBase64String(RandomNumberGenerator.GetBytes(18)).Replace('+', '-').Replace('/', '_').TrimEnd('=')}";
async Task<JsonArray> Inbox() => JsonNode.Parse(await Get(hook))!["items"]!.AsArray();
var secret = Str((await CallApi(HttpMethod.Get, "/v1/me/webhook-secret")).Json["secret"])!;

// 1. Create the monitor: only the quote element, as text; the tags dropped (remove) and the "(about)" link
// label taken out of the text (ignore) before comparing.
// every: "1h" is its own schedule; the program doesn't wait for it and triggers checks with POST …/run.
var spec = new JsonObject
{
    ["url"] = Page, ["format"] = "text", ["selector"] = ".quote", ["remove"] = new JsonArray(".tags"), ["ignore"] = new JsonArray(@"\(about\)"), ["every"] = "1h",
    ["deliver"] = new JsonObject { ["webhook"] = new JsonObject { ["url"] = hook } },
};
var created = await CallApi(HttpMethod.Post, "/v1/monitors", spec);
if (created.Status != 201) throw new Exception($"create {created.Status} {created.Text}");
var c = created.Json;
var id = Str(c["monitor_id"])!;
var output = new JsonObject { ["language"] = "csharp", ["page"] = Page };
try
{
    var listed = (await CallApi(HttpMethod.Get, "/v1/monitors")).Json["monitors"]!.AsArray().Any(m => Str(m!["monitor_id"]) == id);
    output["monitor"] = new JsonObject
    {
        ["create_status"] = created.Status, ["every"] = Copy(c["every"]), ["format"] = Copy(c["format"]), ["selector"] = Copy(c["selector"]),
        ["remove"] = Copy(c["remove"]), ["ignore"] = Copy(c["ignore"]), ["webhook"] = Str(c["deliver"]?["webhook"]?["url"]) == hook, ["active"] = Copy(c["active"]), ["listed"] = listed,
        ["first_check_due_seconds"] = Secs(Ms(c["next_run_at"]) - Ms(c["created_at"])),
    };

    // 2. Check now, and again until the page has changed. Poll the monitor and the inbox every 0.5 s.
    var checks = new List<JsonObject>();
    var windows = new List<(double Run, double LastRun)>();
    int changeN = 0;
    double changeRun = 0, tHook = 0;
    JsonObject? item = null;
    for (var n = 1; n <= MaxRuns; n++)
    {
        var tRun = NowMs();
        var run = await CallApi(HttpMethod.Post, $"/v1/monitors/{id}/run");
        JsonObject m = new();
        double tStatus = 0;
        for (var end = DateTime.UtcNow.AddSeconds(120); DateTime.UtcNow < end; await Task.Delay(500))
        {
            if (tStatus == 0)
            {
                m = (await CallApi(HttpMethod.Get, $"/v1/monitors/{id}")).Json;
                if (Num(m["checks"]) >= n) tStatus = NowMs();
            }
            if (tStatus != 0 && Str(m["last_status"]) != "changed") break;
            if (item == null)
            {
                item = (await Inbox()).FirstOrDefault(d => Str(d!["event"]) == "monitor.change")?.AsObject();
                if (item != null) tHook = NowMs();
            }
            if (tStatus != 0 && item != null) break;
        }
        if (tStatus == 0) throw new Exception($"check {n}: no result after 120 s");
        checks.Add(new JsonObject
        {
            ["n"] = n, ["run_status"] = run.Status, ["status"] = Copy(m["last_status"]), ["error"] = Copy(m["last_error"]),
            ["seconds_to_status"] = Secs(tStatus - tRun), ["check_seconds"] = Secs(Ms(m["last_run_at"]) - tRun),
        });
        windows.Add((tRun, Ms(m["last_run_at"])));
        if (Str(m["last_status"]) == "changed") { changeN = n; changeRun = tRun; break; }
    }

    // 3. The webhook: verify it, then compare it with the change as the API keeps it.
    var deliveries = await Inbox();
    string? changeId = null;
    if (item != null)
    {
        var raw = Str(item["body"])!;
        var body = JsonNode.Parse(raw)!.AsObject();
        var data = body["data"]!.AsObject();
        changeId = Str(data["change_id"]);
        var t = long.Parse(Regex.Match(Str(item["signature"]) ?? "", @"t=(\d+)").Groups[1].Value);
        var full = (await CallApi(HttpMethod.Get, $"/v1/monitors/{id}/changes/{changeId}")).Json;
        var before = Str(full["before_url"]) is string bu ? await Get(bu) : null;
        var after = await Get(Str(full["after_url"])!);
        var snapshot = await Get(Str(data["snapshot_url"])!);
        var diff = Str(data["diff"])!;
        var lines = diff.Split('\n');
        output["webhook"] = new JsonObject
        {
            ["deliveries"] = deliveries.Count, ["event_header"] = Copy(item["event"]), ["type"] = Copy(body["type"]),
            ["delivery_id_is_uuid"] = uuidRe.IsMatch(Str(item["delivery"]) ?? ""),
            ["signature_valid"] = Verify(secret, Str(item["signature"]), raw),
            ["tampered_body_rejected"] = !Verify(secret, Str(item["signature"]), new Regex("\"added\":").Replace(raw, "\"added\": ", 1)),
            ["monitor_id_matches"] = Str(data["monitor_id"]) == id, ["url"] = Copy(data["url"]),
            ["keys"] = new JsonArray(data.Select(kv => (JsonNode)JsonValue.Create(kv.Key)!).OrderBy(k => (string)k!, StringComparer.Ordinal).ToArray()),
            ["seconds_run_to_seen"] = Secs(tHook - changeRun),
            ["seconds_run_to_signed"] = t - (long)changeRun / 1000,
            ["signed_within_1s_of_change"] = Math.Abs(t * 1000 - Ms(data["at"])) < 1000,
        };
        output["change"] = new JsonObject
        {
            ["after_runs"] = changeN, ["change_id"] = changeId, ["added"] = Copy(data["added"]), ["removed"] = Copy(data["removed"]),
            ["diff_bytes"] = Encoding.UTF8.GetByteCount(diff), ["diff_lines"] = lines.Length,
            ["removed_lines"] = new JsonArray(lines.Where(l => l.StartsWith('-') && !l.StartsWith("---")).Select(l => (JsonNode)JsonValue.Create(l[1..])!).ToArray()),
            ["added_lines"] = new JsonArray(lines.Where(l => l.StartsWith('+') && !l.StartsWith("+++")).Select(l => (JsonNode)JsonValue.Create(l[1..])!).ToArray()),
            ["diff_has_ignored_text"] = Regex.IsMatch(diff, @"Tags:|\(about\)"),
            ["api_diff_equal"] = Str(full["diff"]) == diff,
            ["api_counts_equal"] = Num(full["added"]) == Num(data["added"]) && Num(full["removed"]) == Num(data["removed"]),
            ["snapshot_equals_after"] = snapshot == after,
            ["before_lines"] = before?.Split('\n').Length, ["after_lines"] = after.Split('\n').Length,
            ["after_text"] = after,
        };
    }

    // 4. The history: every change, newest first, with a preview of its diff.
    var ids = (await CallApi(HttpMethod.Get, $"/v1/monitors/{id}/changes")).Json["changes"]!.AsArray().Select(x => Str(x!["change_id"])!).ToList();
    var mon = (await CallApi(HttpMethod.Get, $"/v1/monitors/{id}")).Json;
    output["history"] = new JsonObject
    {
        ["changes"] = ids.Count, ["change_ids"] = new JsonArray(ids.Select(x => (JsonNode)JsonValue.Create(x)!).ToArray()),
        ["newest_is_webhook_change"] = ids.Count > 0 && ids[0] == changeId,
        ["monitor_checks"] = Copy(mon["checks"]), ["monitor_changes"] = Copy(mon["changes"]), ["last_status"] = Copy(mon["last_status"]),
        ["next_run_minutes_after_last_check"] = Math.Round((Ms(mon["next_run_at"]) - Ms(mon["last_run_at"])) / 60000),
    };

    // 5. Pause it (PATCH active: false): no more scheduled checks, history kept.
    var paused = await CallApi(HttpMethod.Patch, $"/v1/monitors/{id}", new JsonObject { ["active"] = false });
    output["pause"] = new JsonObject { ["status"] = paused.Status, ["active"] = Copy(paused.Json["active"]), ["next_run_at"] = Copy(paused.Json["next_run_at"]) };

    // 6. What the checks cost. Each check is a scrape the service runs as you, billed like any call: it has no
    // x-call-id you can see, but it is in the account's session history as engine "function:scrape".
    await Task.Delay(3000);
    var sessions = (await CallApi(HttpMethod.Get, $"/v1/me/sessions?limit=200&from={Uri.EscapeDataString(since)}")).Json["sessions"]!.AsArray().Select(x => x!.AsObject()).ToList();
    var billing = new JsonArray();
    for (var i = 0; i < checks.Count; i++)
    {
        var w = windows[i];
        var inside = sessions.Where(x => Str(x["engine"]) == "function:scrape" && Ms(x["started_at"]) >= w.Run - 2000 && Ms(x["started_at"]) <= w.LastRun)
            .OrderBy(x => Ms(x["started_at"])).ToList();
        var s = inside.FirstOrDefault(); // the first scrape to start after the run request
        double? dur = s != null && s["ended_at"] != null ? Ms(s["ended_at"]) - Ms(s["started_at"]) : null;
        billing.Add(new JsonObject
        {
            ["check"] = i + 1, ["sessions_in_window"] = inside.Count, ["engine"] = Copy(s?["engine"]), ["weight"] = Copy(s?["weight"]),
            ["seconds"] = dur is double d1 ? (JsonNode)Secs(d1) : null, ["thread_seconds"] = dur is double d2 ? (JsonNode)Secs(d2 * Num(s!["weight"])) : null,
        });
    }
    output["billing"] = billing;
    output["checks"] = new JsonArray(checks.Select(x => (JsonNode)x).ToArray());
}
finally
{
    // 7. Delete it, whatever happened above.
    var del = await CallApi(HttpMethod.Delete, $"/v1/monitors/{id}");
    var gone = await CallApi(HttpMethod.Get, $"/v1/monitors/{id}");
    output["deleted"] = new JsonObject { ["status"] = del.Status, ["deleted"] = del.Json["deleted"] is JsonValue dv && dv.GetValue<bool>(), ["get_after"] = gone.Status };
}
output["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd");
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
```

```go
// Go 1.22, standard library only. env: CDPFLEET_API_KEY (a paid plan: monitors aren't on Community)
package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"
)

const api = "https://cdpfleet.com"
const page = "https://quotes.toscrape.com/random" // a practice site: a different quote on every load
const maxRuns = 5                                 // checks to trigger at most while waiting for a change (the same quote can come up twice)

var key = os.Getenv("CDPFLEET_API_KEY")
var client = &http.Client{Timeout: 120 * time.Second}
var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)

type M = map[string]any

func must[T any](v T, err error) T {
	if err != nil {
		panic(err) // not log.Fatal: the deferred DELETE must still run
	}
	return v
}

// call sends a request with the API key (when withKey) and returns the status and body.
func call(method, u string, body any, withKey bool) (int, []byte) {
	var rd io.Reader
	if body != nil {
		rd = bytes.NewReader(must(json.Marshal(body)))
	}
	req := must(http.NewRequest(method, u, rd))
	if withKey {
		req.Header.Set("x-api-key", key)
	}
	if body != nil {
		req.Header.Set("content-type", "application/json")
	}
	res := must(client.Do(req))
	defer res.Body.Close()
	return res.StatusCode, must(io.ReadAll(res.Body))
}

func apiJSON(method, path string, body any) (int, M) {
	status, b := call(method, api+path, body, true)
	var m M
	_ = json.Unmarshal(b, &m)
	return status, m
}

func num(v any) float64       { f, _ := v.(float64); return f }
func str(v any) string        { s, _ := v.(string); return s }
func obj(v any) M             { m, _ := v.(M); return m }
func arr(v any) []any         { a, _ := v.([]any); return a }
func secs(ms float64) float64 { return math.Round(ms/100) / 10 }
func nowMs() float64          { return float64(time.Now().UnixMilli()) }

// ms: an API timestamp as epoch milliseconds.
func ms(iso any) float64 {
	t, err := time.Parse(time.RFC3339Nano, str(iso))
	if err != nil {
		return 0
	}
	return float64(t.UnixMilli())
}

// verify: x-cdpfleet-signature is t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. Check it against the
// raw bytes received, before parsing them, and refuse old timestamps (replays).
func verify(secret, header, rawBody string) bool {
	parts := map[string]string{}
	for _, kv := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(kv, "="); ok {
			parts[k] = v
		}
	}
	t, err := strconv.ParseInt(parts["t"], 10, 64)
	given, err2 := hex.DecodeString(parts["v1"])
	if err != nil || err2 != nil {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(parts["t"] + "." + rawBody))
	return hmac.Equal(given, mac.Sum(nil)) && math.Abs(float64(time.Now().Unix()-t)) < 300
}

func get(u string) string {
	_, b := call("GET", u, nil, false)
	return string(b)
}

func main() {
	since := time.Now().Add(-60 * time.Second).UTC().Format(time.RFC3339)
	// The webhook receiver here is cdpfleet.com's own test inbox: it keeps deliveries an hour, raw body and
	// headers, and reads them back with GET. Use your own public https endpoint.
	tok := make([]byte, 18)
	must(rand.Read(tok))
	hook := api + "/e2e/hook/" + base64.RawURLEncoding.EncodeToString(tok)
	inbox := func() []any {
		var r M
		_ = json.Unmarshal([]byte(get(hook)), &r)
		return arr(r["items"])
	}
	_, sec := apiJSON("GET", "/v1/me/webhook-secret", nil)
	secret := str(sec["secret"])

	// 1. Create the monitor: only the quote element, as text; the tags dropped (remove) and the "(about)" link
	// label taken out of the text (ignore) before comparing.
	// every: "1h" is its own schedule; the program doesn't wait for it and triggers checks with POST …/run.
	spec := M{"url": page, "format": "text", "selector": ".quote", "remove": []string{".tags"}, "ignore": []string{`\(about\)`}, "every": "1h",
		"deliver": M{"webhook": M{"url": hook}}}
	status, c := apiJSON("POST", "/v1/monitors", spec)
	if status != 201 {
		log.Fatalf("create %d %v", status, c)
	}
	id := str(c["monitor_id"])
	out := M{"language": "go", "page": page}
	defer func() {
		// 7. Delete it, whatever happened above.
		delStatus, del := apiJSON("DELETE", "/v1/monitors/"+id, nil)
		after, _ := apiJSON("GET", "/v1/monitors/"+id, nil)
		out["deleted"] = M{"status": delStatus, "deleted": del["deleted"] == true, "get_after": after}
		if r := recover(); r != nil {
			log.Fatal(r)
		}
		out["measured_at"] = time.Now().UTC().Format("2006-01-02")
		enc := json.NewEncoder(os.Stdout)
		enc.SetEscapeHTML(false)
		enc.SetIndent("", "  ")
		_ = enc.Encode(out)
	}()

	_, list := apiJSON("GET", "/v1/monitors", nil)
	listed := false
	for _, m := range arr(list["monitors"]) {
		listed = listed || str(obj(m)["monitor_id"]) == id
	}
	out["monitor"] = M{
		"create_status": status, "every": c["every"], "format": c["format"], "selector": c["selector"], "remove": c["remove"], "ignore": c["ignore"],
		"webhook": str(obj(obj(c["deliver"])["webhook"])["url"]) == hook, "active": c["active"], "listed": listed,
		"first_check_due_seconds": secs(ms(c["next_run_at"]) - ms(c["created_at"])),
	}

	// 2. Check now, and again until the page has changed. Poll the monitor and the inbox every 0.5 s.
	type check struct {
		out     M
		tRun    float64
		lastRun float64
	}
	var checks []check
	var changeN int
	var changeRun, tHook float64
	var item M
	for n := 1; n <= maxRuns; n++ {
		tRun := nowMs()
		runStatus, _ := apiJSON("POST", "/v1/monitors/"+id+"/run", nil)
		var m M
		var tStatus float64
		for end := time.Now().Add(120 * time.Second); time.Now().Before(end); time.Sleep(500 * time.Millisecond) {
			if tStatus == 0 {
				_, m = apiJSON("GET", "/v1/monitors/"+id, nil)
				if num(m["checks"]) >= float64(n) {
					tStatus = nowMs()
				}
			}
			if tStatus != 0 && m["last_status"] != "changed" {
				break
			}
			if item == nil {
				for _, d := range inbox() {
					if str(obj(d)["event"]) == "monitor.change" {
						item, tHook = obj(d), nowMs()
						break
					}
				}
			}
			if tStatus != 0 && item != nil {
				break
			}
		}
		if tStatus == 0 {
			panic(fmt.Sprintf("check %d: no result after 120 s", n))
		}
		checks = append(checks, check{M{
			"n": n, "run_status": runStatus, "status": m["last_status"], "error": m["last_error"],
			"seconds_to_status": secs(tStatus - tRun), "check_seconds": secs(ms(m["last_run_at"]) - tRun),
		}, tRun, ms(m["last_run_at"])})
		if m["last_status"] == "changed" {
			changeN, changeRun = n, tRun
			break
		}
	}

	// 3. The webhook: verify it, then compare it with the change as the API keeps it.
	deliveries := inbox()
	changeID := ""
	if item != nil {
		raw := str(item["body"])
		var body M
		_ = json.Unmarshal([]byte(raw), &body)
		data := obj(body["data"])
		changeID = str(data["change_id"])
		t, _ := strconv.ParseInt(regexp.MustCompile(`t=(\d+)`).FindStringSubmatch(str(item["signature"]))[1], 10, 64)
		_, full := apiJSON("GET", "/v1/monitors/"+id+"/changes/"+changeID, nil)
		var before any
		if u := str(full["before_url"]); u != "" {
			before = len(strings.Split(get(u), "\n"))
		}
		after := get(str(full["after_url"]))
		snapshot := get(str(data["snapshot_url"]))
		diff := str(data["diff"])
		lines := strings.Split(diff, "\n")
		removed, added := []string{}, []string{}
		for _, l := range lines {
			if strings.HasPrefix(l, "-") && !strings.HasPrefix(l, "---") {
				removed = append(removed, l[1:])
			}
			if strings.HasPrefix(l, "+") && !strings.HasPrefix(l, "+++") {
				added = append(added, l[1:])
			}
		}
		keys := []string{}
		for k := range data {
			keys = append(keys, k)
		}
		sort.Strings(keys)
		out["webhook"] = M{
			"deliveries": len(deliveries), "event_header": item["event"], "type": body["type"],
			"delivery_id_is_uuid":    uuidRe.MatchString(str(item["delivery"])),
			"signature_valid":        verify(secret, str(item["signature"]), raw),
			"tampered_body_rejected": !verify(secret, str(item["signature"]), strings.Replace(raw, `"added":`, `"added": `, 1)),
			"monitor_id_matches":     data["monitor_id"] == id, "url": data["url"], "keys": keys,
			"seconds_run_to_seen":        secs(tHook - changeRun),
			"seconds_run_to_signed":      t - int64(changeRun)/1000,
			"signed_within_1s_of_change": math.Abs(float64(t*1000)-ms(data["at"])) < 1000,
		}
		out["change"] = M{
			"after_runs": changeN, "change_id": changeID, "added": data["added"], "removed": data["removed"],
			"diff_bytes": len(diff), "diff_lines": len(lines), "removed_lines": removed, "added_lines": added,
			"diff_has_ignored_text": regexp.MustCompile(`Tags:|\(about\)`).MatchString(diff),
			"api_diff_equal":        str(full["diff"]) == diff,
			"api_counts_equal":      num(full["added"]) == num(data["added"]) && num(full["removed"]) == num(data["removed"]),
			"snapshot_equals_after": snapshot == after, "before_lines": before, "after_lines": len(strings.Split(after, "\n")),
			"after_text": after,
		}
	}

	// 4. The history: every change, newest first, with a preview of its diff.
	_, history := apiJSON("GET", "/v1/monitors/"+id+"/changes", nil)
	ids := []string{}
	for _, ch := range arr(history["changes"]) {
		ids = append(ids, str(obj(ch)["change_id"]))
	}
	_, m := apiJSON("GET", "/v1/monitors/"+id, nil)
	out["history"] = M{
		"changes": len(ids), "change_ids": ids, "newest_is_webhook_change": len(ids) > 0 && ids[0] == changeID,
		"monitor_checks": m["checks"], "monitor_changes": m["changes"], "last_status": m["last_status"],
		"next_run_minutes_after_last_check": math.Round((ms(m["next_run_at"]) - ms(m["last_run_at"])) / 60000),
	}

	// 5. Pause it (PATCH active: false): no more scheduled checks, history kept.
	pStatus, paused := apiJSON("PATCH", "/v1/monitors/"+id, M{"active": false})
	out["pause"] = M{"status": pStatus, "active": paused["active"], "next_run_at": paused["next_run_at"]}

	// 6. What the checks cost. Each check is a scrape the service runs as you, billed like any call: it has no
	// x-call-id you can see, but it is in the account's session history as engine "function:scrape".
	time.Sleep(3 * time.Second)
	_, sess := apiJSON("GET", "/v1/me/sessions?limit=200&from="+url.QueryEscape(since), nil)
	billing := []M{}
	outChecks := []M{}
	for _, ch := range checks {
		var inside []M
		for _, s := range arr(sess["sessions"]) {
			so := obj(s)
			st := ms(so["started_at"])
			if so["engine"] == "function:scrape" && st >= ch.tRun-2000 && st <= ch.lastRun {
				inside = append(inside, so)
			}
		}
		sort.Slice(inside, func(a, b int) bool { return ms(inside[a]["started_at"]) < ms(inside[b]["started_at"]) })
		b := M{"check": ch.out["n"], "sessions_in_window": len(inside), "engine": nil, "weight": nil, "seconds": nil, "thread_seconds": nil}
		if len(inside) > 0 { // the first scrape to start after the run request
			s := inside[0]
			b["engine"], b["weight"] = s["engine"], s["weight"]
			if s["ended_at"] != nil {
				dur := ms(s["ended_at"]) - ms(s["started_at"])
				b["seconds"], b["thread_seconds"] = secs(dur), secs(dur*num(s["weight"]))
			}
		}
		billing = append(billing, b)
		outChecks = append(outChecks, ch.out)
	}
	out["billing"] = billing
	out["checks"] = outChecks
}
```

## What we got

| Step | What happened | Measured |
| --- | --- | --- |
| Create | 201: every 1h, text of \`.quote\`, without .tags, ignoring \\(about\\); webhook set, listed true | 2026-11-09 |
| Checks | #1 baseline in 10.8 s, #2 changed in 18.7 s | 2026-11-09 |
| Webhook | 1 delivery: monitor.change, signature valid, tampered body rejected, delivery id a uuid; seen 19.1 s after the run request | 2026-11-09 |
| Diff | +2 −2 lines, 288 bytes; same as the API: true; snapshot\_url = after text: true; tags or "(about)" in it: false | 2026-11-09 |
| Now reads | “He's like a drug for you, Bella.” — by Stephenie Meyer | 2026-11-09 |
| History | 1 change (newest = the webhook's: true), 2 checks; next scheduled check 60 min after the last | 2026-11-09 |
| Cost | 10.7 s + 18.5 s = 29.2 thread-seconds (function:scrape, weight 1) | 2026-11-09 |
| Pause, delete | PATCH active:false → 200, next\_run\_at null; DELETE → 200, then GET → 404 | 2026-11-09 |

From the Node.js run on 2026-11-09. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)

```
{
  "language": "node",
  "page": "https://quotes.toscrape.com/random",
  "monitor": {
    "create_status": 201,
    "every": "1h",
    "format": "text",
    "selector": ".quote",
    "remove": [
      ".tags"
    ],
    "ignore": [
      "\\(about\\)"
    ],
    "webhook": true,
    "active": true,
    "listed": true,
    "first_check_due_seconds": 0
  },
  "webhook": {
    "deliveries": 1,
    "event_header": "monitor.change",
    "type": "monitor.change",
    "delivery_id_is_uuid": true,
    "signature_valid": true,
    "tampered_body_rejected": true,
    "monitor_id_matches": true,
    "url": "https://quotes.toscrape.com/random",
    "keys": [
      "added",
      "at",
      "change_id",
      "diff",
      "monitor_id",
      "removed",
      "snapshot_url",
      "url"
    ],
    "seconds_run_to_seen": 19.1,
    "seconds_run_to_signed": 19,
    "signed_within_1s_of_change": true
  },
  "change": {
    "after_runs": 2,
    "change_id": "680396e6-4751-4759-9378-a455ace3a147",
    "added": 2,
    "removed": 2,
    "diff_bytes": 288,
    "diff_lines": 7,
    "removed_lines": [
      "“It is impossible to live without failing at something, unless you live so cautiously that you might as well not have lived at all - in which case, you fail by default.”",
      "by J.K. Rowling"
    ],
    "added_lines": [
      "“He's like a drug for you, Bella.”",
      "by Stephenie Meyer"
    ],
    "diff_has_ignored_text": false,
    "api_diff_equal": true,
    "api_counts_equal": true,
    "snapshot_equals_after": true,
    "before_lines": 2,
    "after_lines": 2,
    "after_text": "“He's like a drug for you, Bella.”\nby Stephenie Meyer"
  },
  "history": {
    "changes": 1,
    "change_ids": [
      "680396e6-4751-4759-9378-a455ace3a147"
    ],
    "newest_is_webhook_change": true,
    "monitor_checks": 2,
    "monitor_changes": 1,
    "last_status": "changed",
    "next_run_minutes_after_last_check": 60
  },
  "pause": {
    "status": 200,
    "active": false,
    "next_run_at": null
  },
  "billing": [
    {
      "check": 1,
      "sessions_in_window": 1,
      "engine": "function:scrape",
      "weight": 1,
      "seconds": 10.7,
      "thread_seconds": 10.7
    },
    {
      "check": 2,
      "sessions_in_window": 1,
      "engine": "function:scrape",
      "weight": 1,
      "seconds": 18.5,
      "thread_seconds": 18.5
    }
  ],
  "checks": [
    {
      "n": 1,
      "run_status": 202,
      "status": "baseline",
      "error": null,
      "seconds_to_status": 11,
      "check_seconds": 10.8
    },
    {
      "n": 2,
      "run_status": 202,
      "status": "changed",
      "error": null,
      "seconds_to_status": 19.1,
      "check_seconds": 18.7
    }
  ],
  "deleted": {
    "status": 200,
    "deleted": true,
    "get_after": 404
  },
  "measured_at": "2026-11-09"
}
```

## Takeaways

-   **The whole loop works, in all five languages.** On 2026-11-09 each run created the monitor, got `baseline` from the first `POST …/run` and `changed` from the second, received exactly one webhook — `monitor.change`, none for the baseline — verified it, matched it to the history, paused the monitor and deleted it (`GET` then answered `404`).
-   **The webhook arrives with the result, 7.7–19.1 s after the run request.** A check took 7.0–18.7 s from `POST …/run` to `last_run_at` (one browser load of the page in a pooled tab), and the webhook was in the inbox on the same 0.5 s poll that first showed `changed`. Its signature timestamp was within a second of the change's `at`: main sends it the moment the change is recorded, not on a delivery schedule. So the time to news is the time of one page load.
-   **Verify the raw body, then trust it.** In every run `x-cdpfleet-signature` was HMAC-SHA256 of `"<t>.<raw body>"` with the secret from `GET /v1/me/webhook-secret`, the same body with one added space failed the check, `x-cdpfleet-event` was `monitor.change` and `x-cdpfleet-delivery` a uuid (use it to drop retried duplicates). The body is `{type, created_at, data}`; `data` holds `monitor_id`, `change_id`, `url`, `at`, `added`, `removed`, `diff` and `snapshot_url`.
-   **The webhook carries the whole story; the API has the same thing.** The webhook's `diff` was byte for byte the one `GET /v1/monitors/{id}/changes/{change_id}` returns, with the same line counts, and its `snapshot_url` downloaded exactly the change's "after" text. Each diff was a 7-line unified diff, 194–646 bytes: `+2 −2`, the old quote and author out, the new ones in.
-   **Compare the part that matters.** With `selector: ".quote"`, `remove: [".tags"]` and `ignore: ["\\(about\\)"]` the snapshot was two lines (the quote, "by" the author) and no diff contained the tags or the "(about)" label. Use `remove` for whole elements (it works on the page) and `ignore` for patterns inside the text (dates, counters).
-   **A check costs one short scrape: 6.8–18.5 thread-seconds.** Each check was one `function:scrape` session of weight 1 in `GET /v1/me/sessions`, starting right after the run request; the two checks of a run came to 13.9–29.2 thread-seconds. An hourly monitor is 24 such checks a day, a few thread-minutes. The check has no `x-call-id` of its own, so the programs match it to the session by time.
-   **Running a check by hand also resets the schedule.** A new monitor's first check is due at once (`next_run_at` = `created_at`), and after every check — `POST …/run` included — `next_run_at` was 60 minutes after it. `PATCH {active: false}` set `next_run_at` to null and kept the history.

---
Source: https://cdpfleet.com/docs/cases/monitor-end-to-end · Markdown: https://cdpfleet.com/docs/cases/monitor-end-to-end.md · All docs for AI tools: https://cdpfleet.com/llms.txt
