{"openapi":"3.1.0","info":{"title":"Pitchko Public API","version":"1.0.0","description":"Generate pitch decks programmatically and read AI-visibility data. Each API key is bound 1:1 to a Pitchko user; the key acts as that user (same agency, credit balance, and permissions).\nTwo endpoints under /aeo/grade are the exception: they are ANONYMOUS (no API key) and they SPEND MONEY. They back the embeddable public grader widget and are billed to the embedding agency's credits, not to a key. The [integration guide](/docs/guide) is the narrative companion to this reference — sandbox walkthrough, polling loop, rate limits, guarantees.\n\n## Changelog\nProduct and API changes are announced on the [changelog](/changelog) — breaking changes always ship with a was → is-now migration table there and in the integration guide.\n\n## MCP\nPitchko also speaks the Model Context Protocol: a stateless Streamable-HTTP server at `https://pitchko.io/api/mcp` with read-only tools for AI visibility and SEO audits (`aeo:read` scope) and pitch deck status (`decks` scope). It answers POST only — opening the URL in a browser returns a JSON-RPC error by design. Authenticate with an API key from Dashboard → Settings → Developers holding either scope; each tool enforces its own. The full guide — available tools, example prompts, per-client setup — lives at [pitchko.io/docs/mcp](/docs/mcp).\nClaude Code:\n\n    claude mcp add --transport http pitchko \\\n      https://pitchko.io/api/mcp \\\n      --header \"Authorization: Bearer <your API key>\"\n\nAny other MCP client (Claude Desktop, VS Code, Cursor, …) — add this to its `mcpServers` config:\n\n    {\n      \"pitchko\": {\n        \"url\": \"https://pitchko.io/api/mcp\",\n        \"headers\": { \"Authorization\": \"Bearer <your API key>\" }\n      }\n    }\n"},"servers":[{"url":"https://pitchko.io/api/v1"}],"security":[{"bearerAuth":[]}],"paths":{"/decks":{"post":{"operationId":"createDeck","summary":"Create a pitch deck (async)","description":"Returns immediately with a deck id and status `pending`. Poll GET /decks/{id} until the status is terminal. Sandbox keys (pk_test_) never charge credits and simulate the lifecycle.\n","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeckCreateRequest"}}}},"responses":{"201":{"description":"Deck accepted for generation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeckCreated"}}}},"400":{"description":"Invalid request body or no resolvable agency","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Not enough credits (`insufficient_credits`; live keys only)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"User cannot create decks under the resolved agency","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited or daily AI budget exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/decks/{id}":{"get":{"operationId":"getDeck","summary":"Get deck status and result","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Current deck status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeckStatus"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The deck is not accessible to this key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No deck with that id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/aeo/brands":{"get":{"operationId":"listTrackedBrands","summary":"List brands tracked for AI visibility","description":"Read-only. Sandbox keys (pk_test_) return a single fictional brand and never touch real data.\n","parameters":[{"$ref":"#/components/parameters/AgencyId"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100}}],"responses":{"200":{"description":"The agency's tracked brands","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BrandList"}}}},"400":{"description":"The key's user belongs to several agencies and none was named","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Missing scope (`insufficient_scope` — needs `aeo:read`), the key's user has no agency, or they are not a member of the requested one","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/aeo/brands/{id}/report":{"get":{"operationId":"getVisibilityReport","summary":"Get a brand's latest completed AI-visibility report","description":"Returns the latest COMPLETED scan only; there is no way to start a scan over the public API. A metric value of 0 means the brand was queried and never mentioned; null means the metric was not measured. They are not interchangeable.\n","parameters":[{"$ref":"#/components/parameters/BrandId"},{"$ref":"#/components/parameters/AgencyId"},{"name":"include","in":"query","required":false,"description":"Pass `prompts` to include per-prompt-per-engine rows (up to 500).","schema":{"type":"string","enum":["prompts"]}}],"responses":{"200":{"description":"The latest scored report","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VisibilityReport"}}}},"400":{"description":"The key's user belongs to several agencies and none was named","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Missing scope (`insufficient_scope` — needs `aeo:read`), the key's user has no agency, or they are not a member of the requested one","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown brand (`not_found`), or the brand has no completed scan yet (`no_report`). A brand belonging to another agency also returns 404, deliberately — a 403 would confirm the id exists.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/aeo/brands/{id}/history":{"get":{"operationId":"getVisibilityHistory","summary":"Get a brand's AI-visibility trend line","description":"One flat row per completed scan, newest first. A brand with no completed scans returns 200 with an empty `points` array, not an error.\n","parameters":[{"$ref":"#/components/parameters/BrandId"},{"$ref":"#/components/parameters/AgencyId"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":12}}],"responses":{"200":{"description":"Trend points, newest first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VisibilityHistory"}}}},"400":{"description":"The key's user belongs to several agencies and none was named","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Missing scope (`insufficient_scope` — needs `aeo:read`), the key's user has no agency, or they are not a member of the requested one","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown brand","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/aeo/grade":{"post":{"operationId":"startPublicGrade","summary":"Run a public AI-visibility grade (ANONYMOUS, spends the agency's credits)","description":"Backs the embeddable grader widget. Unlike every other endpoint here this one is ANONYMOUS — it takes no API key and no scope, so there is no `insufficient_scope` to hunt for. The caller is identified by the embedding agency's PUBLIC widget `slug`, which is safe to ship in a browser because it grants nothing beyond running a grade against that agency's own credits.\n\nIt is also the ONLY endpoint on this API that spends money: it runs a live sweep (5 prompts x 5 engines, N=1) and consumes one AI-visibility widget-scan credit from the embedding agency, reserved BEFORE any engine call. A credit is refunded only when the scan never started; a sweep that fails part-way is not refunded, because those engine calls were already billed to us.\n\nThe response is a GATED teaser — score, per-engine and per-category grids, and counts — whenever the agency has lead capture enabled. The diagnosis (recommendations, per-prompt results, cited sources, competitors) is withheld until the sibling lead endpoint unlocks it. If the agency disabled lead capture, `report` is present immediately and `lead_gated` is false.\n\nA metric of 0 means measured and never mentioned; null means not measured. The teaser preserves that distinction — `enginesInvisible` and `enginesNotMeasured` are separate counts and must not be summed.\n","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GradeRequest"}}}},"responses":{"201":{"description":"Grade completed. `report` is present only when `lead_gated` is false.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GradeCreated"}}}},"400":{"description":"Malformed body or missing `slug` (`invalid_request`, `message`); failed field validation (`invalid_request`, `details` as an array of `\"path: message\"` strings); or a URL that failed the SSRF allow-list (`invalid_url`, with a `reason`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"The embedding agency is out of credits (`insufficient_credits`). An unreadable meter returns this too — the meter fails closed, because a scan spends money.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Turnstile verification failed (`captcha_failed`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown widget slug (`not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Body exceeded 8 KiB (`invalid_request`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-IP rate limit (5/min), or an abuse cap: 2 grades per graded domain per hour and 15 per agency per hour. Cap responses carry `scope` (`domain` or `agency`) and `retry_after_seconds`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"The sweep failed after it had started (`scan_failed`). The credit is NOT refunded — the engine calls were already paid for.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/aeo/grade/{id}/lead":{"post":{"operationId":"unlockPublicGrade","summary":"Unlock a gated grade with an email (ANONYMOUS)","description":"The email gate on the grader widget: captures the visitor's email and returns the full report that the teaser withheld. Anonymous, like the endpoint that produced the run — no API key, no scope.\n\nRequires a FRESH Turnstile token; the one spent starting the grade is not reusable. The run id is scoped to the agency resolved from `slug` AND to grader runs only, so one agency's widget cannot unlock another agency's run, nor any tracked brand's private scan. A run outside that scope is reported as `not_found`.\n\nThis endpoint spends nothing — the scan already ran.\n","security":[],"parameters":[{"name":"id","in":"path","required":true,"description":"The `run_id` returned by POST /aeo/grade.","schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GradeLeadRequest"}}}},"responses":{"200":{"description":"The unlocked report","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GradeUnlocked"}}}},"400":{"description":"Malformed body or missing `slug` (`invalid_request`); failed field validation (`invalid_request` with string `details`); or a rejected email — `invalid_email` (bad shape) or `disposable_email`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Turnstile verification failed (`captcha_failed`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown widget slug, or no grader run with that id under this agency (`not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Body exceeded 8 KiB (`invalid_request`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-IP rate limit (5/min), or more than 5 unlock attempts against one run in an hour (`rate_limited`, with `retry_after_seconds`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/audit":{"post":{"operationId":"startPublicAudit","summary":"Run a public SEO audit (ANONYMOUS, spends the agency's credits)","description":"Backs the embeddable site-audit widget — the SEO sibling of /aeo/grade. ANONYMOUS: no API key, no scope. The caller is identified by the embedding agency's PUBLIC widget `slug`.\n\nMetered against the embedding agency's credits — the same wallet its dashboard spends. What stops anonymous traffic draining it is a per-period ceiling on what this surface may spend, enforced in the same atomic statement as the charge. A refusal does not say which of the two it hit, deliberately: distinguishing them would let an anonymous caller probe how much the agency has left. The public surface runs an INSTANT audit only — it never triggers a paid deep crawl.\n\nWhat the response carries depends on the agency's `lead_mode` (BL-2713). `teaser` returns the score, per-category grades and issue counts and withholds the findings; `gated` withholds the teaser too; `email_first` requires `email` on THIS request and returns the full report at once. If the agency disabled lead capture, `report` is present immediately and `lead_gated` is false.\n","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditRequest"}}}},"responses":{"201":{"description":"Audit completed. `report` is present when `lead_gated` is false or an `email_first` request satisfied the gate; `teaser` is present only in `teaser` mode or alongside an entitled `report`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditCreated"}}}},"400":{"description":"Malformed body or missing `slug` (`invalid_request`, `message`); failed field validation (`invalid_request`, `details`); or a URL that failed the SSRF allow-list (`invalid_url`, with a `reason`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"The embedding agency is out of credits (`insufficient_credits`). An unreadable meter returns this too — the meter fails closed, because an audit spends money.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Turnstile verification failed (`captcha_failed`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown widget slug (`not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Body exceeded 8 KiB (`invalid_request`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-IP rate limit, or an abuse cap per audited domain / per agency. Cap responses carry `scope` and `retry_after_seconds`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/audit/shared/{token}":{"get":{"operationId":"getSharedAudit","summary":"Read a shared audit report (ANONYMOUS, link-authenticated)","description":"The report behind a share link an agency generated for a completed audit. ANONYMOUS: no API key and no scope — the 256-bit `token` in the path IS the credential.\n\nSpends nothing. Unlike POST /audit, which runs a new scan on the embedding agency's credits, this reads a report that was already paid for, so there is no meter and no captcha on it.\n\nThe link is REVOCABLE. Revoking it removes the token, so a revoked URL is indistinguishable from one that never existed — both answer 404, as do a malformed token and an unknown one. That is deliberate: any other answer would tell a caller enumerating tokens which ones are real.\n\n`report` is null while the audit is still running, and stays null if it failed. An empty report is never fabricated to fill the field.\n","security":[],"parameters":[{"name":"token","in":"path","required":true,"description":"The share credential. Treat as a secret — anyone holding it can read the report.","schema":{"type":"string","minLength":32,"maxLength":128,"pattern":"^[A-Za-z0-9_-]+$"}}],"responses":{"200":{"description":"The shared audit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SharedAudit"}}}},"404":{"description":"Unknown, revoked or malformed token (`not_found`). The three are not distinguished — see the description above.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-IP rate limit (`rate_limited`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/audit/shared/{token}/pdf":{"get":{"operationId":"getSharedAuditPdf","summary":"Download a shared audit as a branded PDF (ANONYMOUS, link-authenticated)","description":"The same report as its JSON sibling, rendered as the owning agency's branded PDF. Same credential, same 404 policy, and a tighter rate limit because a render is far more expensive than a read.\n\nThe document is written in the OWNING AGENCY's language, not the caller's — it is a deliverable about one site for one client, so there is no `?lang=` override.\n\nA white-label agency's PDF carries no Pitchko attribution, in the page or in the document metadata.\n","security":[],"parameters":[{"name":"token","in":"path","required":true,"description":"The share credential. Treat as a secret.","schema":{"type":"string","minLength":32,"maxLength":128,"pattern":"^[A-Za-z0-9_-]+$"}}],"responses":{"200":{"description":"The branded PDF.","content":{"application/pdf":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"Unknown, revoked or malformed token (`not_found`), or the audit has no report yet (`no_report`). A branded PDF of nothing would read as a finding, so none is produced.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Per-IP rate limit (`rate_limited`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/audit/{id}/lead":{"post":{"operationId":"unlockPublicAudit","summary":"Unlock a gated audit with an email (ANONYMOUS)","description":"The email gate on the audit widget: captures the visitor's email and returns the full report the teaser withheld. Anonymous, like the endpoint that produced the audit.\n\nRequires a FRESH Turnstile token; the one spent starting the audit is not reusable. The audit id is scoped to the agency resolved from `slug`, so one agency's widget can never unlock another agency's audit — anything outside that scope reads as `404 not_found`.\n\nThis endpoint spends nothing — the audit already ran.\n","security":[],"parameters":[{"name":"id","in":"path","required":true,"description":"The `audit_id` returned by POST /audit.","schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditLeadRequest"}}}},"responses":{"200":{"description":"The unlocked report","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditUnlocked"}}}},"400":{"description":"Malformed body or missing `slug` (`invalid_request`); or a rejected email — `invalid_email` (bad shape) or `disposable_email`.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Turnstile verification failed (`captcha_failed`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown widget slug, or no audit with that id under this agency (`not_found`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Body exceeded 8 KiB (`invalid_request`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Unlock attempts per audit exceeded (`rate_limited`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"parameters":{"BrandId":{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},"AgencyId":{"name":"agency_id","in":"query","required":false,"description":"Required only when the key's user belongs to more than one agency. Omitting it in that case returns 400 `ambiguous_agency` rather than picking one arbitrarily.","schema":{"type":"string","format":"uuid"}}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"pk_live_* / pk_test_*","description":"Keys carry scopes. `decks` grants /decks* and the MCP deck tools (`list_decks`, `get_deck`); `aeo:read` grants /aeo/* (read-only) plus the MCP visibility tools (`list_tracked_brands`, `get_visibility_report`, `get_visibility_history`) and the MCP SEO-audit tools (`list_audits`, `get_audit_report`) — the audit tools are MCP-only, with no REST twin. The MCP endpoint accepts either scope at the handshake; each tool then enforces its own. A call to a surface the key lacks returns 403 `insufficient_scope` (not 401 — the credential is valid). Keys issued before scopes existed were backfilled to `decks` only. Widened 2026-08: `aeo:read` additionally unlocks the MCP SEO-audit reads, so earlier `aeo:read` keys gain that read surface automatically."}},"schemas":{"DeckCreateRequest":{"type":"object","additionalProperties":false,"if":{"properties":{"slide_image_source":{"const":"aiGenerated"}},"required":["slide_image_source"]},"then":{"required":["slide_image_style"]},"else":{"not":{"required":["slide_image_style"]}},"required":["brand_name","brand_url","country","language","tone_prefix","content_density","slide_image_source"],"properties":{"brand_name":{"type":"string","minLength":1,"maxLength":200},"brand_url":{"type":"string","format":"uri","maxLength":2000},"country":{"type":"string","description":"ISO 3166-1 alpha-2; must be one of the supported markets.","enum":["TR","GB","DE","FR","ES","IT","NL","BE","AT","CH","SE","NO","DK","FI","PL","CZ","PT","IE","GR","RO","HU","BG","HR","SK","RU","UA","US","CA","MX","BR","AR","CL","CO","JP","KR","CN","IN","AU","NZ","SG","MY","TH","ID","PH","AE","SA","EG"]},"target_country":{"type":"string","description":"Optional research-market override (ISO 3166-1 alpha-2, one of the supported markets). Omit to inherit `country`. Affects keyword/SERP/trends/ad research geo only — not the deck language or content.","enum":["TR","GB","DE","FR","ES","IT","NL","BE","AT","CH","SE","NO","DK","FI","PL","CZ","PT","IE","GR","RO","HU","BG","HR","SK","RU","UA","US","CA","MX","BR","AR","CL","CO","JP","KR","CN","IN","AU","NZ","SG","MY","TH","ID","PH","AE","SA","EG"]},"language":{"type":"string","minLength":2,"maxLength":35,"example":"tr","description":"Deck content language. Accepted values are the languages this deployment has a deck message pack for; `tr` and `en` are always among them. An unsupported code is rejected with 400 and the error lists what is supported. This is the DECK's language, which is independent of the panel UI locale."},"tone_prefix":{"type":"string","description":"CHANGED 2026-08 — `data_driven` added. It cites only figures present in the data and states when one is missing, rather than estimating.","enum":["professional","data_driven","bold"]},"content_density":{"type":"string","enum":["concise","standard","detailed"]},"slide_image_source":{"type":"string","description":"CHANGED 2026-08 — narrowed from eight values to three. Retired: pexels, webFreeToUseCommercially, themeAccent, giphy, placeholder. Sending a retired value is rejected with 400. Decks created before the change keep their stored value. See the [migration table](/docs/guide#10-guarantees) in the integration guide.","enum":["pictographic","aiGenerated","noImages"]},"slide_image_style":{"type":"string","description":"The look of the AI-generated slide imagery. **Required when `slide_image_source` is `aiGenerated`** (a request that omits it is rejected with 400 and no deck is generated), and **forbidden** for the other two sources — there is no image to style. There is no default: the choice is visual and belongs to the caller, so we do not pick one on your behalf.","enum":["clean-minimal","photorealistic","flat-illustration","gradient-abstract","dark-premium","isometric-3d"]},"slide_text_mode":{"type":"string","description":"CHANGED 2026-08 — `preserve` removed. It silently disabled content_density (Gamma ignores textOptions.amount under preserve), so a request using it got a wall of text with no warning.","enum":["condense"]},"slide_template_id":{"type":"string","maxLength":100,"description":"Defaults to the standard template; if provided, must be a valid active template id."},"excluded_sections":{"type":"array","maxItems":50,"items":{"type":"string"}},"agency_id":{"type":"string","format":"uuid"},"competitor_urls":{"type":"array","maxItems":10,"items":{"type":"string","format":"uri"}},"notes":{"type":"string","maxLength":5000},"budget_range":{"type":"string","maxLength":100}}},"DeckCreated":{"type":"object","required":["id","status","sandbox"],"properties":{"id":{"type":"string"},"status":{"$ref":"#/components/schemas/PublicStatus"},"sandbox":{"type":"boolean"}}},"DeckStatus":{"type":"object","required":["id","status","sandbox"],"properties":{"id":{"type":"string"},"status":{"$ref":"#/components/schemas/PublicStatus"},"brand_name":{"type":["string","null"]},"sandbox":{"type":"boolean"},"result":{"oneOf":[{"$ref":"#/components/schemas/DeckResult"},{"type":"null"}]},"error":{"type":["string","null"]},"created_at":{"type":["string","null"],"format":"date-time"},"completed_at":{"type":["string","null"],"format":"date-time"}}},"DeckResult":{"description":"Export links for a completed deck. They point at the generator's own asset host, not at Pitchko, so **treat them as impermanent** — we do not re-host the file. Working assumption: do not count on more than about 2 days. That is a ceiling and an estimate, not a measured guarantee (probe cohort 2026-08-04: still alive at 52h — a FLOOR, since the cohort had not died yet — and gone by 346h; the exact boundary is being measured). Download when you first see the link rather than storing it. There is no re-export endpoint: once a link is dead, recovery means the dashboard's Regenerate action (no credit) or a fresh POST /decks (one credit).","type":"object","properties":{"pdf_url":{"type":["string","null"]},"pptx_url":{"type":["string","null"]},"export_expired_at":{"type":["string","null"],"format":"date-time","description":"ADDED 2026-09 (additive). When Pitchko first measured the export refused by the asset host. Non-null means the URLs above are dead — do not retry them; regenerate instead. `null` means \"not measured gone\", not \"alive\"."}}},"PublicStatus":{"type":"string","enum":["pending","processing","completed","failed","cancelled"]},"Brand":{"type":"object","required":["id","name","domain","country","competitor_count","has_prompts","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"domain":{"type":"string"},"industry":{"type":["string","null"]},"country":{"type":"string","description":"ISO 3166-1 alpha-2."},"competitor_count":{"type":"integer"},"has_prompts":{"type":"boolean","description":"Whether the brand has a tracked prompt set (i.e. is scannable)."},"created_at":{"type":"string","format":"date-time"}}},"BrandList":{"type":"object","required":["brands","truncated","sandbox"],"properties":{"brands":{"type":"array","items":{"$ref":"#/components/schemas/Brand"}},"truncated":{"type":"boolean","description":"True when the page filled to `limit` — more brands may exist."},"sandbox":{"type":"boolean"}}},"Metrics":{"type":"object","description":"Each value is a 0..1 ratio, or null when NOT MEASURED. A 0 means measured and never mentioned — do not coerce null to 0.","required":["mention_sov","citation_sov","presence","sentiment"],"properties":{"mention_sov":{"type":["number","null"],"minimum":0,"maximum":1},"citation_sov":{"type":["number","null"],"minimum":0,"maximum":1},"presence":{"type":["number","null"],"minimum":0,"maximum":1},"sentiment":{"type":["number","null"],"minimum":0,"maximum":1}}},"LetterGrade":{"type":["string","null"],"enum":["A+","A","A-","B+","B","B-","C+","C","C-","D","F",null]},"ReportBody":{"type":"object","description":"The MEASUREMENT half of an AI-visibility report — identical on the key-authenticated endpoint (§5) and the anonymous grader (§6). Both compose it with `allOf`, so a parser written for one reads the other. Only the envelope around it differs; see `VisibilityReport` and `GraderReport`.\n\nNull-vs-zero is load-bearing throughout: 0 is a MEASURED absence, null is an ABSENT measurement. Never coerce one to the other.\n","required":["overall","metrics","engines_queried","prompt_count","runs_per_prompt","engines","engine_failures","categories","competitors","cited_sources","cited_sources_truncated","recommendations"],"properties":{"overall":{"type":"object","properties":{"score":{"type":["number","null"],"minimum":0,"maximum":100},"grade":{"$ref":"#/components/schemas/LetterGrade"}}},"metrics":{"$ref":"#/components/schemas/Metrics"},"engines_queried":{"type":"array","description":"Engines the run ATTEMPTED — i.e. the ones whose transport was configured, not the ones that answered. Equals `engines` ∪ `engine_failures`. Read `engine_failures` before presenting this as coverage (ADDED August 2026, BL-2683: previously a caller had no way to tell the two apart, so a scan whose provider quota was exhausted advertised five engines and scored three).\n","items":{"type":"string"}},"prompt_count":{"type":"integer"},"runs_per_prompt":{"type":"integer","minimum":1},"engines":{"type":"array","items":{"type":"object","properties":{"engine":{"type":"string"},"score":{"type":["number","null"],"minimum":0,"maximum":100},"grade":{"$ref":"#/components/schemas/LetterGrade"},"sample_size":{"type":"integer"},"metrics":{"$ref":"#/components/schemas/Metrics"}}}},"engine_failures":{"type":"array","description":"ADDED August 2026 (BL-2683). Entries of `engines_queried` that produced no scorable data this run, and why. Always present; `[]` means every queried engine answered. Disjoint from `engines`.\n\nA failed engine is EXCLUDED from the composite score, never scored as 0 — the null-vs-zero rule applies to coverage as much as to metrics. The reason is a stable key describing OUR measurement, not a judgement about the graded brand.\n","items":{"type":"object","required":["engine","reason"],"properties":{"engine":{"type":"string"},"reason":{"type":"string","enum":["quota","auth","rate_limit","timeout","error","empty"]}}}},"categories":{"type":"array","items":{"type":"object","properties":{"category":{"type":"string"},"score":{"type":["number","null"],"minimum":0,"maximum":100},"grade":{"$ref":"#/components/schemas/LetterGrade"},"sample_size":{"type":"integer"}}}},"competitors":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"domain":{"type":["string","null"]},"mention_sov":{"type":["number","null"]},"citation_sov":{"type":["number","null"]}}}},"cited_sources":{"type":"array","items":{"type":"object","properties":{"domain":{"type":"string"},"citations":{"type":"integer"},"is_own_domain":{"type":"boolean"},"sample_url":{"type":["string","null"]}}}},"cited_sources_truncated":{"type":"boolean","description":"True when the cited-source list hit its cap and rows were dropped."},"sentiment_breakdown":{"oneOf":[{"type":"object","properties":{"positive":{"type":"integer"},"neutral":{"type":"integer"},"negative":{"type":"integer"}}},{"type":"null"}]},"ai_mode_signals":{"oneOf":[{"type":"object","properties":{"attribution_density":{"type":["number","null"]},"shopping_placement_count":{"type":"integer"}}},{"type":"null"}]},"recommendations":{"type":"array","description":"Catalog KEYS only, language-neutral. The localised title/why/action resolve at render time.","items":{"type":"object","properties":{"id":{"type":"string"},"priority":{"type":"string","enum":["high","medium","low"]},"leverage":{"type":"string"},"source":{"type":"string","enum":["onsite","metric"]}}}},"prompt_results":{"type":"array","description":"Present only when `include=prompts`.","items":{"type":"object","properties":{"prompt_id":{"type":"string"},"prompt_text":{"type":["string","null"]},"category":{"type":"string"},"engine":{"type":"string"},"mentioned":{"type":"boolean"},"cited":{"type":"boolean"},"sentiment":{"type":["string","null"],"enum":["positive","neutral","negative",null]},"competitors_mentioned":{"type":"array","items":{"type":"string"}},"mention_excerpt":{"type":["string","null"],"description":"NEW August 2026. A short window (≤ ~240 characters) from the engine's own answer, around the first place the brand's name appeared — the evidence behind `mentioned: true`. Whitespace is collapsed and the window is snapped to word boundaries, so a leading or trailing `…` marks where it was cut. Null on every row where `mentioned` is false, and on reports scanned before this field existed."}}}},"prompt_results_truncated":{"type":"boolean"}}},"VisibilityReport":{"description":"A tracked brand's latest scored report, for a KEY-AUTHENTICATED caller. The shared measurement body plus this surface's own envelope: the roster brand id, the run status, the sandbox flag, and `cost_usd` — which is here because this caller is the agency being metered for the scan.\n","allOf":[{"$ref":"#/components/schemas/ReportBody"},{"type":"object","required":["report_version","brand","run","sandbox"],"properties":{"report_version":{"type":"string"},"brand":{"type":"object","description":"Identity AS SCANNED — name/domain/country are the point-in-time copy from the run, while `id` is the live roster key.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"domain":{"type":"string"},"country":{"type":"string"},"language":{"type":"string"}}},"run":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string"},"scanned_at":{"type":"string","format":"date-time"}}},"cost_usd":{"type":["number","null"],"description":"What the scan cost US. Present ONLY on this key-authenticated surface — the anonymous grader omits it entirely."},"sandbox":{"type":"boolean"}}}]},"VisibilityHistory":{"type":"object","required":["brand_id","points","sandbox"],"properties":{"brand_id":{"type":"string","format":"uuid"},"points":{"type":"array","description":"Newest first. Metric values are 0–100 percentages, or null if not measured.","items":{"type":"object","required":["run_id","scanned_at","score","mention_sov","citation_sov","presence","prompt_set_version"],"properties":{"run_id":{"type":"string","format":"uuid"},"scanned_at":{"type":"string","format":"date-time"},"score":{"type":["number","null"]},"mention_sov":{"type":["number","null"]},"citation_sov":{"type":["number","null"]},"presence":{"type":["number","null"]},"prompt_set_version":{"type":["integer","null"],"minimum":1,"description":"Which version of the brand's tracked prompt set this scan measured. Two points with DIFFERENT versions were produced by asking different questions, so the movement between them is not a change in the brand's visibility — do not draw a trend across the boundary. null means the lineage is unknown (a scan from before versioning); it does NOT mean version 1."}}}},"sandbox":{"type":"boolean"}}},"GradeRequest":{"type":"object","additionalProperties":false,"required":["slug","url","sector"],"properties":{"slug":{"type":"string","description":"The embedding agency's PUBLIC widget slug. Identifies whose credits, branding, market and locale the grade runs under. Not a secret."},"url":{"type":"string","minLength":1,"maxLength":2048,"description":"The site to grade. Must survive the SSRF allow-list — public hosts only; the on-site probes additionally pin to the validated IPs."},"sector":{"type":"string","minLength":2,"maxLength":80,"description":"REQUIRED, and the field that makes the grade mean anything: the two highest-value question categories ask \"who are the best X\", and with no X the grade degrades to three brand-name lookups. Newlines are collapsed to spaces rather than rejected, so a multi-line value cannot append instructions to the engine prompt.\n"},"email":{"type":"string","format":"email","minLength":3,"maxLength":320,"description":"NEW 2026-08 (BL-2713). REQUIRED when the widget's `lead_mode` is `email_first`, rejected as unnecessary in no other mode. Sent on THIS request rather than the lead endpoint because a Turnstile token is single-use, so a two-call flow would have to re-run the challenge."},"consent":{"type":"boolean","description":"NEW 2026-08. Optional marketing consent, recorded with the lead. Never a condition of receiving the report — consent made mandatory to obtain a service is not valid consent."},"turnstile_token":{"type":"string","maxLength":4096,"description":"Optional in the schema, enforced by the route — omitting it fails verification and returns 403 `captcha_failed`."}}},"GradeCreated":{"type":"object","required":["run_id","lead_gated"],"properties":{"run_id":{"type":"string","format":"uuid","description":"Pass to POST /aeo/grade/{id}/lead to unlock the full report."},"teaser":{"description":"CHANGED 2026-08: present only when `lead_mode` is `teaser`, or when `lead_gated` is false, or when the gate was already satisfied by an `email_first` request. Absent otherwise.","oneOf":[{"$ref":"#/components/schemas/VisibilityTeaser"},{"type":"null"}]},"lead_gated":{"type":"boolean","description":"True when the agency has lead capture enabled. Which gate then runs is `lead_mode`."},"lead_mode":{"type":"string","enum":["email_first","gated","teaser"],"description":"NEW 2026-08 (BL-2713). Present only when `lead_gated` is true — with no gate there is no \"which gate\". `email_first` requires `email` on the request itself; the other two use the email endpoint."},"lead_captured":{"type":"boolean","description":"NEW 2026-08. Present only when a lead was captured by THIS request (`email_first`). False means the insert failed; the report is still returned. Absent means no lead was attempted here."},"report":{"description":"CHANGED 2026-08: present when the caller is entitled — `lead_gated` false, or an `email_first` request that carried a valid email.","oneOf":[{"$ref":"#/components/schemas/GraderReport"},{"type":"null"}]}}},"VisibilityTeaser":{"type":"object","description":"The pre-email projection: aggregate shape only, never the diagnosis. This is an ALLOW-list — any future report field that explains a weakness stays on the withheld side by default.\n\nsnake_case like every other v1 payload: the teaser ships in the SAME JSON body as the report, so a second convention here would put two casings side by side in one response.\n","required":["brand_name","domain","overall_score","overall_grade","engines","categories","counts","prompt_count"],"properties":{"brand_name":{"type":"string"},"domain":{"type":"string"},"overall_score":{"type":["number","null"],"minimum":0,"maximum":100},"overall_grade":{"$ref":"#/components/schemas/LetterGrade"},"engines":{"type":"array","items":{"type":"object","required":["engine","score","grade","invisible"],"properties":{"engine":{"type":"string"},"score":{"type":["number","null"],"minimum":0,"maximum":100},"grade":{"$ref":"#/components/schemas/LetterGrade"},"invisible":{"type":"boolean","description":"A MEASURED zero: the engine returned scorable answers and the brand appeared in none of them. An engine with no sample is not invisible — it is unread, and says so via `score: null`."}}}},"categories":{"type":"array","items":{"type":"object","required":["category","score","grade"],"properties":{"category":{"type":"string"},"score":{"type":["number","null"],"minimum":0,"maximum":100},"grade":{"$ref":"#/components/schemas/LetterGrade"}}}},"counts":{"type":"object","description":"`engines_invisible` and `engines_not_measured` are DISJOINT and must not be summed — one is a measured zero, the other an absent reading.","required":["engines_queried","engines_invisible","engines_not_measured","recommendations"],"properties":{"engines_queried":{"type":"integer"},"engines_invisible":{"type":"integer"},"engines_not_measured":{"type":"integer"},"recommendations":{"type":"integer"}}},"prompt_count":{"type":"integer"}}},"GraderReport":{"description":"The full grader report, for an ANONYMOUS caller.\n\nThe measurement body is `ReportBody` — the SAME shape /aeo/brands/{id}/report returns. One parser reads both. Only the envelope differs, and only where the callers genuinely differ: there is no roster `brand.id` (a graded site is a stranger's domain, not a brand you track) and no run `status` (a report exists only once the run completed).\n\n`cost_usd` is deliberately ABSENT. The scan's cost is Pitchko's spend, not a property of the graded brand; the key-authenticated report exposes it because that caller is the agency being metered, while this one is an anonymous visitor.\n\nCHANGED 2026-07: this response was previously the pipeline's internal camelCase shape (`overallScore`, `engineScores`, `citedSources`).\n","allOf":[{"$ref":"#/components/schemas/ReportBody"},{"type":"object","required":["report_version","brand","run"],"properties":{"report_version":{"type":"string"},"brand":{"type":"object","description":"The graded subject as scanned. No `id` — see above.","required":["name","domain","country","language"],"properties":{"name":{"type":"string"},"domain":{"type":"string"},"country":{"type":"string"},"language":{"type":"string"}}},"run":{"type":"object","required":["id","scanned_at"],"properties":{"id":{"type":"string","format":"uuid"},"scanned_at":{"type":"string","format":"date-time"}}}}}]},"GradeLeadRequest":{"type":"object","additionalProperties":false,"required":["slug","email"],"properties":{"slug":{"type":"string","description":"The same public widget slug used to start the grade."},"email":{"type":"string","minLength":3,"maxLength":320,"description":"Rejected with 400 `invalid_email` on a bad shape, or `disposable_email` for a throwaway provider."},"turnstile_token":{"type":"string","maxLength":4096,"description":"Must be a FRESH token — the one spent starting the grade is not reusable. Omitting it returns 403 `captcha_failed`."},"consent":{"type":"boolean","description":"Optional marketing-consent flag stored with the lead."}}},"GradeUnlocked":{"type":"object","required":["report","lead_captured"],"properties":{"report":{"$ref":"#/components/schemas/GraderReport"},"lead_captured":{"type":"boolean","description":"Whether the email was newly recorded. False on a repeat unlock of the same run — the report is still returned."}}},"AuditRequest":{"type":"object","additionalProperties":false,"required":["slug","url"],"properties":{"slug":{"type":"string","description":"The embedding agency's PUBLIC widget slug."},"url":{"type":"string","format":"uri"},"email":{"type":"string","format":"email","minLength":3,"maxLength":320,"description":"NEW 2026-08 (BL-2713). REQUIRED when the widget's `lead_mode` is `email_first`. Sent here rather than on the lead endpoint because a Turnstile token is single-use — a two-call flow would have to re-run the challenge behind the visitor."},"consent":{"type":"boolean","description":"NEW 2026-08. Optional marketing consent recorded with the lead, never a condition of receiving the report."},"turnstile_token":{"type":["string","null"]}}},"AuditLeadRequest":{"type":"object","additionalProperties":false,"required":["slug","email"],"properties":{"slug":{"type":"string"},"email":{"type":"string","format":"email"},"consent":{"type":["boolean","null"]},"turnstile_token":{"type":["string","null"]}}},"AuditCreated":{"type":"object","required":["audit_id","lead_gated"],"properties":{"audit_id":{"type":"string","format":"uuid"},"teaser":{"description":"CHANGED 2026-08: present only when `lead_mode` is `teaser`, or when `lead_gated` is false, or when an `email_first` request already satisfied the gate. Absent otherwise.","oneOf":[{"$ref":"#/components/schemas/AuditTeaser"},{"type":"null"}]},"lead_gated":{"type":"boolean","description":"True when the agency has lead capture on. Which gate then runs is `lead_mode`."},"lead_mode":{"type":"string","enum":["email_first","gated","teaser"],"description":"NEW 2026-08 (BL-2713). Present only when `lead_gated` is true."},"lead_captured":{"type":"boolean","description":"NEW 2026-08. Present only when a lead was captured by THIS request (`email_first`)."},"report":{"$ref":"#/components/schemas/AuditReport"}}},"AuditUnlocked":{"type":"object","required":["report","lead_captured"],"properties":{"report":{"$ref":"#/components/schemas/AuditReport"},"lead_captured":{"type":"boolean","description":"False when the lead insert failed. The report is still returned — the visitor kept their side of the bargain."}}},"AuditTeaser":{"type":"object","description":"The pre-email projection: how many checks failed, never WHICH. Issue keys, affected URLs and the PSI detail are the paywalled half.\n","required":["url","overall_score","categories","issue_counts","pages_crawled"],"properties":{"url":{"type":"string","format":"uri"},"overall_score":{"type":"number","minimum":0,"maximum":100},"categories":{"type":"array","items":{"type":"object","required":["category","score","grade"],"properties":{"category":{"type":"string"},"score":{"type":["number","null"],"minimum":0,"maximum":100},"grade":{"$ref":"#/components/schemas/LetterGrade"}}}},"issue_counts":{"type":"object","required":["total","error","warning","notice"],"properties":{"total":{"type":"integer"},"error":{"type":"integer"},"warning":{"type":"integer"},"notice":{"type":"integer"}}},"pages_crawled":{"type":"integer","description":"Pages the crawl actually returned — a SAMPLE SIZE, never the site total. We do not know how many pages a site has."}}},"ShareBranding":{"type":"object","description":"The owning agency's presentation, and nothing else it owns.\n\nFive keys, listed by hand. The agency's lead-capture configuration — its CTA text, headline, subheadline, whether the widget gates on an email and in which mode — is NOT here: a share reader is already the agency's client, so how the agency runs its funnel is not theirs to see.\n","required":["agency_name","logo_url","primary_color","accent_color","white_label"],"properties":{"agency_name":{"type":"string"},"logo_url":{"type":"string","nullable":true},"primary_color":{"type":"string","nullable":true,"description":"Applied only for a white-label agency; null otherwise."},"accent_color":{"type":"string","nullable":true},"white_label":{"type":"boolean","description":"Whether Pitchko attribution is suppressed. A property of the document, so a renderer can decide whether to draw the footer."}}},"SharedAudit":{"type":"object","description":"A completed audit behind a share link, for an ANONYMOUS caller.\n\n`cost_usd` is deliberately ABSENT at every depth, here and inside `report` — the scan's cost is Pitchko's spend, not a fact about the audited site. So are the audit's internal id and the owning agency's id.\n","required":["url","mode","status","shared_at","report","branding"],"properties":{"url":{"type":"string","description":"The audited site."},"mode":{"type":"string","enum":["instant","crawl"]},"status":{"type":"string","enum":["running","crawling","complete","failed"],"description":"An unrecognised internal status reads as `running` — not a completed report and not a confirmed failure."},"shared_at":{"type":"string","format":"date-time","nullable":true,"description":"When the share link was minted."},"report":{"allOf":[{"$ref":"#/components/schemas/AuditReport"}],"nullable":true,"description":"Null while the audit is running, and if it failed. Never a fabricated empty report."},"branding":{"allOf":[{"$ref":"#/components/schemas/ShareBranding"}],"nullable":true,"description":"Null when the agency row could not be read."}}},"AuditReport":{"type":"object","description":"The full SEO-audit report, for an ANONYMOUS caller.\n\n`cost_usd` is deliberately ABSENT — the scan's cost is Pitchko's spend, not a fact about the audited site.\n\n`null` is not `0` throughout: a category with `score: null` was not evaluated, while `score: 0` was evaluated and failed everything.\n\nCHANGED 2026-07: this response was previously the pipeline's internal camelCase shape (`overallScore`, `categoryGrades`, `pagesCrawled`) and additionally carried `costUsd`.\n","required":["report_version","url","mode","fetched_at","pages_crawled","estimated_indexed_pages","overall_score","overall_grade","categories","issues","page_types","psi","narrative","competitor"],"properties":{"report_version":{"type":"string"},"url":{"type":"string","format":"uri"},"mode":{"type":"string","enum":["instant","crawl"]},"fetched_at":{"type":"string","format":"date-time"},"pages_crawled":{"type":"integer","description":"A crawl SAMPLE SIZE, never the site total."},"estimated_indexed_pages":{"type":["integer","null"],"description":"Approximate indexed-page count from a `site:` query. Null when not measured — never a fabricated total."},"overall_score":{"type":"number","minimum":0,"maximum":100},"overall_grade":{"allOf":[{"$ref":"#/components/schemas/LetterGrade"}],"description":"The letter grade for `overall_score`, DERIVED at serialisation time. The report persists only the score (a stored grade can drift from it), but the band table is not public, so this key is what lets you reproduce the grade our own widget renders."},"categories":{"type":"array","items":{"$ref":"#/components/schemas/AuditCategoryGrade"}},"issues":{"type":"array","items":{"type":"object","required":["key","category","severity","scope","failed_pages","checked_pages","fail_rate","sampled_count","affected_urls","priority","page_type_id"],"properties":{"key":{"type":"string","description":"Catalog key, not copy. The label, explanation and fix resolve from the catalog at render time, so this stays language-neutral."},"category":{"type":"string"},"severity":{"type":"string","enum":["error","warning","notice"]},"scope":{"type":"string","enum":["template","page","site"]},"failed_pages":{"type":"integer"},"checked_pages":{"type":"integer"},"fail_rate":{"type":"number","minimum":0,"maximum":1},"sampled_count":{"type":["integer","null"],"description":"Null = the rollup did not size this template, NOT zero pages."},"affected_urls":{"type":"array","description":"A SAMPLE of failing URLs, capped — never the full set.","items":{"type":"string"}},"priority":{"type":"number","minimum":0},"page_type_id":{"type":["string","null"]}}}},"page_types":{"type":"array","items":{"type":"object","required":["id","label","url_signature","sampled_count","sample_pages"],"properties":{"id":{"type":"string"},"label":{"type":"string"},"url_signature":{"type":"string"},"sampled_count":{"type":"integer","description":"Pages of this type IN THE CRAWL SAMPLE — not the site total."},"sample_pages":{"type":"array","items":{"type":"string"}}}}},"psi":{"oneOf":[{"$ref":"#/components/schemas/PsiSummary"},{"type":"null"}]},"narrative":{"oneOf":[{"type":"object","required":["sections"],"properties":{"sections":{"type":"array","items":{"$ref":"#/components/schemas/AuditNarrativeSection"}}}},{"type":"null"}]},"competitor":{"oneOf":[{"type":"object","required":["url","score","grade","categories","fetched_at","narrative"],"properties":{"url":{"type":"string","format":"uri"},"score":{"type":"number","minimum":0,"maximum":100},"grade":{"$ref":"#/components/schemas/LetterGrade"},"categories":{"type":"array","items":{"$ref":"#/components/schemas/AuditCategoryGrade"}},"fetched_at":{"type":"string","format":"date-time"},"narrative":{"oneOf":[{"$ref":"#/components/schemas/AuditNarrativeSection"},{"type":"null"}]}}},{"type":"null"}]}}},"AuditCategoryGrade":{"type":"object","required":["category","score","grade","issue_count","evaluated_checks"],"properties":{"category":{"type":"string"},"score":{"type":["number","null"],"minimum":0,"maximum":100,"description":"Null when the crawl evaluated no scored check in this category."},"grade":{"$ref":"#/components/schemas/LetterGrade"},"issue_count":{"type":"integer"},"evaluated_checks":{"type":"integer"}}},"AuditNarrativeSection":{"type":"object","required":["id","heading","body","bullets"],"properties":{"id":{"type":"string"},"heading":{"type":"string"},"body":{"type":"string"},"bullets":{"type":"array","items":{"type":"string"}}}},"PsiSummary":{"type":"object","description":"Free Google PageSpeed Insights signal. Category scores are 0–1 as Lighthouse returns them, or null when PSI was unavailable (the audit degrades to DataForSEO-only rather than failing).","required":["performance","accessibility","best_practices","seo","core_web_vitals","insights"],"properties":{"performance":{"type":["number","null"],"minimum":0,"maximum":1},"accessibility":{"type":["number","null"],"minimum":0,"maximum":1},"best_practices":{"type":["number","null"],"minimum":0,"maximum":1},"seo":{"type":["number","null"],"minimum":0,"maximum":1},"core_web_vitals":{"type":"object","required":["mobile","desktop"],"properties":{"mobile":{"$ref":"#/components/schemas/CoreWebVitals"},"desktop":{"$ref":"#/components/schemas/CoreWebVitals"}}},"insights":{"type":"array","description":"Failing Lighthouse audits with no catalog entry, carried through generically so we do not hardcode ids Lighthouse renames.","items":{"type":"object","required":["id","title","description","score"],"properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"score":{"type":["number","null"]}}}}}},"CoreWebVitals":{"type":"object","required":["lcp","cls","tbt","fcp","tti","inp"],"properties":{"lcp":{"type":["number","null"]},"cls":{"type":["number","null"]},"tbt":{"type":["number","null"]},"fcp":{"type":["number","null"]},"tti":{"type":["number","null"]},"inp":{"type":["number","null"],"description":"Replaced `fid`, which Google retired. Null on reports written before that change."}}},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"},"message":{"type":"string"},"retry_after_seconds":{"type":"integer"},"reason":{"type":"string","description":"Set by `invalid_url` on /aeo/grade — why the URL failed the SSRF guard."},"scope":{"type":"string","enum":["domain","agency"],"description":"Which abuse cap tripped on /aeo/grade."},"required_scope":{"type":"string","description":"Set by `insufficient_scope` on the key-authenticated endpoints."},"details":{"description":"Field-level validation errors. The deck endpoints emit objects; the /aeo/grade endpoints emit `\"path: message\"` strings.","oneOf":[{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"message":{"type":"string"}}}},{"type":"array","items":{"type":"string"}}]}}}}}}