Guide
Continuous optimization is a closed loop that feeds Blockquote's weekly monitor results into an AI workflow which fixes your pages and opens the pull request.
Continuous optimization means your site's AI citability is re-measured every week and repaired by an agent, not audited once and forgotten. The raw material ships with Blockquote already: weekly monitor scans, a signed webhook per result, the full report as one Markdown document, and an MCP server any agent can call. This guide assembles them into a working loop, with paste-ready receiver code for the common hosting platforms and a GitHub Action that turns reports into pull requests. By Arne Kellmann. Updated 2026-08-20.
In short
- Loop: monitor → webhook → verify → AI analyzes report → fix → pull request — the next weekly scan measures the effect.
- Verify
X-Blockquote-Signature: t=<unix>,v1=<hex>— HMAC-SHA256 over${timestamp}.${rawBody}, constant-time compare, 5-minute tolerance. - Respond 2xx fast, process async, deduplicate on
event_id. - Three patterns: repository_dispatch → GitHub Action with an AI coding agent, direct Claude API call on the Markdown report, or serverless-free — a scheduled agent polling the MCP server.
- Plans: webhooks are Agency; API keys are Pro and up; anonymous MCP works for everyone.
What does a webhook receiver look like on each platform?
A receiver has exactly three duties: verify the signature against the raw body, answer 2xx within 10 seconds, and process afterwards. The signature scheme is the timestamped HMAC that Stripe's webhook signatures made the industry default — the webhook reference specifies Blockquote's variant field by field. On Cloudflare Workers, the web-standard SubtleCrypto API computes the HMAC:
// Cloudflare Worker — wrangler deploy; secret via: wrangler secret put BLOCKQUOTE_WEBHOOK_SECRET
export default {
async fetch(request, env, ctx) {
const rawBody = await request.text();
const ok = await verify(rawBody, request.headers.get("X-Blockquote-Signature"), env.BLOCKQUOTE_WEBHOOK_SECRET);
if (!ok) return new Response(null, { status: 400 });
ctx.waitUntil(handleEvent(JSON.parse(rawBody), env)); // process after responding
return new Response(null, { status: 200 });
},
};
async function verify(rawBody, header, secret) {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.trim().split("=")));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${parts.t}.${rawBody}`));
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
// timing-safe compare
if (expected.length !== parts.v1.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ parts.v1.charCodeAt(i);
return diff === 0;
}
Put the Worker on a custom domain before you save the URL. Blockquote rejects a
workers.dev hostname, because the delivery queue cannot reach one.
On Vercel Functions — and unchanged in spirit on Netlify Functions or an AWS Lambda function URL — Node's crypto module does the same, and the handler forwards the event straight to GitHub:
// Vercel — app/api/blockquote/route.ts (Node runtime; also works as a Netlify
// Function or AWS Lambda behind a function URL: same body, same verification)
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const rawBody = await request.text();
if (!verify(rawBody, request.headers.get("X-Blockquote-Signature"))) {
return new Response(null, { status: 400 });
}
const event = JSON.parse(rawBody);
// Respond fast; enqueue the real work (repository_dispatch, Claude call, …).
await fetch("https://api.github.com/repos/OWNER/REPO/dispatches", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
accept: "application/vnd.github+json",
},
body: JSON.stringify({ event_type: "blockquote-report", client_payload: {
scan_id: event.scan.id, url: event.monitor.url, score: event.score.current, delta: event.score.delta,
}}),
});
return new Response(null, { status: 200 });
}
function verify(rawBody: string, header: string | null): boolean {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.trim().split("=")));
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac("sha256", process.env.BLOCKQUOTE_WEBHOOK_SECRET!)
.update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex"); const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && timingSafeEqual(a, b);
} What does the autofix workflow look like?
The receiver above sends a repository_dispatch event; this workflow answers it. It pulls the complete report and hands it to claude-code-action, which runs the agent in the runner and opens the pull request — the merge stays human:
# .github/workflows/blockquote-autofix.yml
name: Blockquote autofix
on:
repository_dispatch:
types: [blockquote-report]
jobs:
fix:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Fetch the full report as Markdown
run: |
curl -sS -H "Accept: text/markdown" \
-H "Authorization: Bearer ${{ secrets.BLOCKQUOTE_API_KEY }}" \
"https://blockquote.io/api/v1/scan/${{ github.event.client_payload.scan_id }}" \
> /tmp/report.md
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
/tmp/report.md is a Blockquote AI-citability report for
${{ github.event.client_payload.url }} (score ${{ github.event.client_payload.score }}).
Apply the top 3 recommendations to this repository — JSON-LD fixes into the page
templates, content fixes into the copy. Never invent facts. Open a pull request
summarizing each fix and its expected score impact. The same shape works with any agent that runs in CI. For analysis-only automation, call the Claude API from the receiver instead — the next section compares the patterns. Webhook receivers are servers making requests on attacker-influenceable input, so keep the OWASP SSRF guidance in mind when your handler fetches anything derived from the payload.
How does the loop work end to end?
Five stations, each replaceable. Blockquote's weekly monitoring re-scans every URL you saved and, on the Agency plan, POSTs each result to your webhook endpoint as a signed monitor.weekly_result event. Your receiver — a few dozen lines on any serverless platform — verifies the signature, answers 200 fast, and hands the event onward. An AI step reads the full report, decides what to change, and applies it: JSON-LD payloads into templates, content fixes into copy. The output lands as a pull request a human merges. The next weekly scan measures whether the score actually moved.
Nothing in the loop is proprietary glue: the webhook is standard HMAC-signed JSON, the report is available as plain Markdown, and the AI step is whatever agent you already use. The three patterns below — GitHub Action, direct API call, serverless-free — are the same loop with different amounts of infrastructure.
How do you trigger a GitHub Action from a webhook?
Have the receiver translate the webhook into a repository_dispatch event: one authenticated POST to the GitHub API with an event_type of your choosing and the scan facts in client_payload. A workflow listening on that event checks out your site, fetches the full report as Markdown, and runs an AI coding agent — the claude-code-action runs Claude Code inside the workflow with a prompt like: read this report, apply the top three fixes, open a pull request titled with the score delta.
The pull request is the safety boundary. The agent never pushes to your default branch; a human reviews the diff exactly like any other contribution, and branch protection plus your CI keep the bar identical. Use a fine-grained token that can only create dispatch events on that one repository, and pin the workflow to only act on the event types you send.
How do you analyze a report with the Claude API directly?
Skip GitHub entirely when the output should be analysis rather than a code change. After verifying the webhook, the receiver fetches GET /api/v1/scan/{id} with Accept: text/markdown — with your bq_ API key in the Authorization header so the fix list is complete — and sends the whole document to the Claude API with an instruction like: evaluate every result, group failures by category, and return the three changes with the biggest expected score impact as a task list. Post the answer to Slack, file it as an issue, or store it next to the delta history.
The report is deliberately sized for this: a complete scan renders to a few thousand tokens of Markdown, so it fits in one message with room for instructions. The webhook payload alone already carries the score movement and top three recommendations — enough for a cheap triage step that only calls the model when the score dropped.
Can you run the loop without any server at all?
Yes — invert it from push to pull. Instead of receiving webhooks, schedule an agent that polls: a Claude Code routine, a cron-triggered CI job, or any scheduled agent connects to the MCP server at https://mcp.blockquote.io/mcp, calls start_scan on the pages you care about (refresh true with an API key), reads each report with get_scan view markdown, and applies or proposes fixes. No endpoint, no signature verification, no queue — the MCP server is the whole integration surface.
The trade-off is freshness and cost: a pull loop runs on your schedule and spends your plan's scan quota, while webhooks piggyback on the weekly monitor scans that already happen. Most teams start with the pull variant because it needs zero infrastructure, then add the webhook once the loop proves itself.
What keeps the loop safe and idempotent?
Verify before you trust, deduplicate before you act, and keep a human before merge. Signature verification with a constant-time comparison and a 5-minute timestamp tolerance stops forged and replayed deliveries. Retries carry byte-identical payloads under the same event_id, so a processed-events table keyed on that field makes the whole pipeline idempotent — a redelivered event must not open a second pull request. And the AI step's write access ends at a branch: the pull request is where automation stops and review starts.
Treat the webhook secret like a password — secret manager, not code — and rotate it from the account page if it ever leaks. The receiver should also reject events whose monitor URL it does not recognize: defense in depth against a compromised sender is cheap when it is one Set lookup.
Which plan do you need for which piece?
Webhooks ship with the Agency plan, as part of its API access: one HTTPS endpoint per account, configured, tested and rotated on the account page. API keys — needed for refresh scans, for reading ungated reports programmatically, and for the serverless-free MCP variant at account quotas — come with Pro and Agency. Anonymous MCP access works on every plan and with no account at all, at free-tier limits with the free report gating. Anonymous REST access needs a Cloudflare Turnstile token, which only a browser produces, so a headless caller scans over MCP or with a key on a paid plan.
The weekly monitors themselves are Pro (5 URLs) and Agency (25 URLs). A Pro account can therefore run the pull variant of the loop today and upgrade to push when the monitor count or the webhook convenience justifies it.
How do you start this week?
In order: run a free scan of your most important page and fix the top three findings by hand once — that calibrates your sense for what the agent will do. Create an API key and let your agent read the report over MCP; that is the serverless-free loop. When you upgrade to monitors, add the webhook endpoint, press Send test webhook on the account page to verify your receiver end to end, and turn on the workflow. From then on, each weekly scan opens the next day's pull request.