{"openapi":"3.1.0","info":{"title":"DNS Doctor API","description":"Public REST API for the DNS Doctor scanner. Deterministic verdicts and fixengine-generated records; explanations are cache-first (an API call never triggers a fresh LLM call).","version":"1"},"paths":{"/report/{domain}":{"get":{"tags":["scan"],"summary":"Report","description":"Return the persisted report for `domain`, else scan once and persist.\n\nAnonymous allowed. Explanations are cache-first (never a fresh LLM call);\nuncached findings carry `explanation: null`. The A8 `next_steps` block is\nattached at the edge — the persisted row never gains the key.","operationId":"report_report__domain__get","parameters":[{"name":"domain","in":"path","required":true,"schema":{"type":"string","title":"Domain"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanReportOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/scan":{"post":{"tags":["scan"],"summary":"Scan","description":"Force a fresh scan of `body.domain`, persist, and return the report.\n\nAnonymous allowed. Cache-first explanations (no fresh LLM call) and the A8\n`next_steps` block, same as `GET /v1/report`. \"Force\" is bounded by the same\n`scan_dedup_window_minutes` reuse the web `POST /scan` applies — see\n`api/pipeline.py::run_scan_pipeline` for why that bound belongs on this path\ntoo.","operationId":"scan_scan_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanReportOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/domains":{"get":{"tags":["domains"],"summary":"Domains","description":"List the token's account's verified monitored domains + latest statuses.\n\nToken required (401 without one). Each domain carries the latest\n`scan_history` snapshot's per-check statuses + `scanned_at` — never a letter\ngrade (re-validation #8). A verified domain with no snapshot yet returns\n`scanned_at: null` and empty `checks`.","operationId":"domains_domains_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DomainsOut"}}}}},"security":[{"BearerAuth":[]}]}},"/enroll":{"post":{"tags":["enroll"],"summary":"Enroll","description":"Send an agent-requested monitoring-trial DOI mail; return pending-consent.\n\n422 only on a malformed email/domain (a plain client error, not an existence\nsignal). 429 on the per-IP cap. Everything else is a silent 202 handled in\nthe shared core — the response never reveals whether a mail was sent.","operationId":"enroll_enroll_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnrollIn"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnrollOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"CheckResult":{"properties":{"check":{"type":"string","title":"Check"},"status":{"$ref":"#/components/schemas/CheckStatus"},"title":{"type":"string","title":"Title"},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Raw"},"explanation":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Explanation"},"fix_record":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fix Record"},"details":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Details"}},"type":"object","required":["check","status","title"],"title":"CheckResult","description":"The outcome of one check (e.g. SPF, DMARC, expiry)."},"CheckStatus":{"type":"string","enum":["pass","warn","fail","info","temperror"],"title":"CheckStatus"},"DomainCheck":{"properties":{"check":{"type":"string","title":"Check"},"status":{"type":"string","title":"Status"}},"type":"object","required":["check","status"],"title":"DomainCheck","description":"One check of a domain's latest snapshot — name + status only.\n\nDeliberately NO letter grade: the A-F grade is `frontend/lib/grade.ts`, a\nversioned frontend contract. Recomputing it server-side would duplicate the\nrubric and drift; the API returns the raw per-check statuses instead."},"DomainOut":{"properties":{"domain":{"type":"string","title":"Domain"},"scanned_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scanned At"},"checks":{"items":{"$ref":"#/components/schemas/DomainCheck"},"type":"array","title":"Checks"}},"type":"object","required":["domain","scanned_at","checks"],"title":"DomainOut","description":"A monitored domain with its latest scan snapshot's per-check statuses.\n\n`scanned_at` is null (and `checks` empty) when the domain is verified but has\nno snapshot yet — awaiting its first monitored scan, not an error."},"DomainsOut":{"properties":{"domains":{"items":{"$ref":"#/components/schemas/DomainOut"},"type":"array","title":"Domains"}},"type":"object","required":["domains"],"title":"DomainsOut","description":"The `GET /v1/domains` response: the account's verified monitored domains."},"EnrollIn":{"properties":{"email":{"type":"string","title":"Email"},"domain":{"type":"string","title":"Domain"}},"type":"object","required":["email","domain"],"title":"EnrollIn","description":"The `POST /v1/enroll` body — the human's email and the domain to watch."},"EnrollOut":{"properties":{"status":{"type":"string","title":"Status"},"message":{"type":"string","title":"Message"}},"type":"object","required":["status","message"],"title":"EnrollOut","description":"The pending-consent response: nothing exists until the human clicks."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"NextSteps":{"properties":{"summary":{"type":"string","title":"Summary"},"report_url":{"type":"string","title":"Report Url"},"monitor_url":{"type":"string","title":"Monitor Url"}},"type":"object","required":["summary","report_url","monitor_url"],"title":"NextSteps","description":"The hand-off block's wire shape (documentation only — see `build_next_steps`)."},"ScanIn":{"properties":{"domain":{"type":"string","title":"Domain"}},"type":"object","required":["domain"],"title":"ScanIn","description":"The `POST /v1/scan` body — a bare domain (no Turnstile: agents can't solve one)."},"ScanReportOut":{"properties":{"domain":{"type":"string","title":"Domain"},"checks":{"items":{"$ref":"#/components/schemas/CheckResult"},"type":"array","title":"Checks"},"scanned_at":{"type":"string","format":"date-time","title":"Scanned At"},"explanations_pending":{"type":"boolean","title":"Explanations Pending","default":false},"dns_provider_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Dns Provider Key"},"next_steps":{"anyOf":[{"$ref":"#/components/schemas/NextSteps"},{"type":"null"}]},"indexable":{"type":"boolean","title":"Indexable","description":"Thin-page shield: worth indexing only if mail-capable AND substantive.\n\nMail-capable = any of mx/spf/dmarc observed a record; substantive = at\nleast `_MIN_RESOLVED_CHECKS` checks resolved (non-TEMPERROR). NXDOMAIN,\nparked, and garbage domains fail the first gate; half-broken scans fail\nthe second. Non-indexable reports stay fully viewable — they are only\nnoindex + sitemap-excluded. Serializes into `report_json`; older\npersisted JSON without the key still validates (computed on read).","readOnly":true},"dns_provider":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Dns Provider","description":"The serialized assist-only NS provider, looked up by key at read time.\n\nMirrors the domain-row `dns_provider` object: the key is stored, the full\nobject (display name, console URL, guide anchor, collision note) is\nresolved from the `DNS_PROVIDERS` table on serialize, so a URL/copy fix\nnever needs a re-scan. A key that no longer exists in the table (or None)\nserializes as None — the report page then omits the provider mention.","readOnly":true}},"type":"object","required":["domain","checks","indexable","dns_provider"],"title":"ScanReportOut","description":"What a scan endpoint actually returns: the report plus the A8 hand-off.\n\nDeclared as a response model rather than by widening `ScanReport` itself:\n`ScanReport` round-trips through the persisted `report_json`, and a field\nthere would be frozen into stored rows. This subclass is never handed to\n`save_report`, so the non-persistence property is untouched while\n`/openapi.json` still documents the real response shape — which the agent\nchannel (`/llms-full.txt`, U7's api-catalog) depends on being accurate."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","description":"A DNS Doctor API token: `Authorization: Bearer dnsd_…`."}}},"servers":[{"url":"https://dnsdoctor.dev/api/v1"}]}