blockquote. — AI citability audit 34 checks · scored 0–100 · fixes included

Reference

A Blockquote webhook is a signed HTTP POST that delivers each weekly monitor result to your server.

A Blockquote webhook is an HTTP POST with a JSON body, sent to one HTTPS endpoint per account after each weekly monitor scan, signed with HMAC-SHA256 over a timestamped body. Webhooks are part of the Agency plan's API access. This page is the complete integration contract: the payload schema, the request headers, the retry policy, and a Node.js verification function you can paste. By Arne Kellmann. Updated 2026-08-22.

In short

  • One monitor.weekly_result event per monitored URL per weekly run.
  • Signature header X-Blockquote-Signature: t=<unix>,v1=<hex> — HMAC-SHA256 of ${timestamp}.${rawBody} with your whsec_ secret.
  • Respond 2xx within 10 seconds; failures retry with exponential backoff, up to 6 attempts.
  • After 3 consecutive weekly failures the endpoint is disabled and you get an email; re-enable it on the account page.
  • Deduplicate on event_id; retries repeat the identical payload.

What does the payload contain?

Every delivery is a single JSON object with a stable envelope: the schema version, the event name, a unique event_id, the monitor, the scan, the score movement against the previous week, per-category scores, and the top 3 recommendations. The check_id and category values are the same identifiers the scan report uses. The entries arrive in the report's own order: recoverable_points is how many of the 100 score points that fix recovers, largest first, and it selects the 3 that ship. Sort by that number, not by priority, which is a raw internal weight and orders nothing. recoverable_points is absent when the number is not computable. A complete successful event looks like this:

monitor.weekly_result — example payload
{
  "schema_version": "1",
  "event": "monitor.weekly_result",
  "event_id": "evt_8f3kq1zx0m2v",
  "delivered_at": "2026-08-10T06:12:04.000Z",
  "monitor": {
    "id": "m7k2p9qx41ba",
    "url": "https://example.com/pricing"
  },
  "scan": {
    "id": "s4t8n2vq6w1e",
    "status": "done",
    "error": null,
    "scanned_at": "2026-08-10T06:11:31.000Z",
    "report_url": "https://blockquote.io/scan/s4t8n2vq6w1e"
  },
  "score": {
    "current": 74,
    "previous": 68,
    "delta": 6
  },
  "categories": [
    { "category": "schema", "score": 82, "previous": 71, "delta": 11 },
    { "category": "structure", "score": 70, "previous": 70, "delta": 0 },
    { "category": "citability", "score": 68, "previous": 61, "delta": 7 }
  ],
  "recommendations": [
    {
      "check_id": "schema-faqpage",
      "category": "schema",
      "priority": 5,
      "recoverable_points": 8,
      "summary": "The page answers recurring questions but ships no FAQPage entity.",
      "fix_title": "Add FAQPage JSON-LD"
    },
    {
      "check_id": "content-question-h2",
      "category": "structure",
      "priority": 4,
      "recoverable_points": 5,
      "summary": "Only 1 of 6 H2 headings is phrased as a question.",
      "fix_title": "Rephrase section headings as questions"
    },
    {
      "check_id": "citability-citation-density",
      "category": "citability",
      "priority": 3,
      "recoverable_points": 3,
      "summary": "The page cites 2 external sources; the threshold is 5.",
      "fix_title": "Link primary sources next to the claims they support"
    }
  ]
}

The score.delta and per-category delta fields are computed against the previous completed scan of the same monitor; on a monitor's first run, previous and delta are null. Payloads stay under 64 KB — when a report would push past that, recommendation entries are dropped from the end of the list first.

Which headers does each delivery carry?

