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

  1. Open your monitor and go to Notifications → Webhook URL.
  2. Enter the URL we should POST to and save.
  3. 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:

HeaderExampleNotes
Content-Typeapplication/jsonBody is JSON.
User-AgentAnakinScraper-Monitor/1Identifies our sender.
X-Anakin-Timestamp1783009089Unix seconds when the POST was made.
X-Anakin-Delivery-Idc1e8fbac-…The change id. Stable across retries — use it to de-duplicate.
X-Anakin-Signaturesha256=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 "", 200

Payload

{
  "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 — always monitor.change.
  • watchModefull_page or specific_data (shapes diff, below).
  • changeId — equals X-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 metadatastats (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}/content

Authenticate 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…"
}
FieldTypeNotes
availablebooleanfalse 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 "".
contentstringThe full stored page body — the same representation the monitor compares (markdown, cleaned HTML, or raw HTML, per the monitor's compare format).
capturedAtstringWhen the snapshot was taken.
contentHashstringSHA-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_data monitors already include the changed values inline in diff.before / diff.after, so you usually don't fetch anything. Their snapshots store field values, not a page body, so the content endpoint returns available: false — read extractedData from the snapshots endpoint instead if you want the full field set.
  • Handle available: false — if a body isn't retrievable, fall back to the stats / summary in the payload (and contentHash to detect duplicates).

Same X-API-Key auth:

  • GET /v1/monitors/{id}/changes — recorded changes, each with the same diff object (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 attemptWait before next
    11 min
    25 min
    330 min
    42 h
    56 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 / exhausted delivery 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-Timestamp if you need sequence.

Test it locally

  1. Run a small receiver that logs the request and verifies the signature (read the raw body, recompute sha256=HMAC_SHA256(secret, body), compare to X-Anakin-Signature).
  2. Create a monitor with the webhook URL pointing at it (e.g. http://localhost:4000/hook) and copy the signing secret into the receiver.
  3. 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).
  4. Confirm the receiver logs a signature match and the Notifications card shows a success delivery.

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.