A monitor is a URL, a cadence and a definition of what counts as "the value". Each check reads that value, hashes it, and compares it to last time; only a change fires a webhook. This guide covers picking the check type, choosing a cadence you can afford, and verifying the delivery — the last of which has one detail that silently breaks most first attempts.
Choose the check type first — it decides the price
There are two. check_type "observe" watches a CSS selector's text (or the page map if you give no selector): deterministic, cheap, and incapable of inventing a change — 1 credit per check. check_type "extract" watches LLM-extracted fields or a natural-language description: the only option when the value has no selector, and it bills the 5-credit extract floor on every check. Prefer observe whenever the value is addressable.
Choose a cadence with the arithmetic in front of you
interval_seconds has a 60-second floor, and every check is billed whether or not anything changed. A one-minute observe monitor is 1,440 checks a day; the same cadence on an extract check is 1,440 × the extract floor. Work out the daily number before you type 60.
Create the monitor
callback_url is required. callback_secret is not required and you should set it anyway — without it you cannot tell a real change webhook from anyone else who guesses your endpoint.
curl -X POST https://twin-browser.com/api/v1/monitors \
-H "Authorization: Bearer $TWIN_API_KEY" \
-H "content-type: application/json" \
-d '{
"name": "competitor pricing",
"check_type": "observe",
"url": "https://competitor.example.com/pricing",
"selector": ".price-table",
"interval_seconds": 3600,
"callback_url": "https://your.app/hooks/price",
"callback_secret": "whsec_…"
}'
# → 201 (the created monitor, secrets excluded)What a change delivery looks like
The POST carries x-twin-event: monitor.changed and, when you set a secret, x-twin-signature: sha256=<hex>. The body reports both hashes plus an excerpt of the new value, so your handler can act without having stored the previous state itself.
{
"event": "monitor.changed",
"monitorId": "…",
"name": "competitor pricing",
"checkType": "observe",
"url": "https://competitor.example.com/pricing",
"changedAt": "2026-09-02T09:14:22.518Z",
"previous": { "hash": "9f2c…" },
"current": { "hash": "1ab7…", "value": "…excerpt of the new value…" }
}Verifying the signature — the part that goes wrong
Compute an HMAC-SHA256 over the RAW request body with your secret, hex-encode it, and compare in constant time against the hex after `sha256=`. The mistake is parsing the JSON and re-serializing it before hashing: that changes the bytes — key order, whitespace, number formatting — and the signature will never match. Read the raw body first, verify, then parse.
import { createHmac, timingSafeEqual } from 'node:crypto';
export async function handler(req) {
// RAW bytes first. Do NOT JSON.parse and re-stringify before hashing.
const raw = await req.text();
const header = req.headers.get('x-twin-signature') ?? '';
const expected = 'sha256=' +
createHmac('sha256', process.env.MONITOR_SECRET).update(raw).digest('hex');
const a = Buffer.from(header);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response('bad signature', { status: 401 });
}
const payload = JSON.parse(raw); // safe to parse now
// … act on payload.current.value
return new Response('ok');
}When it goes quiet
A monitor that is erroring looks exactly like a page that never changes — silence. GET /api/v1/monitors/{id}/history returns recent checks with changed / unchanged / error and value excerpts, paginated up to 200. Check it when a monitor has been suspiciously calm. A monitor whose tenant runs out of credits is paused rather than silently dropped, and you keep the definition and the history.