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

# Bulk Scan

`POST /bulk-scan`

## Request

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

Scan 2-50 domains in one paid call. Anonymous; **402 on every call**.

Priced per domain, computed from the batch — the 402 quotes the price for
THIS request. Body validation runs first, so a malformed batch is a 422 that
costs nothing; an inert deployment (no x402 credentials) answers 503, because
a surface that can only be sold cannot be given away.

`_UNPAID_OFFER_LIMIT` bounds the two shapes the lane's own budget never sees:
a request with no payment header, and one whose body is refused before the
lane opens. The lane meters only headers it had to verify, so both would
otherwise be unmetered on the one route with no limiter dependency. The
header-less half is spent in `bound_unpaid_request` — a route dependency, so
a body pydantic refuses on its own is on the counted path too.

## Request body

- BulkScanIn: The request body: 2-50 domains, canonicalized and deduped server-side. The upper bound is enforced TWICE, and both layers earn their place. At the model (below) so pydantic refuses an oversized array during parse; and again in `_normalize_batch`, which is what applies the bound to the DEDUPED count — the number actually scanned and priced. ⚠️ This docstring used to say the model-level bound was deliberately absent, on the reasoning that a pydantic-raised 422 skips the handler and so escapes metering. That reasoning was answered by `_metered_validation_handler`, which meters exactly those refusals, and the bound was added by the 2026-09-05 review — a caller with a junk payment header could otherwise force a large allocation before anything counted it. Metering no longer depends on where the refusal is raised, so the bound belongs at the earliest layer.
  - `domains` string[], required

## Response `200`

Successful Response

- BulkScanOut: The batch response. `domains_scanned` is what the settle gate reads.
  - `domains_requested` integer, required
  - `domains_scanned` integer, required
  - `results` BulkScanResult[], required
    - `domain` string, required
    - `status` 'ok' | 'error', required
    - `report` 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.
    - `error` string, nullable

## Other responses

- `402`: Payment required — retry with an x402 payment header.
- `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)
