---
title: "Scan"
method: POST
path: "/scan"
tags: ["scan"]
---

# Scan

`POST /scan`

## Request

- Base URL: `https://dnsdoctor.dev/api/v1`
- URL: `https://dnsdoctor.dev/api/v1/scan`
- Auth: none required

Force a fresh scan of `body.domain`, persist, and return the report.

Anonymous allowed. Cache-first explanations (no fresh LLM call) and the A8
`next_steps` block, same as `GET /v1/report`. "Force" is bounded by the same
`scan_dedup_window_minutes` reuse the web `POST /scan` applies — see
`api/pipeline.py::run_scan_pipeline` for why that bound belongs on this path
too.

## Request body

- ScanIn: The `POST /v1/scan` body — a bare domain (no Turnstile: agents can't solve one).
  - `domain` string, required

## Response `200`

Successful Response

- ScanReportOut: What a scan endpoint actually returns: the report plus the A8 hand-off. Declared as a response model rather than by widening `ScanReport` itself: `ScanReport` round-trips through the persisted `report_json`, and a field there would be frozen into stored rows. This subclass is never handed to `save_report`, so the non-persistence property is untouched while `/api/v1/openapi.json` documents the real response shape — which the agent channel (`/llms-full.txt`, U7's api-catalog) depends on being accurate.
  - `domain` string, required
  - `checks` CheckResult[], required
    - `check` string, required
    - `status` 'pass' | 'warn' | 'fail' | 'info' | 'temperror', required
    - `title` string, required
    - `raw` string, nullable
    - `explanation` string, nullable
    - `fix_record` string, nullable
    - `details` string[], nullable
  - `scanned_at` string, date-time
  - `explanations_pending` boolean
  - `dns_provider_key` string, nullable
  - `not_registered` boolean
  - `agent_scanned` boolean
  - `registration` Registration: What a registry publishes about one domain. Observation only. Every field is optional because every field is redactable: a registry that publishes only status codes and nameservers is the normal post-GDPR case, not a failure. `status` carries the registry's own strings verbatim (RFC 9083 spells them `client transfer prohibited`; some registries still emit EPP camelCase) — normalizing them would invent a vocabulary neither side uses.
    - `registrar` string, nullable
    - `registrar_iana_id` string, nullable
    - `registered_at` string, date-time, nullable
    - `updated_at` string, date-time, nullable
    - `expires_at` string, date-time, nullable
    - `status` string[]
    - `nameservers` string[]
    - `dnssec` boolean, nullable
    - `abuse_email` string, nullable
    - `redacted` boolean
  - `next_steps` NextSteps: The hand-off block's wire shape (documentation only — see `build_next_steps`).
    - `summary` string, required
    - `report_url` string, required
    - `monitor_url` string, required
    - `signup_url` string, nullable
    - `lookalikes` LookalikesPointer: D130: the scan's pointer to the separate `check_lookalikes` call. Filled by `api/lookalike_pointer.py` on the AGENT surfaces only (MCP `scan_domain`/`get_report`, v1 `/report` + `/scan`). The web `GET /report` that feeds the indexable `/scan/[domain]` page never fills it (the D128 no-teaser rule), so it serializes there as null.
      - `tool` 'check_lookalikes'
      - `url` string, required
      - `cached` CachedLookalikes: A still-fresh `check_lookalikes` result's counts — read from its cache, never probed.
        - `summary` string, required
        - `resolving` integer, required
        - `accepts_mail` integer, required
        - `checked_at` string, required
  - `indexable` boolean, required: Thin-page shield: worth indexing only if mail-capable AND substantive. Mail-capable = any of mx/spf/dmarc observed a record; substantive = at least `_MIN_RESOLVED_CHECKS` checks resolved (non-TEMPERROR). NXDOMAIN, parked, and garbage domains fail the first gate; half-broken scans fail the second. Non-indexable reports stay fully viewable — they are only noindex + sitemap-excluded. Serializes into `report_json`; older persisted JSON without the key still validates (computed on read).
  - `dns_provider` object, nullable, required: The serialized assist-only NS provider, looked up by key at read time. Mirrors the domain-row `dns_provider` object: the key is stored, the full object (display name, console URL, guide anchor, collision note) is resolved from the `DNS_PROVIDERS` table on serialize, so a URL/copy fix never needs a re-scan. A key that no longer exists in the table (or None) serializes as None — the report page then omits the provider mention.

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/dnsdoctor/apis/dns-doctor-api.md) · [All operations](https://skmtc.dev/dnsdoctor/apis/dns-doctor-api/llms.txt) · [OpenAPI document](https://skmtc.dev/dnsdoctor/apis/dns-doctor-api/revisions/1d0114806e76?raw)
