Website Monitor Webhooks
Receive and verify HMAC-signed change alerts from a Website Monitor — request format, signature verification, the payload schema, how to fetch full before/after content, and delivery/retry semantics.
A Website Monitor watches a URL and, when the page changes, sends an HTTP POST to a
webhook you configure. This page covers how to receive those calls, verify they're genuine,
and fetch the full change details.
Webhooks need no email provider and work out of the box. Email is the other live channel; Slack, Discord, and Telegram are planned.
Set up
- Open your monitor and go to Notifications → Webhook URL.
- Enter the URL we should
POSTto and save. - We generate a signing secret (
whsec_…) and show it in the Notifications card. Copy it into your receiving app — you'll use it to verify each delivery.
Why deliveries are signed
Your webhook URL is public: anyone who learns it could POST forged "the page changed"
payloads. So every delivery is HMAC-SHA256 signed with your secret, and the signature is
sent in the X-Anakin-Signature header.
- The secret is shared — we hold it, you hold a copy. It never travels in the request; only the signature (a fingerprint derived from the body + secret) does.
- We always send. We don't check anything about your secret before sending — verification
happens on your side. Your app recomputes the signature from the received body and its copy
of the secret and compares:
- Match → genuinely from us, untampered → process it.
- No match → forged or wrong secret → reject it (return
401, ignore the body).
Verifying is optional but strongly recommended for any endpoint that acts on the data.
Request format
POST to your webhook URL with these headers:
| Header | Example | Notes |
|---|---|---|
Content-Type | application/json | Body is JSON. |
User-Agent | AnakinScraper-Monitor/1 | Identifies our sender. |
X-Anakin-Timestamp | 1783009089 | Unix seconds when the POST was made. |
X-Anakin-Delivery-Id | c1e8fbac-… | The change id. Stable across retries — use it to de-duplicate. |
X-Anakin-Signature | sha256=7987… | sha256= + hex HMAC_SHA256(secret, rawBody). |
Per-request timeout is 10 seconds — acknowledge quickly (see Best practices). The signature is computed over the exact raw bytes of the body, so verify against the raw body, never a re-serialized object (JSON key order or whitespace would differ and the check would fail).
Verify the signature
Node (Express) — capture the raw body:
import express from "express"
import crypto from "crypto"
const app = express()
app.use("/anakin-webhook", express.raw({ type: "application/json" })) // raw bytes, not parsed
app.post("/anakin-webhook", (req, res) => {
const secret = process.env.ANAKIN_WEBHOOK_SECRET // the whsec_… you copied
const got = req.header("X-Anakin-Signature") || ""
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(req.body).digest("hex")
const ok =
got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected)) // constant-time compare
if (!ok) return res.status(401).send("bad signature")
const event = JSON.parse(req.body.toString())
// de-dupe on req.header("X-Anakin-Delivery-Id"), then process asynchronously
res.status(200).send("ok")
})Python (Flask):
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/anakin-webhook")
def webhook():
secret = os.environ["ANAKIN_WEBHOOK_SECRET"].encode()
raw = request.get_data() # raw bytes — do NOT use request.json here
expected = "sha256=" + hmac.new(secret, raw, hashlib.sha256).hexdigest()
got = request.headers.get("X-Anakin-Signature", "")
if not hmac.compare_digest(expected, got): # constant-time compare
abort(401)
event = request.get_json()
# de-dupe on request.headers.get("X-Anakin-Delivery-Id"), then process
return "", 200Payload
{
"type": "monitor.change",
"monitorId": "62e555fb-…",
"url": "https://flipkart.com",
"watchMode": "full_page",
"changeId": "c1e8fbac-…",
"changedAt": "2026-07-02T16:16:52.289Z",
"changedFields": ["content"],
"summary": "…",
"diff": { }
}type— alwaysmonitor.change.watchMode—full_pageorspecific_data(shapesdiff, below).changeId— equalsX-Anakin-Delivery-Id.changedFields— present when non-empty.summary— present only when AI meaningful-change wrote one.
diff for full_page (whole-page content change) — metadata plus snapshot references:
{
"type": "content",
"before": { "contentHash": "8366…" },
"after": { "contentHash": "0c24…" },
"stats": { "adds": 91, "dels": 91 },
"changedFields": ["content"],
"beforeSnapshotId": "63ca1f01-…",
"afterSnapshotId": "45874b38-…"
}diff for specific_data (tracked fields changed) — the values are inline; before / after
hold the fields that changed, e.g. { "price": 999 } → { "price": 899 }.
diff.aiMeaningful — present only for AI meaningful-change monitors: the AI's curated list
of the substantive changes with trivial noise removed. Each entry is { "description", "before", "after" }. Use it for a clean, human-readable "what actually changed" (the stats/snapshot bodies
still carry the full raw diff).
"aiMeaningful": [
{ "description": "Price dropped", "before": "$56,586", "after": "$16,988" },
{ "description": "Availability changed", "before": "In Stock", "after": "Confirm Availability" }
]Design note — a predictable contract
For full_page, the payload carries the change metadata — stats (how much changed) plus
beforeSnapshotId / afterSnapshotId — and you retrieve the full before/after text by snapshot
id (next section). The contract is the same on every delivery: the webhook reports that the
page changed and by how much, and the snapshot ids are always how you fetch the content — so
there's nothing per-delivery to interpret. specific_data values are small, so they're delivered
inline.
Fetch the full before / after content
For a full_page change, the payload gives you monitorId and, inside diff, the
beforeSnapshotId and afterSnapshotId. Each snapshot's stored page body is fetched from:
GET https://api.anakin.io/v1/monitors/{monitorId}/snapshots/{snapshotId}/contentAuthenticate with your Anakin API key in the X-API-Key header (see the
API reference for keys and base URL).
curl -s -H "X-API-Key: $ANAKIN_API_KEY" \
"https://api.anakin.io/v1/monitors/$MONITOR_ID/snapshots/$BEFORE_SNAPSHOT_ID/content"Response
{
"available": true,
"content": "…the stored page body…",
"capturedAt": "2026-07-02T16:16:52.289Z",
"contentHash": "8366…"
}| Field | Type | Notes |
|---|---|---|
available | boolean | false if no body was stored for this snapshot (e.g. a specific_data monitor, or storage not configured) or it couldn't be loaded. When false, content is "". |
content | string | The full stored page body — the same representation the monitor compares (markdown, cleaned HTML, or raw HTML, per the monitor's compare format). |
capturedAt | string | When the snapshot was taken. |
contentHash | string | SHA-256 of the body; equals the before/after contentHash in the diff. |
A missing or non-owned monitor/snapshot returns 404.
End-to-end: from webhook to full diff
Fetch both sides and compare them however you like. Continuing the verified handler from above:
const event = JSON.parse(req.body.toString())
if (event.watchMode === "full_page" && event.diff?.beforeSnapshotId) {
const { monitorId } = event
const { beforeSnapshotId, afterSnapshotId } = event.diff
const getBody = (snapshotId) =>
fetch(`https://api.anakin.io/v1/monitors/${monitorId}/snapshots/${snapshotId}/content`, {
headers: { "X-API-Key": process.env.ANAKIN_API_KEY },
}).then((r) => r.json())
const [before, after] = await Promise.all([getBody(beforeSnapshotId), getBody(afterSnapshotId)])
if (before.available && after.available) {
// before.content / after.content are the full page bodies — diff them with any library
// (e.g. `diff`, `jsdiff`), or store them, or feed them to an LLM.
}
}Or entirely in the shell:
url="https://api.anakin.io/v1/monitors/$MONITOR_ID/snapshots"
before=$(curl -s -H "X-API-Key: $ANAKIN_API_KEY" "$url/$BEFORE_SNAPSHOT_ID/content" | jq -r .content)
after=$(curl -s -H "X-API-Key: $ANAKIN_API_KEY" "$url/$AFTER_SNAPSHOT_ID/content" | jq -r .content)
diff <(printf '%s' "$before") <(printf '%s' "$after")Notes
specific_datamonitors already include the changed values inline indiff.before/diff.after, so you usually don't fetch anything. Their snapshots store field values, not a page body, so the content endpoint returnsavailable: false— readextractedDatafrom the snapshots endpoint instead if you want the full field set.- Handle
available: false— if a body isn't retrievable, fall back to thestats/summaryin the payload (andcontentHashto detect duplicates).
Related endpoints
Same X-API-Key auth:
GET /v1/monitors/{id}/changes— recorded changes, each with the samediffobject (including the snapshot ids).GET /v1/monitors/{id}/snapshots— recent snapshots (id,capturedAt,contentHash,extractedData,s3Path).
Delivery, retries & idempotency
Each change enqueues one delivery per configured channel; a background worker drains the queue.
-
Success: your endpoint returns 2xx → recorded as
success. -
Permanent failure (no retry): a 4xx other than 429 (e.g. 400, 401, 404) → we assume the endpoint is misconfigured and stop → marked
exhausted. -
Retried with backoff: a connection error / timeout, any 5xx, or 429 → retried up to 6 attempts (~8.5h total), then
exhausted:After attempt Wait before next 1 1 min 2 5 min 3 30 min 4 2 h 5 6 h -
At-least-once — de-duplicate. A delivery can arrive more than once (e.g. you processed it but replied slowly, so we retried). De-dupe on
X-Anakin-Delivery-Id(=changeId), which is stable across retries. -
Manual re-drive. A
failed/exhausteddelivery can be re-queued from the monitor's Notifications panel.
Best practices
- Verify the signature with a constant-time compare, over the raw body.
- Respond 2xx fast (within 10s). Acknowledge first, do heavy work asynchronously — slow endpoints get retried and look like failures.
- Be idempotent — key processing on
X-Anakin-Delivery-Id. - Return the right status — 2xx = accepted; 5xx / 429 to ask for a retry; a 4xx (non-429) tells us to stop permanently, so use it only for genuinely bad requests.
- Keep the secret secret — treat it like a password; re-save the monitor to rotate it.
- Don't assume ordering — deliveries can overlap under retries; use
changedAt/X-Anakin-Timestampif you need sequence.
Test it locally
- Run a small receiver that logs the request and verifies the signature (read the raw body,
recompute
sha256=HMAC_SHA256(secret, body), compare toX-Anakin-Signature). - Create a monitor with the webhook URL pointing at it (e.g.
http://localhost:4000/hook) and copy the signing secret into the receiver. - Click Test in the Notifications card for an immediate sample
POST, or let a real check detect a change (which also exercises the retry queue). - Confirm the receiver logs a signature match and the Notifications card shows a
successdelivery.
The Test button delivers synchronously and skips the retry queue; a real detected change goes through the full queue + retry path. Try both before relying on webhooks.