{"openapi":"3.1.0","info":{"title":"Blockquote AI-Visibility API","version":"0.1.0","description":"Score and fix any URL for ChatGPT, Perplexity, and Google AI Overviews citation likelihood. Scans run asynchronously: POST /api/v1/scan returns a scan id, progress streams over Server-Sent Events, and the finished report is fetched by id. Authentication is either an email magic link that sets the `sid` session cookie, or an `Authorization: Bearer bq_…` API key created on /account. Versioning: this document describes v1, served at /api/v1. The unversioned /api base answers the same handlers and is superseded: it carries `Deprecation: @1787443200` (RFC 9745, an sf-date), a `Link` naming its successor, and a `Link` with rel=\"deprecation\" naming the published policy, https://blockquote.io/api-versioning.md. Every answer carries `API-Version`. A version is removed only after a `Sunset` header (RFC 8594) announces the date, and no removal date is set today."},"externalDocs":{"description":"API versioning and deprecation policy","url":"https://blockquote.io/api-versioning.md"},"servers":[{"url":"https://blockquote.io/api/v1","description":"Current version. The unversioned /api base still answers and is superseded."}],"tags":[{"name":"System","description":"Health and liveness."},{"name":"Scans","description":"Run scans and fetch reports."},{"name":"Auth","description":"Magic-link sign-in and session management."},{"name":"Billing","description":"Stripe checkout and customer portal."},{"name":"Stripe","description":"Inbound Stripe webhook (signature-verified)."},{"name":"Monitors","description":"Weekly URL monitoring (Pro and up)."},{"name":"History","description":"Scans stored under the account, grouped by URL — including weekly monitor runs. A scan answered from the 24-hour cache reuses an existing report and adds no entry. Pro and up."},{"name":"Webhooks","description":"Outbound result webhooks (Agency plan)."},{"name":"Account","description":"API keys for agents/scripts (Pro and up), account usage, and email notification preferences."},{"name":"Documentation","description":"This specification and its reference UI."}],"paths":{"/healthz":{"get":{"operationId":"getHealth","summary":"Health check","description":"Reports that the Worker answers, and with `deep=1` that its D1 database and the monitoring heartbeat answer too. Anonymous, and it spends no scan quota — poll it as a liveness probe. The shallow form touches no storage; `deep=1` costs one D1 query and one KV read, so poll that form less often.","tags":["System"],"parameters":[{"name":"deep","in":"query","description":"Pass 1 to include the D1 probe and the monitoring heartbeat.","schema":{"type":"string","enum":["1"]}}],"responses":{"200":{"description":"Service health.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}}}}},"/chain/status":{"get":{"operationId":"getChainStatus","summary":"Pause state of the autonomous chain","description":"Reports the pause switch of the repository's autonomous chain as its mirror last wrote it. `frozen` is true only when the switch is pressed. `stale` is true when no mirror copy exists, or the copy is older than 45 minutes: the writer is down and the switch is unknown. The two fields are independent; a caller that may act reads both false. Anonymous, one KV read, spends no scan quota, and is never cached.","tags":["System"],"responses":{"200":{"description":"The mirrored pause state.","headers":{"Cache-Control":{"description":"Always `no-store`: a cached answer is a pause nobody saw.","schema":{"type":"string","const":"no-store"}}},"content":{"application/json":{"schema":{"type":"object","required":["frozen","stale","asOf","source"],"properties":{"frozen":{"type":"boolean","description":"True only when the mirror wrote frozen true."},"stale":{"type":"boolean","description":"True when the mirror copy is absent, unreadable, or older than 45 minutes. Says nothing about `frozen`."},"asOf":{"type":["string","null"],"format":"date-time","description":"When the mirror wrote the copy; null when unreadable."},"source":{"type":["string","null"],"description":"Where the mirror read the switch (`variable`, or `unreadable` when the read failed and the chain stopped); null when absent."}}}}}}}}},"/chain/board":{"get":{"operationId":"getChainBoard","summary":"The chain board: every object's stage and its last runs","description":"The snapshot the autonomous chain's sweep writes every 10 minutes: one entry per open issue and pull request with its stage, how long it has held it, and the last run of each stage workflow against it; the sweep's last ten decisions with their reasons; and each routine's last heartbeat. `stale` is true when no snapshot exists or it is older than 45 minutes. Needs an API key with the `scan` scope, or a signed-in account on the Agency plan. One KV read, spends no scan quota, never cached.","tags":["System"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"The board as the sweep last wrote it.","headers":{"Cache-Control":{"description":"Always `private, no-store`.","schema":{"type":"string","const":"private, no-store"}}},"content":{"application/json":{"schema":{"type":"object","required":["stale","asOf","sweepRun","objects","decisions","heartbeats"],"properties":{"stale":{"type":"boolean","description":"True when the snapshot is absent or older than 45 minutes."},"asOf":{"type":["string","null"],"format":"date-time"},"sweepRun":{"type":["string","null"],"description":"The Actions run id of the sweep that wrote it."},"objects":{"type":"array","items":{"type":"object","required":["number","kind","title","lane","stage","labels","runs"],"properties":{"number":{"type":"integer"},"kind":{"type":"string","enum":["pr","issue"]},"title":{"type":"string"},"lane":{"type":"string","enum":["bugfix","deps","chain","feature"]},"stage":{"type":["string","null"]},"labels":{"type":"array","items":{"type":"string"}},"head":{"type":["string","null"]},"headRef":{"type":["string","null"]},"enteredStageAt":{"type":["string","null"],"format":"date-time"},"hoursInStage":{"type":["number","null"]},"runs":{"type":"object","description":"Keyed by stage key.","additionalProperties":{"type":"object","required":["id","conclusion","status","at","match"],"properties":{"id":{"type":"integer"},"conclusion":{"type":["string","null"]},"status":{"type":["string","null"]},"at":{"type":["string","null"],"format":"date-time"},"match":{"type":"string","enum":["head","since"],"description":"`head`: matched by head sha or branch. `since`: the stage's workflow ran after the object entered the stage; the run may belong to another object."}}}}}}},"decisions":{"type":"array","items":{"type":"object","required":["at","object","decision","reasons","runId"],"properties":{"at":{"type":"string","format":"date-time"},"object":{"type":["string","null"]},"decision":{"type":["string","null"]},"reasons":{"type":"array","items":{"type":"string"}},"runId":{"type":"string"}}}},"heartbeats":{"type":"array","items":{"type":"object","required":["name","at","failedConnectors","ageHours"],"properties":{"name":{"type":"string"},"at":{"type":"string","format":"date-time"},"failedConnectors":{"type":"array","items":{"type":"string"}},"ageHours":{"type":["number","null"]}}}}}}}}},"401":{"description":"Not signed in, or the API key is invalid or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"The session is not on the Agency plan, or the API key does not carry the scan scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/scan":{"post":{"operationId":"createScan","summary":"Start a scan","description":"Dispatches an asynchronous scan of an HTTPS URL. A fresh cached report is returned immediately (status done, cached true, scannedAt when it ran); otherwise poll GET /api/v1/scan/{id} or subscribe to /api/v1/scan/{id}/events. Send refresh true to skip the cache and scan the live page — a session or a live API key only. Rate-limited per IP for anonymous callers — a day bucket and a calendar-month bucket — and per plan for signed-in users; only a dispatched scan spends a slot, so a cached report is free. A replayed answer is served before the quota is read, so it carries no RateLimit fields and spends no slot.","tags":["Scans"],"security":[{"cookieAuth":[]},{"bearerAuth":[]},{}],"requestBody":{"description":"Target URL (bare domains get https:// prefixed).","required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","examples":["example.com/blog/post"]},"turnstileToken":{"type":"string","description":"Cloudflare Turnstile response token. Required when the deployment has Turnstile configured and the caller is not on a paid plan. A key whose account has lapsed to Free authenticates the call but does not skip the token."},"refresh":{"type":"boolean","description":"Skip the cache and scan the page again. Needs a session or a live API key; a caller with neither gets 401, and a dead key gets 401 invalid_api_key. Counts against the account's scan quota like any other scan. Turnstile is skipped only when the URL already has a stored report; a URL with none is an ordinary scan and needs a token like any other."}}}}}},"responses":{"200":{"description":"A fresh cached report was found; returned as done immediately.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanAccepted"}}},"headers":{"RateLimit":{"description":"Structured Field List, one member per bucket the caller is metered on: `\"day\";r=29;t=37772, \"month\";r=199;t=1209600`. `r` is the quota left after this request, `t` the seconds until that bucket rolls over (draft-ietf-httpapi-ratelimit-headers-08 and later).","schema":{"type":"string"}},"RateLimit-Policy":{"description":"Structured Field List naming every ceiling, in the same order: `\"day\";q=30;w=86400, \"month\";q=200;w=2678400`. `q` is the quota, `w` the window in seconds. A monthly window is the length of that UTC month, not an average. Read every member: the first alone hides the other ceiling.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"The bucket's quota. The -07 and earlier shape of the same draft.","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Quota left after this request. Zero on a 429.","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the bucket rolls over.","schema":{"type":"integer"}},"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"202":{"description":"Scan dispatched. Poll GET /api/v1/scan/{id} or subscribe to GET /api/v1/scan/{id}/events for the result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanAccepted"}}},"headers":{"Location":{"description":"Path to poll for the result: /api/v1/scan/{id}.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before the first poll.","schema":{"type":"integer"}},"RateLimit":{"description":"Structured Field List, one member per bucket the caller is metered on: `\"day\";r=29;t=37772, \"month\";r=199;t=1209600`. `r` is the quota left after this request, `t` the seconds until that bucket rolls over (draft-ietf-httpapi-ratelimit-headers-08 and later).","schema":{"type":"string"}},"RateLimit-Policy":{"description":"Structured Field List naming every ceiling, in the same order: `\"day\";q=30;w=86400, \"month\";q=200;w=2678400`. `q` is the quota, `w` the window in seconds. A monthly window is the length of that UTC month, not an average. Read every member: the first alone hides the other ceiling.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"The bucket's quota. The -07 and earlier shape of the same draft.","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Quota left after this request. Zero on a 429.","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the bucket rolls over.","schema":{"type":"integer"}},"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"Missing body or invalid / non-HTTPS URL. Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"RateLimit":{"description":"Structured Field List, one member per bucket the caller is metered on: `\"day\";r=29;t=37772, \"month\";r=199;t=1209600`. `r` is the quota left after this request, `t` the seconds until that bucket rolls over (draft-ietf-httpapi-ratelimit-headers-08 and later).","schema":{"type":"string"}},"RateLimit-Policy":{"description":"Structured Field List naming every ceiling, in the same order: `\"day\";q=30;w=86400, \"month\";q=200;w=2678400`. `q` is the quota, `w` the window in seconds. A monthly window is the length of that UTC month, not an average. Read every member: the first alone hides the other ceiling.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"The bucket's quota. The -07 and earlier shape of the same draft.","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Quota left after this request. Zero on a 429.","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the bucket rolls over.","schema":{"type":"integer"}}}},"401":{"description":"refresh was requested without a session, or with an invalid or revoked API key. Carries `agent`, because neither signing in nor reloading a page is an action an unattended caller has.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Error"},{"type":"object","required":["agent"],"properties":{"agent":{"$ref":"#/components/schemas/AgentAccess"}}}]}}},"headers":{"RateLimit":{"description":"Structured Field List, one member per bucket the caller is metered on: `\"day\";r=29;t=37772, \"month\";r=199;t=1209600`. `r` is the quota left after this request, `t` the seconds until that bucket rolls over (draft-ietf-httpapi-ratelimit-headers-08 and later).","schema":{"type":"string"}},"RateLimit-Policy":{"description":"Structured Field List naming every ceiling, in the same order: `\"day\";q=30;w=86400, \"month\";q=200;w=2678400`. `q` is the quota, `w` the window in seconds. A monthly window is the length of that UTC month, not an average. Read every member: the first alone hides the other ceiling.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"The bucket's quota. The -07 and earlier shape of the same draft.","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Quota left after this request. Zero on a 429.","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the bucket rolls over.","schema":{"type":"integer"}},"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"Human verification failed, or the API key does not carry the scan scope. Both refusals carry `agent` with the MCP endpoint, the `start_scan` tool and the API-key page, and repeat both pointers as Link headers. A browser reloads the page; a caller without one takes the MCP or API-key path. The scope refusal adds `agent.retryWithout: \"authorization\"`, because the MCP relay forwards the same key and would repeat this refusal. The RateLimit fields are absent in that second case: a key without the scope is refused before the request is metered.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Error"},{"type":"object","required":["agent"],"properties":{"agent":{"$ref":"#/components/schemas/AgentAccess"}}}]}}},"headers":{"RateLimit":{"description":"Structured Field List, one member per bucket the caller is metered on: `\"day\";r=29;t=37772, \"month\";r=199;t=1209600`. `r` is the quota left after this request, `t` the seconds until that bucket rolls over (draft-ietf-httpapi-ratelimit-headers-08 and later).","schema":{"type":"string"}},"RateLimit-Policy":{"description":"Structured Field List naming every ceiling, in the same order: `\"day\";q=30;w=86400, \"month\";q=200;w=2678400`. `q` is the quota, `w` the window in seconds. A monthly window is the length of that UTC month, not an average. Read every member: the first alone hides the other ceiling.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"The bucket's quota. The -07 and earlier shape of the same draft.","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Quota left after this request. Zero on a 429.","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the bucket rolls over.","schema":{"type":"integer"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Scan limit reached: the IP day/month bucket for anonymous callers, or the account's day/month quota for signed-in ones.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanLimit"}}},"headers":{"Retry-After":{"description":"Seconds to wait. The measured wait on the anonymous path, the bucket rollover on the account one.","schema":{"type":"integer"}},"RateLimit":{"description":"Structured Field List, one member per bucket the caller is metered on: `\"day\";r=29;t=37772, \"month\";r=199;t=1209600`. `r` is the quota left after this request, `t` the seconds until that bucket rolls over (draft-ietf-httpapi-ratelimit-headers-08 and later).","schema":{"type":"string"}},"RateLimit-Policy":{"description":"Structured Field List naming every ceiling, in the same order: `\"day\";q=30;w=86400, \"month\";q=200;w=2678400`. `q` is the quota, `w` the window in seconds. A monthly window is the length of that UTC month, not an average. Read every member: the first alone hides the other ceiling.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"The bucket's quota. The -07 and earlier shape of the same draft.","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Quota left after this request. Zero on a 429.","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the bucket rolls over.","schema":{"type":"integer"}}}},"503":{"description":"Account-wide daily scan budget exhausted. `resetAt` and `retryAfterSec` give the exact UTC midnight the caller may return.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"Retry-After":{"description":"Seconds until the budget resets.","schema":{"type":"integer"}},"RateLimit":{"description":"Structured Field List, one member per bucket the caller is metered on: `\"day\";r=29;t=37772, \"month\";r=199;t=1209600`. `r` is the quota left after this request, `t` the seconds until that bucket rolls over (draft-ietf-httpapi-ratelimit-headers-08 and later).","schema":{"type":"string"}},"RateLimit-Policy":{"description":"Structured Field List naming every ceiling, in the same order: `\"day\";q=30;w=86400, \"month\";q=200;w=2678400`. `q` is the quota, `w` the window in seconds. A monthly window is the length of that UTC month, not an average. Read every member: the first alone hides the other ceiling.","schema":{"type":"string"}},"RateLimit-Limit":{"description":"The bucket's quota. The -07 and earlier shape of the same draft.","schema":{"type":"integer"}},"RateLimit-Remaining":{"description":"Quota left after this request. Zero on a 429.","schema":{"type":"integer"}},"RateLimit-Reset":{"description":"Seconds until the bucket rolls over.","schema":{"type":"integer"}}}}},"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]}},"/scan/{id}":{"get":{"operationId":"getScan","summary":"Fetch a scan report","description":"Returns the finished report, filtered for the viewer. A caller with no account keeps 1 recommendation in full and the rest locked by title; a free account keeps 3 recommendations, and a paid plan keeps every one. Send the `sid` cookie or a bearer API key to be recognised. With `Accept: text/markdown` the finished report is returned as one Markdown document instead of JSON — every check, score, and recommendation in a single agent-readable file.","tags":["Scans"],"security":[{"cookieAuth":[]},{"bearerAuth":[]},{}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Finished report, or pending status while the scan runs.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ScanReport"},{"$ref":"#/components/schemas/ScanPending"}]}},"text/markdown":{"schema":{"type":"string","description":"The whole gated report rendered as one Markdown document. Returned when the request prefers text/markdown via Accept."}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown scan id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Scan failed or report missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/scan/{id}/events":{"get":{"operationId":"streamScanEvents","summary":"Stream scan lifecycle events (SSE)","description":"Server-Sent Events stream of `scan` events until the scan reaches a terminal state (done or error). Served by a Durable Object hub with a D1-polling fallback.","tags":["Scans"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"SSE stream; each `scan` event's data is a ScanLifecycleEvent.","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/ScanLifecycleEvent"}}}}}}},"/compare":{"get":{"operationId":"compareScans","summary":"Compare two scans of one URL","description":"What changed between two finished scans of the same URL: score and per-category deltas, every check whose verdict flipped, and recommendations split into resolved, new and still open. Name the pair with from (the baseline scan) and to (the newer one), or pass url to compare that page's two newest stored scans — one form or the other. With `Accept: text/markdown` the same comparison comes back as one Markdown section. Filtered for the viewer's plan like GET /api/v1/scan/{id}: fix titles beyond the free allowance are withheld from free-plan viewers.","tags":["Scans"],"security":[{"cookieAuth":[]},{"bearerAuth":[]},{}],"parameters":[{"name":"from","in":"query","description":"Baseline scan id — the 'before' side. Use with to.","schema":{"type":"string"}},{"name":"to","in":"query","description":"Newer scan id — the 'after' side. Use with from.","schema":{"type":"string"}},{"name":"url","in":"query","description":"Compare the two newest finished scans of this URL instead of naming ids. Bare domains get https:// prefixed, as on POST /api/v1/scan.","schema":{"type":"string"}}],"responses":{"200":{"description":"The comparison, as JSON or as one Markdown section.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanComparison"}},"text/markdown":{"schema":{"type":"string","description":"The same comparison rendered as one Markdown section, starting at H2."}}}},"400":{"description":"Neither form given (or both), a malformed scan id or non-HTTPS url, a named scan is not finished, or the two scans are for different URLs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown scan id, or fewer than two finished scans stored for the URL.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"A named scan finished but stored no report.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/scan/{id}/ai-instructions":{"get":{"operationId":"getScanAiInstructions","summary":"Fetch the report as a ready-to-paste AI agent prompt","description":"The finished report wrapped in instructions for a coding or content agent: apply the fixes, in the order the report lists them, and how to verify. Plain Markdown, gated for the viewer's plan exactly like GET /api/v1/scan/{id}.","tags":["Scans"],"security":[{"cookieAuth":[]},{"bearerAuth":[]},{}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The prompt plus the full Markdown report.","content":{"text/markdown":{"schema":{"type":"string"}}}},"400":{"description":"Malformed scan id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown scan id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The scan has not produced a report (pending or failed).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/history":{"get":{"operationId":"listScanHistory","summary":"List the account's scanned URLs","description":"One entry per URL (deduplicated by URL hash): the most recent scan plus how many versions exist. Newest first, paged with cursor and limit; next_cursor is null on the last page.","tags":["History"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"cursor","in":"query","description":"Opaque keyset cursor from a previous page's next_cursor. Omit for the first page.","schema":{"type":"string"}},{"name":"limit","in":"query","description":"Max rows to return. Defaults to the maximum.","schema":{"type":"integer","minimum":1,"maximum":200}}],"responses":{"200":{"description":"Scanned URLs, newest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HistoryList"}}}},"400":{"description":"Cursor does not decode, or limit is out of range.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"Scan history needs the Pro or Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/history/{urlHash}":{"get":{"operationId":"listScanHistoryVersions","summary":"List every scan of one URL","description":"Newest first, paged with cursor and limit; the history list's `versions` field states the true total. next_cursor is null on the last page.","tags":["History"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"urlHash","in":"path","required":true,"description":"SHA-256 hex of the scanned URL, as returned by the history list.","schema":{"type":"string","pattern":"^[0-9a-f]{64}$"}},{"name":"cursor","in":"query","description":"Opaque keyset cursor from a previous page's next_cursor. Omit for the first page.","schema":{"type":"string"}},{"name":"limit","in":"query","description":"Max rows to return. Defaults to the maximum.","schema":{"type":"integer","minimum":1,"maximum":200}}],"responses":{"200":{"description":"Scans of the URL, newest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HistoryVersionList"}}}},"400":{"description":"Cursor does not decode, or limit is out of range.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"Scan history needs the Pro or Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No scans of that URL for this account, or the hash is not 64 hex characters. A well-formed hash sent with a cursor answers 200 and an empty page instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"deleteScanHistoryGroup","summary":"Delete every scan of one URL","description":"Deletes all scan records and stored HTML snapshots for the URL. Not plan-gated: deleting your own stored scans survives a downgrade.","tags":["History"],"security":[{"cookieAuth":[]}],"parameters":[{"name":"urlHash","in":"path","required":true,"description":"SHA-256 hex of the scanned URL, as returned by the history list.","schema":{"type":"string","pattern":"^[0-9a-f]{64}$"}}],"responses":{"200":{"description":"Scans deleted.","content":{"application/json":{"schema":{"type":"object","required":["ok","deleted"],"properties":{"ok":{"type":"boolean"},"deleted":{"type":"integer"}}}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No scans of that URL for this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"A scan of the URL is still running.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/history/scans/{id}":{"delete":{"operationId":"deleteScanHistoryEntry","summary":"Delete one scan from the history","description":"Deletes a single scan record and its stored HTML snapshot. Not plan-gated: deleting your own stored scans survives a downgrade.","tags":["History"],"security":[{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Scan deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such scan for this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The scan is still running.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/auth/request-link":{"post":{"operationId":"requestLoginLink","summary":"Request a magic sign-in link","description":"Emails a one-time link to the address, and creates the account on first use. This is the only way in: an agent cannot finish the flow, because the link arrives by email and a person must open it. That person then creates the `bq_…` API key on /account. Metered per address per hour, not against the scan quota.","tags":["Auth"],"requestBody":{"description":"Address to send the link to.","required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email"}}}}}},"responses":{"200":{"description":"Link sent (or returned directly in local development).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestLinkResult"}}}},"400":{"description":"Implausible email address.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Too many links requested for this address. `limit`, `resetAt` and `retryAfterSec` say when the address may ask again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"Retry-After":{"description":"Seconds until the hourly bucket rolls over.","schema":{"type":"integer"}}}}}}},"/auth/callback":{"post":{"operationId":"completeLogin","summary":"Complete magic-link sign-in","description":"Consumes the token the confirm page read from its query string, sets the `sid` session cookie, and destroys any session already on the request. GET is not supported on this path on purpose — see /login/confirm, which never touches the token.","tags":["Auth"],"requestBody":{"description":"Token read from the confirm page's query string.","required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string"}}}}}},"responses":{"200":{"description":"Session cookie set.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"token_invalid (missing or never issued), token_expired, token_used, or cross_site_request (Sec-Fetch-Site or Origin named another site).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}}}}},"/auth/logout":{"post":{"operationId":"logout","summary":"Sign out","description":"Destroys the session the `sid` cookie names and clears the cookie. It needs no credential and answers 200 whether or not a session was there. It does nothing to an API key: revoke one with DELETE /keys/{id}.","tags":["Auth"],"responses":{"200":{"description":"Session destroyed; cookie cleared.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}}}}},"/auth/me":{"get":{"operationId":"getCurrentUser","summary":"Current session","description":"Anonymous callers get `{ authenticated: false }` rather than 401.","tags":["Auth"],"security":[{"cookieAuth":[]},{"bearerAuth":[]},{}],"responses":{"200":{"description":"Viewer identity, plan, and limits.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthMe"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/billing/checkout":{"post":{"operationId":"createCheckoutSession","summary":"Start a Stripe checkout","description":"Creates a Stripe-hosted checkout session for the Pro or Agency plan and returns its URL. A person opens that URL and pays; the plan starts when Stripe confirms. Session cookie only — a `bq_…` API key is refused here, so no agent can spend money on the account.","tags":["Billing"],"security":[{"cookieAuth":[]}],"requestBody":{"description":"Plan selection.","required":true,"content":{"application/json":{"schema":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string","enum":["pro","agency"]},"interval":{"type":"string","enum":["monthly","yearly"],"description":"Defaults to monthly; yearly exists for pro only."}}}}}},"responses":{"200":{"description":"Hosted checkout session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutSession"}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"Invalid plan selection. Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Stripe could not complete the request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Billing not configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]}},"/billing/portal":{"post":{"operationId":"createPortalSession","summary":"Open the Stripe customer portal","description":"Creates a Stripe-hosted portal session and returns its URL, where a person changes the plan, updates the payment method, or cancels. It needs a billing profile, so an account that never bought anything answers 400. Session cookie only, for the same reason checkout is.","tags":["Billing"],"security":[{"cookieAuth":[]}],"responses":{"200":{"description":"Hosted portal session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutSession"}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"No billing profile yet. Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Stripe could not complete the request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Billing not configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]}},"/stripe/webhook":{"post":{"operationId":"handleStripeWebhook","summary":"Stripe event webhook","description":"Called by Stripe, not by API consumers. Authenticated via the `stripe-signature` header; duplicate event ids are acknowledged without reprocessing.","tags":["Stripe"],"parameters":[{"name":"stripe-signature","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"description":"Raw Stripe event payload (verified against the signature).","content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"Event processed or deduplicated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StripeWebhookAck"}}}},"400":{"description":"Signature verification failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Handler error — Stripe should retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Billing not configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/monitors":{"get":{"operationId":"listMonitors","summary":"List active monitors","description":"An API key needs the `read` scope.","tags":["Monitors"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Active monitors plus the plan's allowance.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MonitorList"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createMonitor","summary":"Add a monitored URL","description":"An API key needs the `monitors` scope.","tags":["Monitors"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"requestBody":{"description":"HTTPS URL to monitor weekly.","required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string"},"trackingConsent":{"type":"object","required":["engines"],"properties":{"engines":{"type":"array","minItems":1,"items":{"type":"string","enum":["perplexity","openai","openrouter"]}}},"description":"Optional. Allows AI answer tracking for this URL, engine by engine. A session only — an API key that sends this is refused with 403."}}}}}},"responses":{"201":{"description":"Monitor created (or re-activated).","content":{"application/json":{"schema":{"type":"object","required":["monitor"],"properties":{"monitor":{"$ref":"#/components/schemas/Monitor"}}}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"Invalid HTTPS URL, or an engine no entry names. Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"Plan has no monitoring, or the monitor limit is reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the `monitors` scope, or it sent trackingConsent — consent needs an account session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]}},"/monitors/{id}":{"delete":{"operationId":"deleteMonitor","summary":"Deactivate a monitor","description":"An API key needs the `monitors` scope.","tags":["Monitors"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Monitor deactivated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such monitor for this user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/monitors/{id}/tracking-consent":{"post":{"operationId":"grantTrackingConsent","summary":"Allow AI answer tracking for a monitored URL","description":"Records the engines, the time and the consenting IP address. An account session only: an API key cannot consent for a person and is refused with 403.","tags":["Monitors"],"security":[{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}],"requestBody":{"description":"Engines this URL may be tracked in.","required":true,"content":{"application/json":{"schema":{"type":"object","required":["engines"],"properties":{"engines":{"type":"array","minItems":1,"items":{"type":"string","enum":["perplexity","openai","openrouter"]}}}}}}},"responses":{"200":{"description":"Consent recorded.","content":{"application/json":{"schema":{"type":"object","required":["monitor"],"properties":{"monitor":{"$ref":"#/components/schemas/Monitor"}}}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"No engine named, or an engine no entry names. Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"API keys cannot use this route.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such monitor for this user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"withdrawTrackingConsent","summary":"Withdraw AI answer tracking consent","description":"Clears the consent record. The next weekly run skips this URL. An account session only, like granting it.","tags":["Monitors"],"security":[{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Consent withdrawn.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"API keys cannot use this route.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such monitor for this user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/webhooks":{"get":{"operationId":"getWebhookEndpoint","summary":"Get the configured webhook endpoint","tags":["Webhooks"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"The endpoint, or null when none is configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointEnvelope"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"Webhook callbacks need the Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"description":"Works with the session cookie or a bearer API key. The `webhooks` scope is not offered at key creation, so a key created since scopes shipped answers 403 insufficient_scope here; a key issued before scopes existed carries `read`, `scan`, `monitors`, `webhooks` and `billing`, so it still reaches these routes."},"post":{"operationId":"upsertWebhookEndpoint","summary":"Create or replace the webhook endpoint","tags":["Webhooks"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"requestBody":{"description":"Public HTTPS URL to deliver weekly monitor results to.","required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string"}}}}}},"responses":{"200":{"description":"Endpoint URL updated (secret kept).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointEnvelope"}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"201":{"description":"Endpoint created with a fresh secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointEnvelope"}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"URL is not public HTTPS. Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"Webhook callbacks need the Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"description":"Works with the session cookie or a bearer API key. The `webhooks` scope is not offered at key creation, so a key created since scopes shipped answers 403 insufficient_scope here; a key issued before scopes existed carries `read`, `scan`, `monitors`, `webhooks` and `billing`, so it still reaches these routes.","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]},"delete":{"operationId":"deleteWebhookEndpoint","summary":"Delete the webhook endpoint","tags":["Webhooks"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Endpoint deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"Webhook callbacks need the Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No webhook endpoint configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"description":"Works with the session cookie or a bearer API key. The `webhooks` scope is not offered at key creation, so a key created since scopes shipped answers 403 insufficient_scope here; a key issued before scopes existed carries `read`, `scan`, `monitors`, `webhooks` and `billing`, so it still reaches these routes."}},"/webhooks/rotate-secret":{"post":{"operationId":"rotateWebhookSecret","summary":"Rotate the signing secret","tags":["Webhooks"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Endpoint with the new secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointEnvelope"}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"Webhook callbacks need the Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No webhook endpoint configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"description":"Works with the session cookie or a bearer API key. The `webhooks` scope is not offered at key creation, so a key created since scopes shipped answers 403 insufficient_scope here; a key issued before scopes existed carries `read`, `scan`, `monitors`, `webhooks` and `billing`, so it still reaches these routes.","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]}},"/webhooks/enable":{"post":{"operationId":"enableWebhookEndpoint","summary":"Re-enable deliveries","tags":["Webhooks"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Endpoint re-enabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointEnvelope"}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"Webhook callbacks need the Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No webhook endpoint configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"description":"Works with the session cookie or a bearer API key. The `webhooks` scope is not offered at key creation, so a key created since scopes shipped answers 403 insufficient_scope here; a key issued before scopes existed carries `read`, `scan`, `monitors`, `webhooks` and `billing`, so it still reaches these routes.","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]}},"/webhooks/disable":{"post":{"operationId":"disableWebhookEndpoint","summary":"Pause deliveries","tags":["Webhooks"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Endpoint disabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointEnvelope"}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"Webhook callbacks need the Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No webhook endpoint configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"description":"Works with the session cookie or a bearer API key. The `webhooks` scope is not offered at key creation, so a key created since scopes shipped answers 403 insufficient_scope here; a key issued before scopes existed carries `read`, `scan`, `monitors`, `webhooks` and `billing`, so it still reaches these routes.","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]}},"/webhooks/test":{"post":{"operationId":"testWebhookEndpoint","summary":"Send a signed sample delivery","tags":["Webhooks"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"description":"Delivers a recognizably synthetic payload shaped exactly like a weekly result and reports the endpoint's response. Limited to 5 per hour.\n\nWorks with the session cookie or a bearer API key. The `webhooks` scope is not offered at key creation, so a key created since scopes shipped answers 403 insufficient_scope here; a key issued before scopes existed carries `read`, `scan`, `monitors`, `webhooks` and `billing`, so it still reaches these routes.","responses":{"200":{"description":"Delivery attempted; result of the attempt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookTestResult"}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"Webhook callbacks need the Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No webhook endpoint configured.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Too many test deliveries this hour.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]}},"/keys":{"get":{"operationId":"listApiKeys","summary":"List active API keys","description":"Metadata only — the token itself is returned exactly once, by POST. Key management requires the session cookie; a bearer key cannot manage keys.","tags":["Account"],"security":[{"cookieAuth":[]}],"responses":{"200":{"description":"Active keys for the account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyList"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"API keys need the Pro or Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createApiKey","summary":"Create an API key","description":"Returns the `bq_…` bearer token exactly once. Store it — only its hash is kept. At most 5 keys you create yourself; connected apps do not count against it.","tags":["Account"],"security":[{"cookieAuth":[]}],"requestBody":{"description":"Optional display name and scopes for the key.","required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"scopes":{"type":"array","description":"Non-empty subset of read, scan, monitors; must include read (every readable route sits behind it, so a read-less key would be refused everywhere). Defaults to read and scan. A key's scopes are fixed at creation; to change them, create a new key.","items":{"type":"string","enum":["read","scan","monitors"]}}}}}}},"responses":{"201":{"description":"Key created; token shown this once.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyCreated"}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"API keys need the Pro or Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Invalid name or scopes, or the 5-key limit is reached. This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]}},"/keys/{id}":{"delete":{"operationId":"revokeApiKey","summary":"Revoke an API key","description":"Revokes one `bq_…` key immediately and for good. Every later call carrying it answers 401 invalid_api_key, and nothing is issued in its place — create the replacement on /account first. Session cookie only: a key cannot revoke itself or any other.","tags":["Account"],"security":[{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Key revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"402":{"description":"API keys need the Pro or Agency plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such active key for this account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/account/usage":{"get":{"operationId":"getAccountUsage","summary":"Plan, limits, and current usage","description":"Works with the session cookie or a bearer API key. A presented but invalid/revoked key answers 401 invalid_api_key rather than degrading to anonymous.","tags":["Account"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Effective plan, its limits, and used scans.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountUsage"}}}},"401":{"description":"Not signed in, or the API key is invalid or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"403":{"description":"The API key does not carry the scope this route needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/notifications":{"get":{"operationId":"getNotificationPreferences","summary":"Read email notification preferences","description":"`all` is the master switch: when it is false no notification email is sent, whatever the per-type flags say. The sign-in link is not a notification and is never suppressed.","tags":["Account"],"security":[{"cookieAuth":[]}],"responses":{"200":{"description":"The three flags for the account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPreferences"}}}},"401":{"description":"Not signed in, or an API key was presented instead of a session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}}}},"post":{"operationId":"updateNotificationPreferences","summary":"Update email notification preferences","description":"A partial patch: only the flags named are written. Requires the session cookie; a bearer key is refused with `session_required`, because no grantable scope covers account settings.","tags":["Account"],"security":[{"cookieAuth":[]}],"requestBody":{"description":"At least one of the three flags.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPreferencesPatch"}}}},"responses":{"200":{"description":"The stored flags after the write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationPreferences"}}},"headers":{"Idempotency-Replayed":{"description":"`true` when this body is a replay of an earlier answer to the same Idempotency-Key, `false` when the request really ran. Absent when no key was sent.","schema":{"type":"string","enum":["true","false"]}}}},"400":{"description":"Malformed Idempotency-Key: not 1 to 255 visible ASCII characters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in, or an API key was presented instead of a session.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"headers":{"WWW-Authenticate":{"description":"RFC 9110 challenge. The `resource_metadata` parameter is RFC 9728 §5.1: it names the protected-resource metadata that states which credentials this API takes and where a person gets one. The prose walkthrough is /auth.md.","schema":{"type":"string","const":"Bearer resource_metadata=\"https://blockquote.io/.well-known/oauth-protected-resource\""}}}},"409":{"description":"An earlier request with this Idempotency-Key is still running. Retry in a few seconds to read its answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"No flag named, or a flag that is not a boolean. This Idempotency-Key was already used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional. A key of your own choosing that makes this write safe to retry. The first answer is stored for 24 hours and returned again — same status, same body, same Location — for every repeat of the same request with the same key. A replayed answer carries `Idempotency-Replayed: true`, a first answer `Idempotency-Replayed: false`. Only a 2xx is stored: a refused request releases the key, so a corrected retry may send the same one. The same key with a different body answers 422, and the same key while the first request still runs answers 409. Keys are private to the credential that sent them. Reordered JSON keys and whitespace do not make a body different. This is not the scan cache: a key replays your own earlier answer, and the cache returns a recent report for a URL to any caller.","schema":{"type":"string","pattern":"^[!-~]{1,255}$","examples":["3f8b1c2a-9d4e-4a71-8f0b-2c5e6a7d8901"]}}]}},"/unsubscribe":{"get":{"operationId":"unsubscribeConfirmPage","summary":"Ask whether to turn all notification email off","description":"The Unsubscribe link in every notification email footer. No session and no API key: the recipient may not be signed in on the device that opens the mail. This route reads no table and writes nothing. It answers a page with a Confirm button that posts back to the same path, so a mail gateway that prefetches every link cannot unsubscribe anyone. The page is the same whether the token is live, spent or malformed.","tags":["Account"],"security":[{}],"parameters":[{"name":"token","in":"query","required":true,"description":"The single-use token from the email footer.","schema":{"type":"string"}}],"responses":{"200":{"description":"HTML page with the Confirm button. Same body for any token.","content":{"text/html":{"schema":{"type":"string"}}}}}},"post":{"operationId":"unsubscribe","summary":"Turn all notification email off","description":"Spends the token and turns the master switch off for its user. The per-type flags keep their stored values; the master switch suppresses every notification email whatever they say. The token is single-use and grants exactly this one write; it reads nothing, so a forwarded email cannot become account access. An unknown, spent or malformed token answers the same page with status 200, so the route cannot be used to test guessed tokens.","tags":["Account"],"security":[{}],"parameters":[{"name":"token","in":"query","required":false,"description":"The token, when it is not sent as a form field.","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"The single-use token."}}}}}},"responses":{"200":{"description":"HTML result page. Same status whether the token worked.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/openapi.json":{"get":{"operationId":"getOpenApiDocument","summary":"This OpenAPI document","description":"Serves this document, which is the file to generate a client from. Anonymous, cached five minutes, and free of every quota. Its `servers` entry names the versioned base, so a generated client calls the current version and never the superseded alias.","tags":["Documentation"],"responses":{"200":{"description":"The OpenAPI 3.1 description of this API.","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/docs":{"get":{"operationId":"getApiDocs","summary":"Interactive API reference","description":"Serves the reference UI a person reads, rendered from this document. An agent should fetch openapi.json instead: the same content, without the HTML. Anonymous and free of every quota.","tags":["Documentation"],"responses":{"200":{"description":"HTML reference UI rendering /api/v1/openapi.json.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/batch":{"get":{"operationId":"batchReadScans","summary":"Read up to ten stored scan reports in one request","description":"Supply comma-separated scan IDs. Each read uses the caller's session or bearer key and the normal report plan filter. Results preserve input order and duplicates. Each result contains its own status, headers, and JSON body, including errors. The outer response is 200 for a valid batch, even when individual reads fail. No scans are started. JSON only.","tags":["Scans"],"security":[{"cookieAuth":[]},{"bearerAuth":[]},{}],"parameters":[{"name":"ids","in":"query","required":true,"description":"One to ten comma-separated scan IDs. Each ID has 1 to 32 lowercase letters or digits.","schema":{"type":"string","pattern":"^[a-z0-9]{1,32}(,[a-z0-9]{1,32}){0,9}$"}}],"responses":{"200":{"description":"Per-report results. Authentication and read failures appear in each item's status and body.","content":{"application/json":{"schema":{"type":"object","required":["results"],"properties":{"results":{"type":"array","items":{"type":"object","required":["id","status","headers","body"],"properties":{"id":{"type":"string"},"status":{"type":"integer"},"headers":{"type":"object","additionalProperties":{"type":"string"}},"body":{"type":"object","description":"The report route's JSON response, including pending states or errors."}}}}}}}}},"400":{"description":"Invalid or oversized ID list. No reads are performed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/openapi.json.md":{"get":{"operationId":"getOpenApiMarkdown","summary":"Read the OpenAPI contract as Markdown","description":"Contains the complete current OpenAPI JSON contract with document metadata.","tags":["Documentation"],"responses":{"200":{"description":"Markdown with document metadata and canonical links.","content":{"text/markdown":{"schema":{"type":"string"}}}}}}},"/sandbox.md":{"get":{"operationId":"getSandboxMarkdown","summary":"Read the isolated sandbox guide","description":"Lists executable synthetic fixture URLs and the boundaries of the read-only sandbox.","tags":["Documentation"],"responses":{"200":{"description":"Markdown with document metadata and canonical links.","content":{"text/markdown":{"schema":{"type":"string"}}}}}}},"/sandbox/scan/sandbox-example.md":{"get":{"operationId":"getSandboxReportMarkdown","summary":"Read the synthetic sandbox report as Markdown","description":"Contains the same synthetic JSON fixture as the report endpoint. No URL was scanned.","tags":["Documentation"],"responses":{"200":{"description":"Markdown with document metadata and canonical links.","content":{"text/markdown":{"schema":{"type":"string"}}}}}}},"/sandbox/batch.md":{"get":{"operationId":"getSandboxBatchMarkdown","summary":"Read sandbox batch usage and error documentation","description":"Documents the required ids parameter and an executable fixture example. This page does not execute a batch.","tags":["Documentation"],"responses":{"200":{"description":"Markdown with document metadata and canonical links.","content":{"text/markdown":{"schema":{"type":"string"}}}}}}},"/sandbox":{"get":{"operationId":"getSandbox","summary":"Discover isolated synthetic report fixtures","description":"Lists fixture IDs for client tests. No account, quota, or production data is used.","tags":["Documentation"],"responses":{"200":{"description":"Fixture IDs. No account, quota, or production data.","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/sandbox/scan/{id}":{"get":{"operationId":"getSandboxScan","summary":"Read a synthetic report fixture","description":"Returns a fixed synthetic ScanReport example. It was not fetched or analyzed.","tags":["Documentation"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","enum":["sandbox-example"]}}],"responses":{"200":{"description":"Synthetic report with sandbox: true. This is not a live scan result.","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"Unknown fixture ID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/sandbox/batch":{"get":{"operationId":"getSandboxBatch","summary":"Read up to ten synthetic report fixtures in one call","description":"Reads fixture reports in input order. Unknown IDs fail the whole batch; no production calls occur.","tags":["Documentation"],"parameters":[{"name":"ids","in":"query","required":true,"description":"One to ten comma-separated fixture IDs. Order and duplicates are preserved.","schema":{"type":"string"}}],"responses":{"200":{"description":"Synthetic reports with sandbox: true. No production calls.","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"Invalid or oversized ID list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown fixture ID. No partial results.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/docs.md":{"get":{"operationId":"getApiDocsMarkdown","summary":"Blockquote API reference in Markdown","description":"Every operation, derived from the OpenAPI document. Anonymous and free of quotas.","tags":["Documentation"],"responses":{"200":{"description":"Heading-led Markdown with links to schemas and authentication.","content":{"text/markdown":{"schema":{"type":"string"}}}}}}},"/llms.txt":{"get":{"operationId":"getApiLlmsTxt","summary":"Scoped llms.txt for the API section","description":"Serves a short plain-text map of this API — the interfaces, the calls an agent makes most, and how authentication works — for a reader that wants the shape before parsing the schema. Anonymous and free of every quota.","tags":["Documentation"],"responses":{"200":{"description":"Plain-text index of the API surface, for an agent that wants context for this section only. The whole-site index is https://blockquote.io/llms.txt.","content":{"text/plain":{"schema":{"type":"string"}}}}}}}},"components":{"schemas":{"Error":{"type":"object","description":"Uniform error envelope produced by handlers and the error middleware.","required":["error"],"properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Human-readable explanation."},"docs":{"type":"string","description":"Path to the API reference. Present only on the 404 for a path that matches no route."},"agent":{"$ref":"#/components/schemas/AgentAccess","description":"Present on refusals a caller without a browser cannot satisfy. See AgentAccess."},"resetAt":{"type":"string","format":"date-time","description":"When the refusing counter rolls over. Present on the 503 of POST /api/scan and the 429 of POST /api/auth/request-link; the scan 429 declares its own in ScanLimit."},"retryAfterSec":{"type":"integer","description":"Seconds until resetAt, mirroring the Retry-After header."},"limit":{"type":"integer","description":"The ceiling that refused the request. Present on POST /api/auth/request-link."}}},"AgentAccess":{"type":"object","description":"How a caller with no browser reaches the same result. A Turnstile challenge and a sign-in page are actions only a human has, so every refusal built on one carries this block. The same two pointers arrive as RFC 8288 Link headers: rel=\"alternate\" for the MCP endpoint and rel=\"help\" for the documentation.","required":["mcp","transport","apiKeys","authorization","docs"],"properties":{"mcp":{"type":"string","description":"Remote MCP endpoint that answers this request without a browser.","examples":["https://mcp.blockquote.io/mcp"]},"transport":{"type":"string","const":"streamable-http"},"tool":{"type":"string","description":"MCP tool that does what the refused route does.","examples":["start_scan"]},"apiKeys":{"type":"string","description":"Page where the account owner creates a bq_ key (Pro and Agency plans)."},"authorization":{"type":"string","description":"The header form an API key travels in.","examples":["Bearer bq_…"]},"docs":{"type":"string","description":"Page that explains both paths end to end."},"retryWithout":{"type":"string","enum":["authorization"],"description":"Header the caller must omit for `mcp` to answer this request. Present only when the credential presented is what the refusal is about, so relaying the same request over MCP would repeat it. Absent means the pointer works as-is."}}},"Ok":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}},"CheckCategory":{"type":"string","enum":["schema","structure","citability"],"description":"Scoring category. Weights: schema 40%, structure 30%, citability 30%."},"PageType":{"type":"string","enum":["content","legal","transactional","navigational","other"],"description":"Deterministic page-type classification. Drives schema-article applicability, N/A wording, and the citability LLM rubric."},"Fix":{"type":"object","required":["kind","title","payload"],"properties":{"kind":{"type":"string","enum":["jsonld","content"]},"title":{"type":"string","description":"Short label shown above the snippet."},"payload":{"type":"string","description":"Code or copy block the user can paste."},"guidance":{"type":"string","description":"One-sentence hint where to put it."}}},"CheckResult":{"type":"object","required":["id","category","weight","pass","score","evidence"],"properties":{"id":{"type":"string","description":"Stable check identifier, e.g. schema-jsonld-present."},"category":{"$ref":"#/components/schemas/CheckCategory"},"weight":{"type":"integer","minimum":1,"maximum":5},"pass":{"type":"boolean"},"score":{"type":"number","minimum":0,"maximum":1},"evidence":{"type":"string","description":"What was found / not found."},"fix":{"$ref":"#/components/schemas/Fix"},"notApplicable":{"type":"boolean","description":"True when the check did not apply."}}},"CategoryScore":{"type":"object","required":["category","score","weightSum"],"properties":{"category":{"$ref":"#/components/schemas/CheckCategory"},"score":{"type":"number","minimum":0,"maximum":100},"weightSum":{"type":"number","description":"Sum of contributing check weights."}}},"Recommendation":{"type":"object","description":"Prioritized fix. Beyond the free allowance, entries are gated for free-plan viewers: locked=true and the fix payload is withheld.","required":["checkId","category","priority","summary"],"properties":{"checkId":{"type":"string"},"category":{"$ref":"#/components/schemas/CheckCategory"},"priority":{"type":"integer","description":"Larger number = higher priority."},"recoverablePoints":{"type":"integer","description":"Overall-score points this fix would recover if applied cleanly. Absent when not computable."},"summary":{"type":"string"},"fix":{"$ref":"#/components/schemas/Fix"},"locked":{"type":"boolean","description":"True when fix content was withheld by plan."}}},"ScanReport":{"type":"object","description":"Finished scan report, filtered for what the viewer's plan may see.","required":["scanId","url","finalUrl","fetchedAt","score","categories","checks","recommendations","meta","gated"],"properties":{"scanId":{"type":"string"},"url":{"type":"string","format":"uri"},"finalUrl":{"type":"string","format":"uri","description":"URL after redirects."},"fetchedAt":{"type":"integer","description":"Unix epoch milliseconds."},"reportSchemaVersion":{"type":"integer"},"scanTier":{"type":"string","enum":["free","paid"]},"partial":{"type":"boolean","description":"True when only part of the response body could be analyzed."},"score":{"type":"number","minimum":0,"maximum":100},"categories":{"type":"array","items":{"$ref":"#/components/schemas/CategoryScore"}},"checks":{"type":"array","items":{"$ref":"#/components/schemas/CheckResult"}},"recommendations":{"type":"array","items":{"$ref":"#/components/schemas/Recommendation"}},"meta":{"type":"object","required":["title","description","contentType","bytes","wordCount"],"properties":{"title":{"type":["string","null"]},"description":{"type":["string","null"]},"contentType":{"type":"string"},"bytes":{"type":"integer"},"observedBytes":{"type":"integer"},"wordCount":{"type":"integer"},"truncated":{"type":"boolean"},"truncationReason":{"type":"string"}}},"pageType":{"$ref":"#/components/schemas/PageType"},"pageTypeSignal":{"type":"string","description":"Signals that produced pageType, joined with \"; \", for audit."},"gated":{"type":"boolean","description":"True when fix content was withheld by plan."}}},"ScanAccepted":{"type":"object","required":["id","status","cached"],"properties":{"id":{"type":"string","description":"Scan id; poll GET /api/v1/scan/{id} with it."},"status":{"type":"string","enum":["pending","done"]},"cached":{"type":"boolean","description":"True when a fresh cached report was reused."},"scannedAt":{"type":"integer","description":"Epoch ms the cached scan started — the anchor the cache window is measured from, so scannedAt + SCAN_CACHE_TTL_MS is when the entry stops being served. Present only on a cache hit; send refresh true to scan the page again instead."},"queued":{"type":"boolean","description":"Present (true) when a new scan was dispatched."}}},"ScanPending":{"type":"object","required":["id","status"],"properties":{"id":{"type":"string"},"status":{"type":"string","enum":["pending"]}}},"ComparedScan":{"type":"object","required":["scanId","url","finalUrl","scannedAt","score","reportSchemaVersion","pageType","pageTypeSignal"],"properties":{"scanId":{"type":"string"},"url":{"type":"string","format":"uri"},"finalUrl":{"type":"string","format":"uri"},"scannedAt":{"type":"string","format":"date-time"},"score":{"type":"number","minimum":0,"maximum":100},"reportSchemaVersion":{"type":["integer","null"]},"pageType":{"type":["string","null"],"enum":["content","legal","transactional","navigational","other",null]},"pageTypeSignal":{"type":["string","null"]}}},"ComparedRecommendation":{"type":"object","required":["checkId","category","priority","summary"],"properties":{"checkId":{"type":"string"},"category":{"$ref":"#/components/schemas/CheckCategory"},"priority":{"type":"integer"},"recoverablePoints":{"type":"integer","description":"Overall-score points this fix would recover if applied cleanly, and the number each list is ordered by. Absent when not computable."},"summary":{"type":"string"},"fixTitle":{"type":"string","description":"Omitted when the viewer's plan locked this recommendation's fix."},"locked":{"type":"boolean","description":"Present (true) when the viewer's plan withheld this recommendation's fix — a missing fixTitle with locked true means withheld, not that no fix exists."}}},"ScanComparison":{"type":"object","description":"What changed between two finished scans of one URL. Deltas are null when the two reports were produced under different reportSchemaVersions — they would measure the checks rather than the page.","required":["baseline","current","methodologyChanged","score","categories","checks","gated","recommendations"],"properties":{"baseline":{"$ref":"#/components/schemas/ComparedScan"},"current":{"$ref":"#/components/schemas/ComparedScan"},"methodologyChanged":{"type":"boolean"},"score":{"type":"object","required":["current","previous","delta"],"properties":{"current":{"type":"number"},"previous":{"type":["number","null"]},"delta":{"type":["number","null"]}}},"categories":{"type":"array","items":{"type":"object","required":["category","score","previous","delta"],"properties":{"category":{"$ref":"#/components/schemas/CheckCategory"},"score":{"type":"number"},"previous":{"type":["number","null"]},"delta":{"type":["number","null"]}}}},"checks":{"type":"object","required":["changed","unchanged"],"properties":{"changed":{"type":"array","items":{"type":"object","required":["checkId","category","from","to"],"properties":{"checkId":{"type":"string"},"category":{"$ref":"#/components/schemas/CheckCategory"},"from":{"type":["string","null"],"enum":["pass","warn","fail","na",null],"description":"Verdict in the baseline; null when the check was not run then."},"to":{"type":["string","null"],"enum":["pass","warn","fail","na",null],"description":"Verdict in the current report; null when it is no longer run."}}}},"unchanged":{"type":"integer","description":"Checks whose verdict did not move."}}},"gated":{"type":"boolean","description":"True when either side had fix content withheld for the viewer's plan — the same flag GET /api/v1/scan/{id} carries."},"recommendations":{"type":"object","required":["resolved","added","stillOpen"],"properties":{"resolved":{"type":"array","items":{"$ref":"#/components/schemas/ComparedRecommendation"}},"added":{"type":"array","items":{"$ref":"#/components/schemas/ComparedRecommendation"}},"stillOpen":{"type":"array","items":{"$ref":"#/components/schemas/ComparedRecommendation"}}}}}},"ScanLifecycleEvent":{"type":"object","description":"Payload of each `scan` SSE event on GET /api/v1/scan/{id}/events.","required":["scanId","status","timestamp"],"properties":{"scanId":{"type":"string"},"status":{"type":"string","enum":["queued","running","done","error"]},"timestamp":{"type":"integer","description":"Unix epoch milliseconds."},"url":{"type":"string","format":"uri"},"reportUrl":{"type":"string"},"error":{"type":"string"},"message":{"type":"string"}}},"PlanLimits":{"type":"object","required":["scansPerDay","scansPerMonth","monitors","fullFixList","pdfExport","whiteLabel","api","apiKeys","history"],"properties":{"scansPerDay":{"type":"integer"},"scansPerMonth":{"type":"integer"},"monitors":{"type":"integer"},"fullFixList":{"type":"boolean"},"pdfExport":{"type":"boolean"},"whiteLabel":{"type":"boolean"},"api":{"type":"boolean","description":"Outbound webhooks (Agency)."},"apiKeys":{"type":"boolean","description":"Bearer API keys for agents (Pro and up)."},"history":{"type":"boolean","description":"Account scan history and the before/after comparison (every plan)."}}},"ApiKey":{"type":"object","description":"Key metadata; the bearer token itself is only ever in ApiKeyCreated.","required":["id","name","keyPrefix","createdAt","lastUsedAt","scopes","source"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"keyPrefix":{"type":"string","description":"`bq_` plus the first 6 secret characters."},"createdAt":{"type":"integer","description":"Unix epoch milliseconds."},"lastUsedAt":{"type":["integer","null"],"description":"Unix epoch milliseconds."},"scopes":{"type":"array","description":"What this key may reach. Keys created before scopes existed report all five.","items":{"type":"string","enum":["read","scan","monitors","webhooks","billing"]}},"source":{"type":"string","enum":["manual","oauth"],"description":"`manual` — created on /account. `oauth` — an approved client's connection; its token is never shown. Revoking either uses the same DELETE."}}},"ApiKeyList":{"type":"object","required":["keys"],"properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}},"ApiKeyCreated":{"type":"object","required":["key","token"],"properties":{"key":{"$ref":"#/components/schemas/ApiKey"},"token":{"type":"string","description":"The full `bq_…` bearer token. Shown exactly once — store it now."}}},"AccountUsage":{"type":"object","required":["plan","limits","used"],"properties":{"plan":{"type":"string","enum":["free","pro","agency"]},"limits":{"$ref":"#/components/schemas/PlanLimits"},"used":{"type":"object","required":["day","month"],"properties":{"day":{"type":"integer","description":"Scans dispatched today (UTC)."},"month":{"type":"integer","description":"Scans dispatched this UTC calendar month."}}}}},"NotificationPreferences":{"type":"object","required":["notifications"],"properties":{"notifications":{"type":"object","required":["all","digest","webhookAlerts"],"properties":{"all":{"type":"boolean","description":"Master switch. False suppresses every notification email."},"digest":{"type":"boolean","description":"The weekly monitoring digest."},"webhookAlerts":{"type":"boolean","description":"The email sent once when a webhook endpoint is auto-disabled."}}}}},"NotificationPreferencesPatch":{"type":"object","description":"Every flag is optional; at least one must be present.","properties":{"all":{"type":"boolean"},"digest":{"type":"boolean"},"webhookAlerts":{"type":"boolean"}}},"ScanLimit":{"type":"object","description":"Scan refusal body. Additive over Error: `error` and `message` are unchanged, the remaining fields describe which counter refused and when it rolls over.","required":["error","message","scope","limit","used","resetAt","authenticated","plan","upgrade"],"properties":{"error":{"type":"string","enum":["quota_exceeded","rate_limited"]},"message":{"type":"string","description":"Human-readable explanation."},"scope":{"type":"string","enum":["day","month"],"description":"Which counter refused: the anonymous per-IP day/month bucket, or the account one."},"limit":{"type":"integer","description":"The ceiling that was reached."},"used":{"type":"integer","description":"Scans counted in the refusing window."},"resetAt":{"type":"string","format":"date-time","description":"UTC instant the window rolls over."},"authenticated":{"type":"boolean"},"plan":{"type":["string","null"],"enum":["free","pro","agency",null],"description":"The caller's effective plan; null for anonymous callers."},"upgrade":{"type":["string","null"],"enum":["signup","pro",null],"description":"Which action the product offers. null when there is nothing to sell — a paid account over its own ceiling."},"retryAfterSec":{"type":"integer","description":"Anonymous callers only; mirrors the Retry-After header."}}},"AuthMe":{"type":"object","required":["authenticated"],"properties":{"authenticated":{"type":"boolean"},"user":{"type":"object","required":["id","email","plan","planStatus","planRenewsAt"],"properties":{"id":{"type":"string"},"email":{"type":"string","format":"email"},"plan":{"type":"string","enum":["free","pro","agency"]},"planStatus":{"type":"string","enum":["none","active","past_due","canceled"]},"planRenewsAt":{"type":["integer","null"],"description":"Unix epoch milliseconds."}}},"limits":{"$ref":"#/components/schemas/PlanLimits"}}},"RequestLinkResult":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"},"message":{"type":"string"},"devLink":{"type":"string","description":"Sign-in link, returned instead of an email in local development only."}}},"CheckoutSession":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"Stripe-hosted session URL."}}},"StripeWebhookAck":{"type":"object","required":["received"],"properties":{"received":{"type":"boolean"},"duplicate":{"type":"boolean","description":"True when the event id was already processed."}}},"Monitor":{"type":"object","required":["id","user_id","url","url_hash","active","created_at"],"properties":{"id":{"type":"string"},"user_id":{"type":"string"},"url":{"type":"string","format":"uri"},"url_hash":{"type":"string"},"active":{"type":"integer","enum":[0,1]},"created_at":{"type":"integer","description":"Unix epoch milliseconds."},"last_scan_id":{"type":["string","null"]},"last_score":{"type":["number","null"]},"last_run_at":{"type":["integer","null"],"description":"Unix epoch milliseconds."},"tracking_consent_at":{"type":["integer","null"],"description":"When the account owner allowed AI answer tracking for this URL. Null means no consent."},"tracking_consent_engines":{"type":"array","items":{"type":"string","enum":["perplexity","openai","openrouter"]},"description":"Engines this URL is allowed to be tracked in. Empty when there is no consent."}}},"MonitorList":{"type":"object","required":["monitors","limit"],"properties":{"monitors":{"type":"array","items":{"$ref":"#/components/schemas/Monitor"}},"limit":{"type":"integer","description":"Monitor allowance of the viewer's plan."}}},"HistoryEntry":{"type":"object","description":"One scanned URL, deduplicated: the latest scan plus its version count.","required":["url","urlHash","lastScanId","lastStatus","lastScore","lastCreatedAt","versions","previousScore","delta"],"properties":{"url":{"type":"string","format":"uri"},"urlHash":{"type":"string","description":"SHA-256 of the URL; key for the version routes."},"lastScanId":{"type":"string"},"lastStatus":{"type":"string","enum":["pending","done","error"]},"lastScore":{"type":["number","null"]},"lastCreatedAt":{"type":"integer","description":"Unix epoch milliseconds."},"versions":{"type":"integer","description":"How many scans of this URL the account owns."},"previousScore":{"type":["number","null"],"description":"Score of the newest finished scan of this URL before lastScanId; null when the account has no earlier finished scan of it."},"delta":{"type":["number","null"],"description":"lastScore minus previousScore; null with no previous score, or across a reportSchemaVersion boundary."}}},"HistoryList":{"type":"object","required":["entries","next_cursor"],"properties":{"entries":{"type":"array","items":{"$ref":"#/components/schemas/HistoryEntry"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}}},"HistoryVersion":{"type":"object","description":"One historical scan of a URL.","required":["id","status","score","createdAt","finishedAt"],"properties":{"id":{"type":"string"},"status":{"type":"string","enum":["pending","done","error"]},"score":{"type":["number","null"]},"createdAt":{"type":"integer","description":"Unix epoch milliseconds."},"finishedAt":{"type":["integer","null"],"description":"Unix epoch milliseconds."}}},"HistoryVersionList":{"type":"object","required":["versions","next_cursor"],"properties":{"versions":{"type":"array","items":{"$ref":"#/components/schemas/HistoryVersion"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}}},"WebhookEndpoint":{"type":"object","required":["url","secret","active","consecutiveFailures","createdAt","lastDelivery"],"properties":{"url":{"type":"string","format":"uri"},"secret":{"type":"string","description":"HMAC signing secret; shown only to the authenticated owner."},"active":{"type":"boolean"},"consecutiveFailures":{"type":"integer"},"disabledAt":{"type":["integer","null"],"description":"Unix epoch milliseconds."},"disabledReason":{"type":["string","null"]},"createdAt":{"type":"integer","description":"Unix epoch milliseconds."},"lastDelivery":{"type":["object","null"],"properties":{"at":{"type":"integer","description":"Unix epoch milliseconds."},"status":{"type":["string","null"]},"code":{"type":["integer","null"]},"error":{"type":["string","null"]}}}}},"WebhookEndpointEnvelope":{"type":"object","required":["webhook"],"properties":{"webhook":{"oneOf":[{"$ref":"#/components/schemas/WebhookEndpoint"},{"type":"null"}]}}},"WebhookTestResult":{"type":"object","required":["ok","status"],"properties":{"ok":{"type":"boolean"},"status":{"type":["integer","null"],"description":"HTTP status the endpoint returned."},"error":{"type":["string","null"]}}},"Health":{"type":"object","required":["ok","env","time"],"properties":{"ok":{"type":"boolean"},"env":{"type":"string"},"time":{"type":"integer","description":"Unix epoch milliseconds."},"db":{"type":"string","enum":["ok","error"],"description":"Present with ?deep=1: D1 probe result."},"lastMonitoringRunAt":{"type":["integer","null"],"description":"Present with ?deep=1: last monitoring cron run (hourly due-check)."}}}},"securitySchemes":{"cookieAuth":{"type":"apiKey","in":"cookie","name":"sid","description":"Session cookie set by the magic-link callback (POST /api/v1/auth/callback)."},"bearerAuth":{"type":"http","scheme":"bearer","description":"API key (`bq_…`) created on /account or via POST /api/v1/keys — Pro and Agency plans. A key carries the scopes it was created with: read (reports and usage), scan (start scans), monitors (add and remove monitors). A route outside those scopes answers 403 insufficient_scope. Key management, billing, webhook configuration and scan-history deletion stay session-only."}}}}