cdpfleet Docs Sign up free

Examples & benchmarks / #88 · 2026-11-09 · Example · Medium · for monitoring, 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

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 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

WhatWhy
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}/runCheck 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-secretA 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/sessionsEach 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, with the raw output). Set these environment variables first:

// 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));

What we got

StepWhat happenedMeasured
Create201: every 1h, text of `.quote`, without .tags, ignoring \(about\); webhook set, listed true2026-11-09
Checks#1 baseline in 10.8 s, #2 changed in 18.7 s2026-11-09
Webhook1 delivery: monitor.change, signature valid, tampered body rejected, delivery id a uuid; seen 19.1 s after the run request2026-11-09
Diff+2 −2 lines, 288 bytes; same as the API: true; snapshot_url = after text: true; tags or "(about)" in it: false2026-11-09
Now reads“He's like a drug for you, Bella.” — by Stephenie Meyer2026-11-09
History1 change (newest = the webhook's: true), 2 checks; next scheduled check 60 min after the last2026-11-09
Cost10.7 s + 18.5 s = 29.2 thread-seconds (function:scrape, weight 1)2026-11-09
Pause, deletePATCH active:false → 200, next_run_at null; DELETE → 200, then GET → 4042026-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