{"openapi":"3.1.0","info":{"title":"Audience Intel API","version":"1.0.0","summary":"Market-scan reports, the truth-gate fact layer, review intelligence, validation studies and PECR-safe outreach — the same engine behind audienceintel.app, keyed and versioned.","description":"Everything lives under `/api/v1`. Within v1 changes are additive only — new fields, new endpoints, new output enum values; never renames, removals or new required inputs.\n\n**Auth** — every request (except this document) sends `Authorization: Bearer aik_live_…`. Keys are minted by the operator (enterprise, price-on-application — no self-serve signup) and carry explicit scopes plus a daily spend cap in pence.\n\n**One error envelope** — errors are `{ ok: false, error: { code, message, details? } }` with machine-stable codes: `unauthorized`, `forbidden_scope`, `spend_cap_exceeded`, `validation_failed`, `not_found`, `rate_limited`, `upstream_unavailable`, `run_failed`.\n\n**Honest degradation** — a missing upstream or dead dependency answers `{ ok: false, reason }` or a typed envelope, never an invented number, never a stack trace.\n\n**Provenance travels** — every fact carries `provenance` (`measured` | `inferred` | `modelled` | `supplied`), `confidence` and truth-gate `status` (`unverified` | `verified` | `corrected` | `rejected`). No endpoint strips these.\n\n**Spend** — operations marked `x-spend: true` are admitted against the key's daily cap BEFORE dispatch and refused with `spend_cap_exceeded` (cap, spent and estimate figures in `details`) when they would exceed it. `POST /scans/estimate` is the free pre-flight.\n\nA remote MCP server shares this auth at https://audienceintel.app/api/mcp (scope `mcp:connect`). Human documentation: https://audienceintel.app/developers","contact":{"name":"Audience Intel","email":"ian@cambraydesign.co.uk","url":"https://audienceintel.app/developers"}},"servers":[{"url":"https://audienceintel.app/api/v1","description":"Production"}],"security":[{"apiKey":[]}],"tags":[{"name":"Meta","description":"Spec, key smoke-test, counters and connector health."},{"name":"Reports","description":"Scan reports: summaries, payload blocks, audit trails and exports."},{"name":"Facts","description":"The truth-gate fact layer and its derivation graph — provenance always attached."},{"name":"Scans","description":"Durable full market-scan jobs: enqueue (spend), status, SSE events, free estimates."},{"name":"Prospect runs","description":"Full-surface prospect console runs (spend): enqueue a durable job, poll to completion."},{"name":"Buyer panel","description":"Synthetic buyer-panel studies (credit-metered): enqueue a run, poll for the aggregated result."},{"name":"Continuous assessment","description":"\"The Watch\": place a firm under recurring re-scan on a cadence and read the deltas between runs."},{"name":"Market Mirror","description":"Review-intelligence analyses (spend): gather sources, analyse, poll."},{"name":"Validation","description":"Real-money Prolific validation studies: draft free, publish is the spend step, calibrate free."},{"name":"Outreach","description":"PECR-gated teaser links, warm packs and eligibility checks. Fails closed."},{"name":"Friday","description":"Friday website-first reports: tracked companies and saved reports."},{"name":"Growth","description":"Growth-engine CRM: audience upserts, do-not-contact suppressions, funnel snapshot."},{"name":"Keys","description":"Key admin: mint, list, revoke. Operator keys act cross-client."},{"name":"Dev proxy","description":"Secretless local dev: forward scrape/LLM calls through the Fly proxy so a laptop needs no vendor key. Non-prod only."}],"paths":{"/openapi.json":{"get":{"operationId":"getOpenApi","summary":"This document. Public — no key required.","tags":["Meta"],"security":[],"x-required-scopes":[],"responses":{"200":{"description":"The OpenAPI 3.1 document.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/health":{"get":{"operationId":"getHealth","summary":"Key smoke-test: echoes the client name and granted scopes.","description":"Requires a valid key but no particular scope. Never returns hashes or the key itself.","tags":["Meta"],"security":[{"apiKey":[]}],"x-required-scopes":[],"responses":{"200":{"description":"Key is valid.","content":{"application/json":{"schema":{"type":"object","required":["ok","client","scopes"],"properties":{"ok":{"const":true},"client":{"type":"string"},"scopes":{"type":"array","items":{"$ref":"#/components/schemas/Scope"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/stats":{"get":{"operationId":"getStats","summary":"Live counters: Friday reports built, scan reports, scan jobs by status.","description":"Honest degradation: DB down answers HTTP 200 `{ ok:false, reason }` — never an invented count. `reportsBuilt` degrades independently to null.","tags":["Meta"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"responses":{"200":{"description":"Counters, or an honest ok:false.","content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["ok","scanReports","scanJobs"],"properties":{"ok":{"const":true},"reportsBuilt":{"type":["integer","null"]},"scanReports":{"type":"integer"},"scanJobs":{"type":"object","properties":{"queued":{"type":"integer"},"running":{"type":"integer"},"done":{"type":"integer"},"failed":{"type":"integer"}}}}},{"$ref":"#/components/schemas/HonestFailure"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/connectors/health":{"get":{"operationId":"getConnectorsHealth","summary":"Env-presence health for the full connector roster. Booleans only.","description":"Never a secret value, never throws. `false` means \"this connector's env is not configured on the deployment\", mirroring each connector's typed `not_configured` contract. `?probe=live` (rate-limited harder than the tier limit) additionally fires a minimal validity request for the cheap, safe connectors only — Companies House, DataForSEO, Places, PageSpeed — and reports `ok`/`invalid`/`timeout` per connector; expensive or stateful connectors stay presence-only with `\"probe\": \"unsupported\"`.","tags":["Meta"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"probe","in":"query","description":"`live` to fire the cheap validity probes. Any other value is a 400.","schema":{"type":"string","enum":["live"]}}],"responses":{"200":{"description":"Connector configuration flags.","content":{"application/json":{"schema":{"type":"object","required":["ok","connectors"],"properties":{"ok":{"const":true},"connectors":{"type":"object","additionalProperties":{"type":"boolean"},"description":"dataforseo, spyfu, companiesHouse, nomis, places, pagespeed, searchConsole, analytics, mapsGrid, questionCloud, zmot, externalForces, competitorProperties."},"probes":{"type":"object","description":"Present only with `?probe=live`. Per connector: `{ probe: \"live\", status, detail? }` for the probed four, `{ probe: \"unsupported\" }` for the rest. Never carries a secret.","additionalProperties":{"type":"object","required":["probe"],"properties":{"probe":{"type":"string","enum":["live","unsupported"]},"status":{"type":"string","enum":["ok","invalid","timeout","error","not_configured"]},"detail":{"type":"string"}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports":{"get":{"operationId":"listReports","summary":"List scan reports, newest first.","tags":["Reports"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"limit","in":"query","description":"Page size, 1–100 (default 20). Out-of-range values clamp.","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"offset","in":"query","description":"Rows to skip (default 0). Negative values clamp to 0.","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"Report summaries.","content":{"application/json":{"schema":{"type":"object","required":["ok","reports","limit","offset"],"properties":{"ok":{"const":true},"reports":{"type":"array","items":{"$ref":"#/components/schemas/ReportSummary"}},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports/{id}":{"get":{"operationId":"getReport","summary":"One scan report with its payload blocks.","description":"`?blocks=a,b` selects a subset of payload blocks; unknown names come back in `unknownBlocks` rather than erroring. A report with no stored payload yet returns `payload: null` — honest, never an invented block.","tags":["Reports"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report id (uuid). Non-uuid values answer 404, never a DB error.","schema":{"type":"string","format":"uuid"}},{"name":"blocks","in":"query","description":"Comma-separated payload block names to select.","schema":{"type":"string"}}],"responses":{"200":{"description":"The report.","content":{"application/json":{"schema":{"type":"object","required":["ok","report"],"properties":{"ok":{"const":true},"report":{"type":"object","required":["id","firm","status","availableBlocks","payload"],"properties":{"id":{"type":"string","format":"uuid"},"firm":{"type":"string"},"date":{"type":["string","null"]},"status":{"type":"string"},"deliveredAt":{"type":["string","null"],"format":"date-time"},"availableBlocks":{"type":"array","items":{"type":"string"}},"unknownBlocks":{"type":"array","items":{"type":"string"}},"payload":{"type":["object","null"],"additionalProperties":true}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports/{id}/blocks/{block}":{"get":{"operationId":"getReportBlock","summary":"One payload block, untouched — provenance labels travel with it.","description":"Block names are the stored top-level keys of the report payload (machineRead, multiEngine, marketIntel, audience, perceptionGap, universe, calibration, auditTrail, schema, …). An unknown block 404s with the report's actual block list in `details.availableBlocks`.","tags":["Reports"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report id (uuid). Non-uuid values answer 404, never a DB error.","schema":{"type":"string","format":"uuid"}},{"name":"block","in":"path","required":true,"description":"Payload block name.","schema":{"type":"string"}}],"responses":{"200":{"description":"The block.","content":{"application/json":{"schema":{"type":"object","required":["ok","reportId","block","data"],"properties":{"ok":{"const":true},"reportId":{"type":"string","format":"uuid"},"block":{"type":"string"},"data":{}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports/{id}/audit":{"get":{"operationId":"getReportAudit","summary":"The provenance audit trail as JSON.","description":"Same resolver as the public token-gated audit downloads; every entry keeps its provenance and confidence labels untouched.","tags":["Reports"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report id (uuid). Non-uuid values answer 404, never a DB error.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The audit trail.","content":{"application/json":{"schema":{"type":"object","required":["ok","reportId","audit"],"properties":{"ok":{"const":true},"reportId":{"type":"string","format":"uuid"},"audit":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports/{id}/exports/audit.csv":{"get":{"operationId":"exportAuditCsv","summary":"Audit trail as a CSV attachment (keyed alias of the public download).","tags":["Reports"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report id (uuid). Non-uuid values answer 404, never a DB error.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"CSV file.","content":{"text/csv":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports/{id}/exports/audit.json":{"get":{"operationId":"exportAuditJson","summary":"Audit trail as a JSON attachment (keyed alias of the public download).","tags":["Reports"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report id (uuid). Non-uuid values answer 404, never a DB error.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Audit-trail file.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports/{id}/exports/schema.json":{"get":{"operationId":"exportSchemaJsonLd","summary":"The report's schema.org JSON-LD, ready for a site head.","description":"A single schema.org node is served bare, multiple nodes as an array. Content type is `application/ld+json`.","tags":["Reports"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report id (uuid). Non-uuid values answer 404, never a DB error.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"JSON-LD file.","content":{"application/ld+json":{"schema":{"oneOf":[{"type":"object","additionalProperties":true},{"type":"array"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports/{id}/facts":{"get":{"operationId":"getReportFacts","summary":"The truth-gate fact layer — every fact carries provenance, confidence, status.","description":"Operator-overlay merged. Filters: `?category=`, `?provenance=`, `?status=`. An invalid enum value is a 400, never a silently-empty list.","tags":["Facts"],"security":[{"apiKey":["read:facts"]}],"x-required-scopes":["read:facts"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report id (uuid). Non-uuid values answer 404, never a DB error.","schema":{"type":"string","format":"uuid"}},{"name":"category","in":"query","schema":{"type":"string"},"description":"Filter by fact category."},{"name":"provenance","in":"query","schema":{"$ref":"#/components/schemas/Provenance"},"description":"Filter by provenance label."},{"name":"status","in":"query","schema":{"$ref":"#/components/schemas/FactStatus"},"description":"Filter by truth-gate status."}],"responses":{"200":{"description":"The facts.","content":{"application/json":{"schema":{"type":"object","required":["ok","reportId","count","facts"],"properties":{"ok":{"const":true},"reportId":{"type":"string","format":"uuid"},"firm":{"type":"string"},"date":{"type":["string","null"]},"count":{"type":"integer"},"facts":{"type":"array","items":{"$ref":"#/components/schemas/Fact"}}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports/{id}/graph":{"get":{"operationId":"getReportGraph","summary":"The derivation graph: fact nodes + derivation edges. Re-derived, never persisted.","tags":["Facts"],"security":[{"apiKey":["read:facts"]}],"x-required-scopes":["read:facts"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report id (uuid). Non-uuid values answer 404, never a DB error.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Nodes and edges.","content":{"application/json":{"schema":{"type":"object","required":["ok","reportId","acyclic","nodes","edges"],"properties":{"ok":{"const":true},"reportId":{"type":"string","format":"uuid"},"firm":{"type":"string"},"date":{"type":["string","null"]},"acyclic":{"type":"boolean"},"nodes":{"type":"array","items":{"$ref":"#/components/schemas/Fact"}},"edges":{"type":"array","items":{"type":"object","required":["from","to","method"],"properties":{"from":{"type":"string"},"to":{"type":"string"},"method":{"type":"string"},"formula":{"type":["string","null"]}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/scans":{"post":{"operationId":"createScan","summary":"Enqueue a full market scan. SPEND — admitted against the key's daily cap.","description":"Returns `202 { jobId }` immediately; the durable job survives restarts. Follow it via `GET /scans/{id}` and the SSE stream at `/scans/{id}/events`. Cap admission happens BEFORE enqueue: refusal is `403 spend_cap_exceeded` with cap/spent/estimate figures.","tags":["Scans"],"security":[{"apiKey":["write:scans"]}],"x-required-scopes":["write:scans"],"x-spend":true,"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanRequest"}}}},"responses":{"202":{"description":"Scan queued.","content":{"application/json":{"schema":{"type":"object","required":["ok","jobId","status","spend"],"properties":{"ok":{"const":true},"jobId":{"type":"string"},"status":{"const":"queued"},"spend":{"$ref":"#/components/schemas/SpendSummary"}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrSpendCap"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/scans/{id}":{"get":{"operationId":"getScan","summary":"Scan job status: queued | running | done | failed, with reportId when done.","tags":["Scans"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Scan job id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Job status.","content":{"application/json":{"schema":{"type":"object","required":["ok","job"],"properties":{"ok":{"const":true},"job":{"$ref":"#/components/schemas/ScanJob"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/scans/{id}/events":{"get":{"operationId":"getScanEvents","summary":"Scan progress over SSE: replays the buffer, then tails live events.","description":"`text/event-stream`. Send `Last-Event-ID` to resume; a connection after completion replays everything and closes. A dropped stream never affects the job.","tags":["Scans"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Scan job id.","schema":{"type":"string"}},{"name":"Last-Event-ID","in":"header","description":"Resume strictly after this event index.","schema":{"type":"integer"}}],"responses":{"200":{"description":"Server-sent event stream of scan events.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/scans/estimate":{"post":{"operationId":"estimateCosts","summary":"Free pre-flight cost estimates — check an op against your cap before spending.","description":"Always returns the flat scan and mirror estimates in pence; pass `{ sources: [...] }` to also price a prospect run with the real per-source model. Spends nothing, writes nothing.","tags":["Scans"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"sources":{"type":"array","maxItems":20,"items":{"type":"string"},"description":"Console source keys; unknowns are dropped."}}}}}},"responses":{"200":{"description":"Estimates in pence.","content":{"application/json":{"schema":{"type":"object","required":["ok","scan","mirror"],"properties":{"ok":{"const":true},"scan":{"$ref":"#/components/schemas/PenceEstimate"},"mirror":{"$ref":"#/components/schemas/PenceEstimate"},"prospectRun":{"type":"object","required":["sources","estimatePence","tokens"],"properties":{"sources":{"type":"array","items":{"type":"string"}},"estimatePence":{"type":"integer"},"tokens":{"type":"integer"}}}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/prospect-runs":{"post":{"operationId":"createProspectRun","summary":"Run a prospect console run. SPEND — answers 202, then poll.","description":"Body: `{ brief: BusinessBrief, sources: SourceKey[] }`, exactly as the console. The run is enqueued as a durable job and this answers `202 { runJobId }` immediately — the job survives restarts; poll `GET /prospect-runs/{runJobId}` to completion. Per-source failures degrade inside the report (`done` | `degraded` | `empty`) rather than failing the run, but a report that cannot be persisted FAILS the job (the spend is admitted either way, so an unretrievable run is an honest `run_failed`, never a silent success). Priced by the real per-source estimate model. Pass `sync: true` for the legacy in-request mode (long-poll: expect up to minutes) answering `201` with the full report.","tags":["Prospect runs"],"security":[{"apiKey":["write:scans"]}],"x-required-scopes":["write:scans"],"x-spend":true,"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["brief","sources"],"properties":{"brief":{"type":"object","description":"BusinessBrief: name, site, and optional targeting fields — validated by the console's own validator.","additionalProperties":true},"sources":{"type":"array","items":{"type":"string"},"description":"Console source keys; unknowns are dropped, empty after sanitising is a 400."},"sync":{"type":"boolean","default":false,"description":"true = legacy synchronous mode: run in-request and answer 201 with the full report instead of enqueueing."}}}}}},"responses":{"201":{"description":"`sync: true` only — the persisted console report.","content":{"application/json":{"schema":{"type":"object","required":["ok","runId","report","spend"],"properties":{"ok":{"const":true},"runId":{"type":"string"},"report":{"type":"object","additionalProperties":true},"spend":{"$ref":"#/components/schemas/SpendSummary"}}}}}},"202":{"description":"Run enqueued — poll GET /prospect-runs/{runJobId}.","content":{"application/json":{"schema":{"type":"object","required":["ok","runJobId","status","spend"],"properties":{"ok":{"const":true},"runJobId":{"type":"string","format":"uuid"},"status":{"const":"queued"},"spend":{"$ref":"#/components/schemas/SpendSummary"}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrSpendCap"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/prospect-runs/{id}":{"get":{"operationId":"getProspectRun","summary":"Poll a prospect run: status while in flight, the stored report once done.","description":"`id` is the `runJobId` from the 202 enqueue (legacy report ids from sync runs also resolve). Queued/running answers `{ ok, runJobId, status }`; done answers `{ ok, report }` with the report exactly as persisted; a failed run answers the error envelope with code `run_failed` and the recorded failure message.","tags":["Prospect runs"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Prospect run job id (or legacy report id).","schema":{"type":"string"}}],"responses":{"200":{"description":"In-flight status, or the stored report once done.","content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["ok","report"],"properties":{"ok":{"const":true},"report":{"type":"object","additionalProperties":true}}},{"type":"object","required":["ok","runJobId","status"],"properties":{"ok":{"const":true},"runJobId":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["queued","running"]},"createdAt":{"type":"string","format":"date-time"},"startedAt":{"type":["string","null"],"format":"date-time"}}}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The run failed (`run_failed`) — the recorded error in `message`, `runJobId` in `details`. Not retryable by waiting; re-run instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/panel":{"post":{"operationId":"runPanel","summary":"Run a synthetic buyer-panel study. SPEND — answers 202, then poll.","description":"Body: `{ audienceId, questions: string[] (1..20), maxCredits? }`. Generates/reuses the audience's synthetic replicas, asks them the questions, aggregates and (where own-data exists) calibrates. Enqueued as a durable job answering `202 { runId }`; poll `GET /panel/{id}`. Spends credits (1 per replica-question); `maxCredits` caps the run.","tags":["Buyer panel"],"security":[{"apiKey":["write:scans"]}],"x-required-scopes":["write:scans"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["audienceId","questions"],"properties":{"audienceId":{"type":"string","description":"Persisted audience id to run the panel against."},"questions":{"type":"array","items":{"type":"string"},"description":"1–20 questions put to every replica."},"maxCredits":{"type":"number","description":"Optional ceiling on credits this run may spend."}}}}}},"responses":{"202":{"description":"Run enqueued — poll GET /panel/{id}.","content":{"application/json":{"schema":{"type":"object","required":["ok","runId"],"properties":{"ok":{"const":true},"runId":{"type":"string","format":"uuid"}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}},"get":{"operationId":"listPanelRuns","summary":"List this org's buyer-panel runs, newest first.","description":"Org-scoped; `?limit=` clamps the page size.","tags":["Buyer panel"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"responses":{"200":{"description":"The org's panel runs.","content":{"application/json":{"schema":{"type":"object","required":["ok","runs"],"properties":{"ok":{"const":true},"runs":{"type":"array","items":{"type":"object","additionalProperties":true}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/panel/{id}":{"get":{"operationId":"getPanelRun","summary":"Poll a buyer-panel run: status while in flight, the result once done.","description":"Org-scoped — another org's id reads as `404 not_found`. Queued/running answers status; done answers the aggregated (and calibrated) result.","tags":["Buyer panel"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Panel run id.","schema":{"type":"string"}}],"responses":{"200":{"description":"Run status, or the result once done.","content":{"application/json":{"schema":{"type":"object","required":["ok","run"],"properties":{"ok":{"const":true},"run":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/assessment":{"post":{"operationId":"createAssessmentWatch","summary":"Register a firm under recurring re-scan (\"The Watch\").","description":"Body: `{ companyKey, website?, name?, vertical?, cadenceDays?, ownerLabel? }`. Places a firm under continuous assessment: the scheduler re-scans it on its cadence and records what changed. A watch COMMITS FUTURE SCAN SPEND on its cadence (each re-scan spends like a market scan), hence `write:scans`. Idempotent per firm — an existing active watch for the same `companyKey` is returned unchanged. Answers `201 { watch }`.","tags":["Continuous assessment"],"security":[{"apiKey":["write:scans"]}],"x-required-scopes":["write:scans"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["companyKey"],"properties":{"companyKey":{"type":"string","description":"Stable key tying the firm to its reports across re-scans."},"website":{"type":"string","format":"uri","description":"The firm's website — carried into each re-scan brief."},"name":{"type":"string","description":"Display name (defaults to companyKey when omitted)."},"vertical":{"type":"string","description":"Sector/vertical, used as the scan category."},"cadenceDays":{"type":"integer","minimum":1,"maximum":365,"description":"Days between re-scans (default 30)."},"ownerLabel":{"type":"string","description":"Free-text internal account/owner label."}}}}}},"responses":{"201":{"description":"Watch created (or the existing active watch for this firm).","content":{"application/json":{"schema":{"type":"object","required":["ok","watch"],"properties":{"ok":{"const":true},"watch":{"type":"object","additionalProperties":true}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}},"get":{"operationId":"listAssessmentWatches","summary":"List continuous-assessment watches, newest first.","description":"Internal-first, so this lists every watch (no org filter).","tags":["Continuous assessment"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"responses":{"200":{"description":"The registered watches.","content":{"application/json":{"schema":{"type":"object","required":["ok","watches"],"properties":{"ok":{"const":true},"watches":{"type":"array","items":{"type":"object","additionalProperties":true}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/assessment/{id}":{"get":{"operationId":"getAssessmentWatch","summary":"Get one watch and its latest deltas.","description":"An unknown id answers `404 not_found`. `?limit=` clamps how many deltas come back (newest first). Each delta carries the AI-visibility movement, the money-question changes, gaps resolved/new, a direction and a one-line headline.","tags":["Continuous assessment"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Assessment watch id (uuid).","schema":{"type":"string"}},{"name":"limit","in":"query","description":"Page size, 1–100 (default 20). Out-of-range values clamp.","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}}],"responses":{"200":{"description":"The watch and its deltas.","content":{"application/json":{"schema":{"type":"object","required":["ok","watch","deltas"],"properties":{"ok":{"const":true},"watch":{"type":"object","additionalProperties":true},"deltas":{"type":"array","items":{"type":"object","additionalProperties":true}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/mirror/analyses":{"post":{"operationId":"createMirrorAnalysis","summary":"Run a Market Mirror review analysis. SPEND — answers 202, then poll.","description":"The keyed twin of the public tool without its 60-second compromises: up to 12 sources (URL, Reddit, or pasted text ≤20k chars each). Source URLs pass an SSRF guard — public http(s) only. The pipeline continues detached; poll `GET /mirror/analyses/{id}`.","tags":["Market Mirror"],"security":[{"apiKey":["write:mirror"]}],"x-required-scopes":["write:mirror"],"x-spend":true,"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MirrorAnalysisRequest"}}}},"responses":{"202":{"description":"Analysis started.","content":{"application/json":{"schema":{"type":"object","required":["ok","analysisId","status","spend"],"properties":{"ok":{"const":true},"analysisId":{"type":"string","format":"uuid"},"status":{"const":"fetching"},"spend":{"$ref":"#/components/schemas/SpendSummary"}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrSpendCap"},"413":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/mirror/analyses/{id}":{"get":{"operationId":"getMirrorAnalysis","summary":"Poll a Market Mirror analysis: queued | fetching | analyzing | done | failed.","description":"The stored ReviewInsight `result` is returned exactly as persisted — confidence and evidence labels untouched.","tags":["Market Mirror"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Analysis id (uuid).","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The analysis run.","content":{"application/json":{"schema":{"type":"object","required":["ok","analysis"],"properties":{"ok":{"const":true},"analysis":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/dev/scrape":{"post":{"operationId":"devProxyScrape","summary":"Forward a Firecrawl scrape through the Fly proxy. SPEND. Dev only.","description":"Secretless local dev: a laptop holds no Firecrawl key and POSTs here; the Fly app injects the real key server-side by calling the same scrape() path and returns the raw result. Stateless — writes no report/fact rows. The URL passes the same SSRF guard as the public tools.","tags":["Dev proxy"],"security":[{"apiKey":["read:dev-proxy"]}],"x-required-scopes":["read:dev-proxy"],"x-spend":true,"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"Public http(s) page to scrape."},"includeHtml":{"type":"boolean"},"screenshot":{"type":"boolean"}}}}}},"responses":{"200":{"description":"The upstream scrape result.","content":{"application/json":{"schema":{"type":"object","required":["ok","result"],"properties":{"ok":{"const":true},"result":{"type":"object","additionalProperties":true}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrSpendCap"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/dev/llm":{"post":{"operationId":"devProxyLlm","summary":"Forward a structured LLM completion through the Fly proxy. SPEND. Dev only.","description":"Secretless local dev: a laptop holds no Anthropic/OpenRouter key and POSTs the built prompt here; the Fly app injects the real key by calling the same completeStructured() path and returns the RAW model JSON. The client re-validates it against its own schema. Stateless — writes no report/fact rows.","tags":["Dev proxy"],"security":[{"apiKey":["read:dev-proxy"]}],"x-required-scopes":["read:dev-proxy"],"x-spend":true,"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["built"],"properties":{"built":{"type":"object","additionalProperties":true,"description":"A BuiltPrompt (model + system + messages)."},"maxTokens":{"type":"integer","minimum":1,"maximum":64000},"label":{"type":"string"},"provider":{"type":"string","enum":["anthropic","openrouter"]}}}}}},"responses":{"200":{"description":"The raw model JSON.","content":{"application/json":{"schema":{"type":"object","required":["ok","result"],"properties":{"ok":{"const":true},"result":{}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenOrSpendCap"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/validation/studies":{"post":{"operationId":"createValidationStudy","summary":"Create a validation study as an UNPUBLISHED panel draft. No money moves.","description":"Drafts are free; publishing is the separate, explicit spend step. A draft that could never publish under the global spend guard is refused at create time (402).","tags":["Validation"],"security":[{"apiKey":["write:validation"]}],"x-required-scopes":["write:validation"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reportId"],"properties":{"reportId":{"type":"string","format":"uuid"},"mode":{"type":"string","enum":["micro","standard"]}}}}}},"responses":{"201":{"description":"Draft created.","content":{"application/json":{"schema":{"type":"object","required":["ok","studyId","status"],"properties":{"ok":{"const":true},"studyId":{"type":"string","format":"uuid"},"status":{"const":"draft"},"draftCostPence":{"type":"integer"},"surveyUrl":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"The global spend guard refused the draft — it could never publish.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"$ref":"#/components/responses/UpstreamUnavailable"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/validation/studies/{id}":{"get":{"operationId":"getValidationStudy","summary":"Study status: DB status + panel state + submission tallies.","description":"A panel-API failure leaves `panel: null` rather than inventing state.","tags":["Validation"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Study id (uuid).","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Study status.","content":{"application/json":{"schema":{"type":"object","required":["ok","study"],"properties":{"ok":{"const":true},"study":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/validation/studies/{id}/publish":{"post":{"operationId":"publishValidationStudy","summary":"Publish the study to Prolific. THE money step — two independent gates.","description":"Gate 1: the per-key daily cap, checked and ledgered BEFORE Prolific is touched — a cap-0 key is refused without a single upstream call. The admission figure is the study's REAL panel-computed cost persisted at draft time (falling back to the global guard ceiling for studies with no stored cost), so a low-cap key can publish a cheap study and the ledger row carries the true figure. Gate 2: the global spend guard inside the panel client (402 with both figures on refusal).","tags":["Validation"],"security":[{"apiKey":["write:validation"]}],"x-required-scopes":["write:validation"],"x-spend":true,"parameters":[{"name":"id","in":"path","required":true,"description":"Study id (uuid).","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published.","content":{"application/json":{"schema":{"type":"object","required":["ok","studyId","panelStatus","totalCostPence","spend"],"properties":{"ok":{"const":true},"studyId":{"type":"string","format":"uuid"},"panelStatus":{"type":"string"},"totalCostPence":{"type":"integer"},"spend":{"$ref":"#/components/schemas/SpendSummary"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"The GLOBAL spend guard refused the publish.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"403":{"$ref":"#/components/responses/ForbiddenOrSpendCap"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Not a publishable draft.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"$ref":"#/components/responses/UpstreamUnavailable"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/validation/studies/{id}/calibrate":{"post":{"operationId":"calibrateValidationStudy","summary":"Collect quality-gated responses and compute the calibration block. Non-spend.","description":"Only collects responses already paid for at publish. The calibration block is returned untouched — convergence figures and verdicts pass through exactly as computed.","tags":["Validation"],"security":[{"apiKey":["write:validation"]}],"x-required-scopes":["write:validation"],"parameters":[{"name":"id","in":"path","required":true,"description":"Study id (uuid).","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Calibration computed and merged into the linked report.","content":{"application/json":{"schema":{"type":"object","required":["ok","studyId","calibration"],"properties":{"ok":{"const":true},"studyId":{"type":"string","format":"uuid"},"calibration":{"type":"object","additionalProperties":true},"completedResponses":{"type":"integer"},"parseFailures":{"type":"integer"},"submissionsByStatus":{"type":"object","additionalProperties":{"type":"integer"}},"mergedIntoReport":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Study has no linked report.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports/{id}/teaser-links":{"post":{"operationId":"mintTeaserLinks","summary":"Mint PECR-safe competitor teaser links + drafted outreach for a report.","description":"Default is PECR-eligible competitors only (confirmed active Ltd/LLP). `{ includeIneligible: true }` mirrors the CLI's `--all` — the caller thereby asserts independent verification of corporate-subscriber status, and that assertion is echoed into the response. Fails closed: ineligible firms are always listed with the reason, never silently minted. Every call consumes one unit of the key's daily mint budget (default 200/day, shared with warm-pack; operator-overridable via `API_OUTREACH_MINT_DAILY_CAP`) — exhausted budget is a 429 naming the cap.","tags":["Outreach"],"security":[{"apiKey":["write:outreach"]}],"x-required-scopes":["write:outreach"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report id (uuid). Non-uuid values answer 404, never a DB error.","schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"includeIneligible":{"type":"boolean","default":false}}}}}},"responses":{"200":{"description":"Minted links with eligibility.","content":{"application/json":{"schema":{"type":"object","required":["ok","reportId","links","skipped","eligibleCount"],"properties":{"ok":{"const":true},"reportId":{"type":"string","format":"uuid"},"includeIneligible":{"type":"boolean"},"callerAssertion":{"type":"string"},"links":{"type":"array","items":{"$ref":"#/components/schemas/TeaserLink"}},"skipped":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"domain":{"type":["string","null"]},"reason":{"type":"string"}}}},"eligibleCount":{"type":"integer"}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Report has no payload yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/reports/{id}/warm-pack":{"get":{"operationId":"getWarmPack","summary":"The full warm-outreach pack: teasers + PECR eligibility + drafted copy.","description":"Read-shaped but SENSITIVE — it mints live signed tokens — so it sits under `write:outreach` by design. `?contacts=true` additionally resolves a contact email per ELIGIBLE firm (slow, network-bound); ineligible firms never get a contact lookup. Every call consumes one unit of the key's daily mint budget (default 200/day, shared with teaser-links; operator-overridable via `API_OUTREACH_MINT_DAILY_CAP`) — exhausted budget is a 429 naming the cap.","tags":["Outreach"],"security":[{"apiKey":["write:outreach"]}],"x-required-scopes":["write:outreach"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report id (uuid). Non-uuid values answer 404, never a DB error.","schema":{"type":"string","format":"uuid"}},{"name":"contacts","in":"query","description":"`true` to resolve contact emails for eligible firms.","schema":{"type":"string","enum":["true","false"]}}],"responses":{"200":{"description":"The warm pack.","content":{"application/json":{"schema":{"type":"object","required":["ok","contactsResolved","items","eligibleCount"],"properties":{"ok":{"const":true},"contactsResolved":{"type":"boolean"},"items":{"type":"array","items":{"type":"object","additionalProperties":true}},"eligibleCount":{"type":"integer"}},"additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Report has no payload yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/outreach/pecr-check":{"post":{"operationId":"pecrCheck","summary":"Is this company PECR-safe to email? Pure check, no side effects.","description":"Provide `companyNumber` (prefix-derived entity type — a NECESSARY condition only, said plainly in the response) and/or `reportId` (the full fail-closed per-competitor assessment over the stored payload).","tags":["Outreach"],"security":[{"apiKey":["write:outreach"]}],"x-required-scopes":["write:outreach"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"companyNumber":{"type":"string","minLength":1,"maxLength":20},"reportId":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"Assessment.","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"const":true},"companyNumberCheck":{"type":"object","properties":{"companyNumber":{"type":"string"},"entityType":{"type":"string"},"corporateSubscriber":{"type":"boolean"},"note":{"type":"string"}}},"competitors":{"type":"array","items":{"type":"object","additionalProperties":true}}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/friday/reports":{"get":{"operationId":"listFridayReports","summary":"Companies that have generated a Friday report — one summary row each.","tags":["Friday"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"limit","in":"query","description":"Page size, 1–100 (default 20). Out-of-range values clamp.","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"offset","in":"query","description":"Rows to skip (default 0). Negative values clamp to 0.","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"Tracked companies.","content":{"application/json":{"schema":{"type":"object","required":["ok","reports","limit","offset"],"properties":{"ok":{"const":true},"reports":{"type":"array","items":{"type":"object","additionalProperties":true}},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/friday/reports/{id}":{"get":{"operationId":"getFridayReport","summary":"One saved Friday report with its rendered sections untouched.","tags":["Friday"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Friday report id (opaque text, not a uuid).","schema":{"type":"string"}}],"responses":{"200":{"description":"The saved report.","content":{"application/json":{"schema":{"type":"object","required":["ok","report"],"properties":{"ok":{"const":true},"report":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/growth/audiences":{"post":{"operationId":"upsertAudience","summary":"Insert or update a company in the growth audience CRM. Idempotent on id.","description":"Bridge Core's ingest direction. `pecrEligible` defaults FALSE in the store — the PECR gate fails closed unless the caller explicitly asserts eligibility.","tags":["Growth"],"security":[{"apiKey":["write:outreach"]}],"x-required-scopes":["write:outreach"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AudienceRow"}}}},"responses":{"200":{"description":"Upserted.","content":{"application/json":{"schema":{"type":"object","required":["ok","id"],"properties":{"ok":{"const":true},"id":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/growth/suppressions":{"post":{"operationId":"addSuppression","summary":"Add a do-not-contact entry. A suppression always says why it exists.","tags":["Growth"],"security":[{"apiKey":["write:outreach"]}],"x-required-scopes":["write:outreach"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reason"],"properties":{"domain":{"type":"string"},"companyNumber":{"type":"string"},"email":{"type":"string","format":"email"},"reason":{"type":"string","enum":["opt_out","bounce","complaint","manual"]}},"description":"At least one of domain / companyNumber / email is required."}}}},"responses":{"201":{"description":"Added.","content":{"application/json":{"schema":{"type":"object","required":["ok"],"properties":{"ok":{"const":true}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/growth/suppressions/check":{"get":{"operationId":"checkSuppression","summary":"Is any of the given identifiers on the do-not-contact list?","description":"At least one of `?domain=`, `?companyNumber=`, `?email=`. A DB failure is a 503, never an implied \"not suppressed\" — the check fails closed like everything PECR-adjacent.","tags":["Growth"],"security":[{"apiKey":["write:outreach"]}],"x-required-scopes":["write:outreach"],"parameters":[{"name":"domain","in":"query","schema":{"type":"string"}},{"name":"companyNumber","in":"query","schema":{"type":"string"}},{"name":"email","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Suppression state.","content":{"application/json":{"schema":{"type":"object","required":["ok","suppressed"],"properties":{"ok":{"const":true},"suppressed":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/growth/funnel":{"get":{"operationId":"getGrowthFunnel","summary":"The growth-engine funnel snapshot — every number a live count, nothing padded.","tags":["Growth"],"security":[{"apiKey":["read:reports"]}],"x-required-scopes":["read:reports"],"responses":{"200":{"description":"Funnel snapshot.","content":{"application/json":{"schema":{"type":"object","required":["ok","funnel"],"properties":{"ok":{"const":true},"funnel":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/keys":{"get":{"operationId":"listKeys","summary":"List keys: prefix + label + scopes — never hashes.","description":"Operator-kind keys list every client's keys; every other kind sees its own client only.","tags":["Keys"],"security":[{"apiKey":["admin:keys"]}],"x-required-scopes":["admin:keys"],"responses":{"200":{"description":"Key summaries.","content":{"application/json":{"schema":{"type":"object","required":["ok","keys"],"properties":{"ok":{"const":true},"keys":{"type":"array","items":{"$ref":"#/components/schemas/KeySummary"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}},"post":{"operationId":"mintKey","summary":"Mint a key. The plaintext is returned ONCE and never stored.","description":"Cross-client minting (explicit `clientId` or inline `client` creation) requires an operator-kind key.","tags":["Keys"],"security":[{"apiKey":["admin:keys"]}],"x-required-scopes":["admin:keys"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["scopes"],"properties":{"clientId":{"type":"string","format":"uuid"},"client":{"type":"object","required":["name","kind"],"properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["operator","partner","client","integration"]}}},"label":{"type":"string","maxLength":200,"default":""},"scopes":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/Scope"}},"rateTier":{"type":"string","enum":["operator","partner","standard"],"default":"standard"},"dailySpendCapPence":{"type":"integer","minimum":0,"default":0,"description":"0 = spend scopes unusable even if granted."},"mode":{"type":"string","enum":["live","test"],"default":"live"}}}}}},"responses":{"201":{"description":"Minted. Store the key now — only a hash is kept.","content":{"application/json":{"schema":{"type":"object","required":["ok","key","id","keyPrefix"],"properties":{"ok":{"const":true},"key":{"type":"string","description":"The plaintext key. Shown once."},"id":{"type":"string","format":"uuid"},"keyPrefix":{"type":"string"},"note":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}},"/keys/{id}":{"delete":{"operationId":"revokeKey","summary":"Revoke a key (idempotent). The row is kept for audit + the spend ledger.","description":"Non-operator keys can only revoke within their own client; keys outside it answer 404 so existence never leaks across tenants.","tags":["Keys"],"security":[{"apiKey":["admin:keys"]}],"x-required-scopes":["admin:keys"],"parameters":[{"name":"id","in":"path","required":true,"description":"Key id (uuid).","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Revoked (or already revoked).","content":{"application/json":{"schema":{"type":"object","required":["ok","id"],"properties":{"ok":{"const":true},"id":{"type":"string","format":"uuid"},"alreadyRevoked":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ForbiddenScope"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/UpstreamUnavailable"}}}}},"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","bearerFormat":"aik_live_… / aik_test_… API key","description":"Audience Intel API key. Minted by the operator (enterprise, POA); carries explicit scopes and a daily spend cap in pence. In OpenAPI 3.1, each operation's security-requirement array lists the required scopes (also duplicated in x-required-scopes)."}},"responses":{"Unauthorized":{"description":"Missing/malformed Authorization header, or unknown/revoked key. Auth fails closed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"ForbiddenScope":{"description":"The key is missing a required scope (`forbidden_scope`, with `details.missingScopes`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"ForbiddenOrSpendCap":{"description":"Either the key is missing a required scope (`forbidden_scope`), or admitting this spend would exceed the key's daily cap (`spend_cap_exceeded`, with capPence / spentPence / estimatePence in `details`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"NotFound":{"description":"No such resource (`not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"ValidationFailed":{"description":"Request failed validation (`validation_failed`, Zod issues in `details.issues`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"RateLimited":{"description":"Over the key tier's rate limit (`rate_limited`). Retry-After header set.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"UpstreamUnavailable":{"description":"A dependency is unavailable (`upstream_unavailable`). Never a stack trace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}},"schemas":{"Scope":{"type":"string","enum":["read:reports","read:facts","write:facts","write:scans","write:mirror","write:outreach","write:validation","admin:keys","mcp:connect","read:dev-proxy"],"description":"API key scope."},"ErrorEnvelope":{"type":"object","required":["ok","error"],"properties":{"ok":{"const":false},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden_scope","spend_cap_exceeded","validation_failed","not_found","rate_limited","upstream_unavailable","run_failed"]},"message":{"type":"string"},"details":{}}}},"description":"The one error envelope. Codes are machine-stable; additive only within v1."},"HonestFailure":{"type":"object","required":["ok","reason"],"properties":{"ok":{"const":false},"reason":{"type":"string"}},"description":"Honest degradation: the dependency is down, so the endpoint says so instead of inventing a number. HTTP 200."},"Provenance":{"type":"string","enum":["measured","inferred","modelled","supplied"],"description":"How a figure was obtained: measured (observed directly), inferred (derived from measured inputs), modelled (estimated from priors), supplied (stated by the client). Travels with every fact."},"FactStatus":{"type":"string","enum":["unverified","verified","corrected","rejected"],"description":"Truth-gate status set by operator QA."},"Fact":{"type":"object","required":["id","label","provenance","status"],"properties":{"id":{"type":"string"},"category":{"type":"string"},"key":{"type":"string"},"label":{"type":"string"},"value":{},"unit":{"type":["string","null"]},"provenance":{"$ref":"#/components/schemas/Provenance"},"confidence":{"type":["number","string","null"]},"status":{"$ref":"#/components/schemas/FactStatus"},"operatorValue":{},"sourceSubstance":{},"derivation":{"type":["object","null"],"properties":{"inputs":{"type":"array","items":{"type":"string"}},"method":{"type":"string"},"formula":{"type":["string","null"]}}}},"additionalProperties":true,"description":"One truth-gate fact. Provenance, confidence and status are always present — no endpoint strips them."},"ReportSummary":{"type":"object","required":["id","firm"],"properties":{"id":{"type":"string","format":"uuid"},"firm":{"type":"string"},"date":{"type":["string","null"]},"status":{"type":"string"}},"additionalProperties":true},"ScanJob":{"type":"object","required":["id","status"],"properties":{"id":{"type":"string"},"status":{"type":"string","enum":["queued","running","done","failed"]},"createdAt":{"type":"string","format":"date-time"},"startedAt":{"type":["string","null"],"format":"date-time"},"finishedAt":{"type":["string","null"],"format":"date-time"},"eventCount":{"type":"integer"},"reportId":{"type":["string","null"]},"reportUrl":{"type":["string","null"]},"error":{"type":["string","null"]}}},"SpendSummary":{"type":"object","required":["estimatePence","spentTodayPence","capPence"],"properties":{"estimatePence":{"type":"integer"},"spentTodayPence":{"type":"integer","description":"Includes this operation's admitted estimate."},"capPence":{"type":"integer"}},"description":"Admission-time spend accounting against the key's daily cap (UTC day)."},"PenceEstimate":{"type":"object","required":["estimatePence"],"properties":{"estimatePence":{"type":"integer"}}},"ScanRequest":{"type":"object","required":["business"],"properties":{"business":{"type":"object","required":["name","website","sector"],"properties":{"name":{"type":"string"},"website":{"type":"string","format":"uri"},"sector":{"type":"string"},"description":{"type":"string"},"companyNumber":{"type":"string","pattern":"^[A-Za-z0-9]{8}$","description":"Companies House number, e.g. 01234567 or SC123456."},"economics":{"type":"object","additionalProperties":true,"description":"Client's own stated economics (average job value, lifetime value, target clients/month). Supplied, never measured — labelled so."}}},"competitors":{"type":"array","maxItems":3,"items":{"type":"object","required":["name","website"],"properties":{"name":{"type":"string"},"website":{"type":"string","format":"uri"}}}},"location":{"type":"string","description":"Service area, e.g. \"Cheltenham,England,United Kingdom\". Absent = national (and labelled so)."},"territory":{"type":"object","properties":{"driveMinutes":{"type":"integer","minimum":1,"maximum":600},"towns":{"type":"array","maxItems":40,"items":{"type":"string"}}}},"icp":{"type":"string","description":"The customer the caller wants this site to win."},"analysisType":{"type":"string","enum":["machine_read","both"],"default":"machine_read"},"multiEngine":{"type":"boolean","default":false},"serp":{"type":"boolean","default":true},"forces":{"type":"boolean","default":true},"aiVisibility":{"type":"boolean","default":false,"description":"Run the MEASURED open-web AI-visibility queries (paid). Off by default."},"email":{"type":"string","format":"email"}}},"MirrorAnalysisRequest":{"type":"object","required":["niche","sources"],"properties":{"niche":{"type":"string","minLength":2,"maxLength":120},"products":{"type":"array","maxItems":8,"items":{"type":"string","minLength":1,"maxLength":80}},"sources":{"type":"array","minItems":1,"maxItems":12,"items":{"oneOf":[{"type":"object","required":["kind","url"],"properties":{"kind":{"type":"string","enum":["url","reddit"]},"url":{"type":"string","format":"uri"},"product":{"type":"string","maxLength":80}}},{"type":"object","required":["kind","text"],"properties":{"kind":{"const":"paste"},"text":{"type":"string","minLength":1,"maxLength":20000},"product":{"type":"string","maxLength":80}}}]}}}},"TeaserLink":{"type":"object","required":["name","eligible","teaserUrl"],"properties":{"name":{"type":"string"},"domain":{"type":["string","null"]},"eligible":{"type":"boolean"},"ineligibleReason":{"type":"string"},"teaserUrl":{"type":"string"},"subject":{"type":"string"},"body":{"type":"string"}}},"AudienceRow":{"type":"object","required":["id","companyName"],"properties":{"id":{"type":"string","maxLength":120},"companyName":{"type":"string","maxLength":300},"companyNumber":{"type":["string","null"],"maxLength":20},"sic":{"type":["string","null"],"maxLength":20},"vertical":{"type":["string","null"],"maxLength":120},"town":{"type":["string","null"],"maxLength":120},"postcode":{"type":["string","null"],"maxLength":16},"domain":{"type":["string","null"],"maxLength":253},"email":{"type":["string","null"],"format":"email"},"emailSource":{"type":["string","null"],"maxLength":60},"emailStatus":{"type":["string","null"],"maxLength":60},"chStatus":{"type":["string","null"],"maxLength":60},"pecrEligible":{"type":"boolean","description":"Defaults false in the store — the PECR gate fails closed."},"cellKey":{"type":["string","null"],"maxLength":200},"stage":{"type":"string","maxLength":40},"personName":{"type":["string","null"],"maxLength":200},"personRole":{"type":["string","null"],"maxLength":200},"linkedinUrl":{"type":["string","null"],"format":"uri"},"linkedinCompanyUrl":{"type":["string","null"],"format":"uri"},"mobile":{"type":["string","null"],"maxLength":30}}},"KeySummary":{"type":"object","required":["id","key_prefix","scopes"],"properties":{"id":{"type":"string","format":"uuid"},"client_id":{"type":"string","format":"uuid"},"client_name":{"type":"string"},"key_prefix":{"type":"string","description":"First 12 chars of the plaintext — display/audit only. Hashes are never returned."},"label":{"type":"string"},"scopes":{"type":"array","items":{"$ref":"#/components/schemas/Scope"}},"rate_tier":{"type":"string","enum":["operator","partner","standard"]},"daily_spend_cap_pence":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"}},"additionalProperties":true}}}}