{
"success": true,
"type": "monitor.page",
"id": "whd_5c1e9a2b7d3f4e6a8b0c2d4e6f8a1b3c",
"created_at": "2026-10-12T06:01:02.481Z",
"monitor_id": "mon_3f9a1c2b7d4e4f6a8b0c1d2e3f4a5b6c",
"monitor_name": "Competitor pricing",
"check_id": "chk_8e2d4f6a1b3c4d5e6f7a8b9c0d1e2f3a",
"data": {
"target_id": "tgt_8d1e0f2a3b4c5d6e",
"url": "https://example.com/pricing",
"status": "changed",
"status_code": 200,
"error": null,
"title": "Pricing — Example",
"previous_check_id": "chk_51b0c2d3e4f5a6b7c8d9e0f1a2b3c4d5",
"is_meaningful": true,
"judgment": {
"meaningful": true,
"confidence": "high",
"reason": "The Starter plan's price went from $19 to $24 a month.",
"meaningful_changes": [
{
"type": "changed",
"before": "$19/mo",
"after": "$24/mo",
"reason": "A plan price changed."
}
]
},
"diff": {
"fields": {
"plans[0].price": {
"previous": "$19/mo",
"current": "$24/mo",
"change": "changed"
}
},
"changed_fields": 1
},
"snapshot": {
"json": {
"plans": [
{
"name": "Starter",
"price": "$24/mo",
"features": [
"3 seats",
"Email support"
]
},
{
"name": "Pro",
"price": "$49/mo",
"features": [
"Unlimited seats",
"SSO"
]
}
]
}
}
},
"metadata": {
"env": "production"
}
}Webhooks
monitor.page webhook
POSTed to your webhook for each page that changed, appeared, was removed, or started failing.
WEBHOOK
monitor.page
{
"success": true,
"type": "monitor.page",
"id": "whd_5c1e9a2b7d3f4e6a8b0c2d4e6f8a1b3c",
"created_at": "2026-10-12T06:01:02.481Z",
"monitor_id": "mon_3f9a1c2b7d4e4f6a8b0c1d2e3f4a5b6c",
"monitor_name": "Competitor pricing",
"check_id": "chk_8e2d4f6a1b3c4d5e6f7a8b9c0d1e2f3a",
"data": {
"target_id": "tgt_8d1e0f2a3b4c5d6e",
"url": "https://example.com/pricing",
"status": "changed",
"status_code": 200,
"error": null,
"title": "Pricing — Example",
"previous_check_id": "chk_51b0c2d3e4f5a6b7c8d9e0f1a2b3c4d5",
"is_meaningful": true,
"judgment": {
"meaningful": true,
"confidence": "high",
"reason": "The Starter plan's price went from $19 to $24 a month.",
"meaningful_changes": [
{
"type": "changed",
"before": "$19/mo",
"after": "$24/mo",
"reason": "A plan price changed."
}
]
},
"diff": {
"fields": {
"plans[0].price": {
"previous": "$19/mo",
"current": "$24/mo",
"change": "changed"
}
},
"changed_fields": 1
},
"snapshot": {
"json": {
"plans": [
{
"name": "Starter",
"price": "$24/mo",
"features": [
"3 seats",
"Email support"
]
},
{
"name": "Pro",
"price": "$49/mo",
"features": [
"Unlimited seats",
"SSO"
]
}
]
}
}
},
"metadata": {
"env": "production"
}
}When it is sent
One event per page that, in a check:- changed since the previous check,
- is new — a page that appeared since the monitor’s first check,
- was removed — it answers 404 or 410, or a complete crawl no longer finds it,
- or started failing. A page that stays down sends one event, not one per check.
data is the same page object GET /monitor/{id}/checks/{checkId} returns — status, diff, judgment and, in json mode, the current extraction.
Responding
Answer with any2xx within 10 seconds. Anything else — a timeout, a 4xx or 5xx, a redirect — is retried after 1, 5 and 15 minutes, then marked failed. Do slow work after you respond.
Retries carry the same id, also sent as X-Prefetch-Delivery. Store the ids you have processed and skip repeats.
Verify the signature
Every request carries anX-Prefetch-Signature header:
X-Prefetch-Signature: t=1760248862,v1=5f2b8c1e...
v1 is the hex HMAC-SHA256 of <t>.<raw request body>, keyed with the monitor’s webhook.secret. The timestamp is signed too, so a captured request cannot be replayed with a fresh header — reject anything older than five minutes.
Verify against the raw body, before any JSON parsing: re-serialising changes the bytes and breaks the signature.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/hooks/prefetch", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Prefetch-Signature") || "";
const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300;
const expected = crypto
.createHmac("sha256", process.env.PREFETCH_WEBHOOK_SECRET)
.update(`${t}.${req.body}`)
.digest();
const given = Buffer.from(v1 || "", "hex");
if (!fresh || given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body);
res.sendStatus(200); // acknowledge first, then do the work
handle(event);
});
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/hooks/prefetch")
def prefetch_webhook():
header = request.headers.get("X-Prefetch-Signature", "")
parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
t, v1 = parts.get("t", "0"), parts.get("v1", "")
expected = hmac.new(
os.environ["PREFETCH_WEBHOOK_SECRET"].encode(),
f"{t}.".encode() + request.get_data(),
hashlib.sha256,
).hexdigest()
if abs(time.time() - int(t)) > 300 or not hmac.compare_digest(v1, expected):
abort(401)
event = request.get_json()
# ... handle the event
return "", 200
webhook.secret. To change it, update the monitor with "rotate_secret": true — see PATCH /monitor/{id}.
Headers
| Header | Value |
|---|---|
X-Prefetch-Event | monitor.page |
X-Prefetch-Delivery | The delivery id — the same as id in the body |
X-Prefetch-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
User-Agent | Prefetch-Webhooks/1.0 |
headers you set on the monitor’s webhook are sent as well.
Related
monitor.check.completed
One event per check, with its counts.
POST /monitor
Configure the webhook and the goal.
Authorizations
Your Prefetch API key. Obtain one from the dashboard.
Headers
The event type.
The delivery id — the same as id in the body, and stable across retries.
t=<unix seconds>,v1=<hex>, where hex is HMAC-SHA256 of "<t>.<raw body>" keyed with the monitor's webhook.secret.
Example:
"t=1760248862,v1=5f2b8c..."
Body
application/json
Delivery id, also sent as X-Prefetch-Delivery. Stable across retries — use it to de-duplicate.
Example:
"whd_5c1e9a2b7d3f4e6a8b0c2d4e6f8a1b3c"
The metadata you set on the webhook.
Available options:
monitor.page Show child attributes
Show child attributes
Response
200
Answer any 2xx within 10 seconds. Anything else, or no answer, is retried after 1, 5 and 15 minutes.