Four headers matter to a consumer; everything else is transport routine.

  • X-Blockquote-Signaturet=<unix-seconds>,v1=<hex-hmac>, the signature scheme described below.
  • X-Blockquote-Event-Id — the same value as event_id in the body, so you can deduplicate before parsing JSON.
  • X-Blockquote-Event-Type — currently always monitor.weekly_result; route on it so future event types do not break you.
  • Content-Typeapplication/json; the User-Agent is Blockquote-Webhooks/1.0 (+https://blockquote.io).

How do you verify the signature in Node.js?

The signature is HMAC-SHA256 over the string ${timestamp}.${rawBody} — the Unix timestamp from t=, a literal dot, then the exact raw request body — keyed with your whsec_ secret and hex-encoded into v1=. The scheme is deliberately the one Stripe's webhook signatures established, so existing verification middleware adapts in minutes. Two rules keep the verification sound: compare digests with a constant-time comparison such as crypto.timingSafeEqual, and reject timestamps more than 5 minutes from your clock to shut down replay of captured requests.

verify-blockquote-signature.mjs
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300; // reject timestamps more than 5 minutes off

export function verifyBlockquoteSignature(rawBody, signatureHeader, secret) {
  let timestamp = "";
  let received = "";
  for (const part of (signatureHeader ?? "").split(",")) {
    const [key, value] = part.trim().split("=");
    if (key === "t") timestamp = value;
    if (key === "v1") received = value;
  }
  if (!timestamp || !received) return false;

  const age = Math.abs(Date.now() / 1000 - Number.parseInt(timestamp, 10));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: verify the raw body BEFORE any JSON parsing.
app.post(
  "/hooks/blockquote",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const valid = verifyBlockquoteSignature(
      req.body.toString("utf8"),
      req.get("X-Blockquote-Signature"),
      process.env.BLOCKQUOTE_WEBHOOK_SECRET,
    );
    if (!valid) return res.status(400).end();

    const event = JSON.parse(req.body);
    // Record event.event_id, respond fast, process afterwards.
    res.status(200).end();
  },
);

Verify the raw body bytes, never a re-serialized object: JSON serializers reorder keys and rewrite whitespace, and one changed byte changes the digest. HMAC itself is specified in RFC 2104; in runtimes without Node's crypto module, the browser-standard SubtleCrypto sign() API computes the same HMAC-SHA256 — that is exactly how Blockquote produces the signature on Cloudflare Workers.

When does Blockquote send a webhook?

Blockquote sends one webhook per monitored URL per weekly run. Monitoring re-scans every saved URL once a week, on a schedule anchored to the moment you added your first monitor; after a monitor's scan finishes and its result is stored, a delivery job is queued and your endpoint receives a single monitor.weekly_result event for that monitor. A run over 25 monitored URLs therefore produces 25 separate deliveries, each with its own event_id, not one batched payload.

Webhooks are an Agency-plan feature and sit alongside the weekly email digest — the digest arrives in any week a score moves, whether or not an endpoint is configured, and you can turn it off on the account page. A delivery is sent for every monitored URL each week, including a week in which the score did not move. Each account has exactly one endpoint URL. The endpoint is configured, tested, rotated, disabled and removed on the account page; no code deployment on Blockquote's side is involved.

Which URLs can receive deliveries?

Only public HTTPS URLs on the default port 443. Plain http:// URLs, non-standard ports, localhost, hostnames ending in .local or .internal, and IP-address literals that resolve to private, loopback, link-local or otherwise reserved ranges are all rejected when you save the URL — including obfuscated IPv4 spellings in decimal, hexadecimal or octal notation. A workers.dev hostname is also rejected: Blockquote cannot deliver to one from the queue that sends your weekly results, so a Cloudflare Worker endpoint needs a custom domain.

The restriction exists because a webhook sender is, by construction, a server that makes requests to attacker-chosen URLs — the classic server-side request forgery shape. Rejecting private targets at save time follows the standard SSRF-prevention guidance and keeps Blockquote's infrastructure from being used as a proxy into anyone's internal network.

What is the retry policy?

A delivery counts as successful when your endpoint answers with any 2xx status within 10 seconds. Anything else — a network error, a timeout, a 429 or a 5xx — is retried with exponential backoff: the first retry waits about 30 seconds, each further retry doubles the wait, the wait is capped at one hour, and the message is attempted up to 6 times in total before it is parked in a dead-letter queue.

A 4xx response other than 429 is treated as a permanent rejection of that event: it is not retried, but it still counts as a failed delivery. Every failed event — a response from your endpoint, a network error or a timeout — advances the consecutive-failure counter by exactly one; a successful delivery resets the counter to zero. A delivery Blockquote refuses to send, because the hostname cannot be reached from the delivery queue, is recorded as the last delivery outcome but does not advance the counter and never disables your endpoint.

When is an endpoint disabled, and how do you re-enable it?

After 3 consecutive weekly failures the endpoint is disabled automatically and Blockquote emails the account owner with the endpoint URL and the last delivery error. Consecutive means three distinct events in a row that Blockquote sent and that failed — the six retry attempts of a single event count once, so a broken endpoint has roughly three weeks before deliveries stop.

That email is the Webhook failures notification on the account page. Turning it off, or turning all email off, silences the message but not the guard: the endpoint is still disabled, and the account page still shows the reason. Leave the notification on if nobody watches the endpoint.

Re-enabling is manual and takes one click on the account page; the disable reason and the last delivery outcome are shown there. Deliveries missed while the endpoint was disabled are not replayed — the scan history remains the record for those runs.

How should your endpoint respond?

Return a 2xx as fast as possible and do the real work afterwards. The delivery times out after 10 seconds, and a timeout is a failure that triggers retries — so verify the signature, persist the raw event, respond 200, and process asynchronously. This is the same acknowledge-then-process pattern every major webhook provider recommends.

Handle duplicates by event_id. Retries carry byte-identical payloads under the same event_id, so an idempotent consumer needs nothing more than a processed-event table keyed on that field. Payloads are capped at 64 KB; when a report is unusually large, recommendations are truncated first to stay under the cap.

How is the signing secret handled?

The secret is generated once, when you first save an endpoint URL. It starts with the prefix whsec_ followed by 43 base64url characters — 256 bits of entropy from a cryptographically secure generator. It is shown, masked, on the account page, where the account owner can reveal, copy and rotate it; it never appears in any log and never travels in a delivery.

Treat the secret like a password: store it in your secret manager, not in code. Rotation invalidates the old secret immediately, so update your server first and rotate second, or tolerate a short window of failed verifications that the retry policy will absorb.

Why is the payload versioned?

Every event carries schema_version, currently the string 1. New optional fields are added without a version bump, so a correct consumer ignores fields it does not recognize. A change that removes or renames a field, or changes a field's type, gets a new version and an announcement to Agency accounts before it ships.

The event name monitor.weekly_result is versioned independently of the envelope: future event types will arrive under new names in the X-Blockquote-Event-Type header, and a consumer that checks the event type before processing keeps working unchanged when they do.

What does a failed scan look like in the payload?

When the weekly scan itself fails — the page timed out, returned a server error, or could not be fetched — the webhook is still delivered. In that error variant, scan.status is error, scan.error contains the failure message, score.current is null, and both categories and recommendations are empty arrays. The score.previous field keeps the last known score so your dashboard can show what the page scored before it broke.

This makes the webhook a monitoring signal, not only a reporting one: a delivery with scan.status of error the week after a site migration is exactly the alert the feature exists to give you.

How is delivery infrastructure built?

Deliveries run on Cloudflare Queues: the weekly monitoring run enqueues one message per monitor result, and a queue consumer builds the payload, signs it and performs the POST from Cloudflare's edge. The queue — not your endpoint's availability — absorbs retry scheduling, which is why a slow endpoint on your side never delays anyone's scan or email digest. The URL rules in this document follow the OWASP SSRF prevention guidance.

How do you set it up?

On the account page, save your endpoint URL under Webhook callback — the secret is generated at that moment and shown once masked, with reveal and copy controls. Then press Send test webhook: it posts a synthetic monitor.weekly_result event through the same signing and delivery code as production traffic, reports the HTTP status inline, and never touches the failure counter. A test delivery that verifies correctly on your server is the whole setup checklist; the first real event arrives with your next weekly run.

Privacy policy