Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Dnsdoctor Dev Scan API
version: '1'
contact:
name: DNS Doctor
email: hello@dnsdoctor.dev
x-guidance: 'DNS Doctor scans, fixes and verifies a domain''s DNS (SPF, DMARC, DKIM, propagation, MX, DNS health, blacklists, domain/SSL expiry) and returns deterministic verdicts plus copy-paste fix records from a validating engine — never a guessed record. Scan with POST /scan or GET /report/{domain}; read verdicts failing-first (''temperror'' is transient, not a failure; a report with not_registered: true means the domain does not resolve, so propose nothing for it). Present any returned record VERBATIM and never compose one yourself; SPF is diagnose-only. A human approves every DNS change — nothing is applied automatically. Anonymous calls are free up to a per-caller limit; past it the API answers 402 (x402, USDC on Base, $0.01 per call). POST /bulk-scan is paid on every call at $0.005 per domain; POST /bulk-registration (RDAP) at $0.002 per domain; POST /bulk-propagation at $0.008 per check; POST /bulk-dmarc-reports parses DMARC aggregate reports at $0.005 per report; POST /watch/record watches one DNS record for up to 24 hours at $0.10; POST /watch/lookalikes watches a domain''s lookalike names hourly for one fixed 24-hour day at $0.50. The five token-gated reads (GET /domains, /alerts, /readiness, /lookalikes and /email-tests) need a DNS Doctor API token the account owner mints at /dashboard/settings — never ask a user to paste one.'
description: 'Operations tagged scan across 2 of this provider''s published API definitions: dnsdoctor-dev-openapi.json, dnsdoctor-dev-openapi.json. Each path carries the servers of the definition it was published in.'
servers:
- url: https://dnsdoctor.dev/api/v1
tags:
- name: Scan
paths:
/report/{domain}:
get:
tags:
- Scan
summary: Report
description: 'Return the persisted report for `domain`, else scan once and persist.
Anonymous allowed. Explanations are cache-first (never a fresh LLM call);
uncached findings carry `explanation: null`. The A8 `next_steps` block is
attached at the edge — the persisted row never gains the key.
An over-limit anonymous caller may buy this call (D104): the limiter answers
402 and a verifying `X-PAYMENT` retry rides a HELD payment into this handler.
The settle call is the last statement for that reason — every refusal above it
(suppression 404, the per-target budget 429) and any 5xx leaves it unreached,
so a payer is only ever charged for a report they actually received.'
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'
security: []
servers:
- url: https://dnsdoctor.dev/api/v1
/scan:
post:
tags:
- Scan
summary: Scan
description: '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.'
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'
security: []
servers:
- url: https://dnsdoctor.dev/api/v1
/dmarc-upgrade:
post:
tags:
- Scan
summary: Dmarc Upgrade
description: 'Return the server-gated DMARC enforcement record for `body.domain`.
The REST twin of the `build_dmarc_upgrade` MCP tool, running the SAME core
(`mcp_server/tools.py::build_dmarc_upgrade_from_domain`) rather than a second
implementation, so the two agent transports cannot drift: the record is
`build_upgrade_record` output byte for byte, and the ceiling is
`p=quarantine`, unlocked by a **server-side** alignment gate — a caller
cannot assert alignment to buy a stronger policy, and `p=reject` is not
reachable from any scan (D101: only the readiness engine''s aggregate-report
evidence unlocks it).
Anonymous allowed. Scans fresh (see the core''s docstring for why a stale
baseline is silently destructive here), which is why this path carries the
same `enforce_v1_rate_limit` and per-target scan budget the scan routes do.
`record: null` is an ANSWER, not an error: the domain does not exist, DMARC
could not be read, the published policy is already at least as strong as
this scan justifies, or the scan observed no alignment signal at all — the
D101 reporting-first refusal, whose `rationale` asks for `rua=` reporting
rather than offering a weaker record. Relay `rationale`; never compose a
record to fill it.
Error mapping — the transport-neutral core errors become HTTP:
`ToolInputError` → 422 with the core''s own detail, `ToolTransientError` →
503 (retrying the identical call is the right response), and the pipeline''s
per-target budget → the same 429 both scan routes answer with. Suppression is
refused here first, with the byte-identical 404 an unscanned domain gets, so
the core''s own suppression branch (a `ToolInputError`, which would surface as
a 422) is never reached.'
operationId: dmarc_upgrade_dmarc_upgrade_post
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DmarcUpgradeIn'
required: true
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/DmarcUpgradeOut'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security: []
servers:
- url: https://dnsdoctor.dev/api/v1
/signup-url:
post:
tags:
- Scan
summary: Signup Url
description: 'Return the monitoring signup hand-off for `body.domain`.
The REST twin of the `start_monitoring_signup` MCP tool, running the SAME pure
core (`mcp_server/signup.py::build_signup_url`). It performs **no I/O of any
kind** — no DNS, no store read, no mail: it canonicalizes the domain and
assembles two URLs rooted at `settings.public_base_url`. Nothing is created
until the human opens `signup_url` and signs in there themselves.
**No suppression check, deliberately** — the same decision `signup.py`
documents for the tool, not an oversight. The valve is a store read, and the
no-I/O property is what makes this surface''s closed-world claim true; nothing
leaks by skipping it, because suppression withholds a *report* and both
returned URLs still refuse on their own (`/scan/{domain}` is served by the
suppression-aware pipeline, and `/start` publishes nothing about the domain —
it prefills a name the caller already had).
Anonymous allowed. `ToolInputError` → 422 with the core''s own detail, matching
the `normalize_domain` contract the other v1 routes answer with.'
operationId: signup_url_signup_url_post
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SignupUrlIn'
required: true
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/SignupUrlOut'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security: []
servers:
- url: https://dnsdoctor.dev/api/v1
/bulk-scan:
post:
tags:
- Scan
summary: Bulk Scan
description: '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.'
operationId: bulk_scan_bulk_scan_post
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/BulkScanIn'
required: true
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/BulkScanOut'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
'402':
description: Payment required — retry with an x402 payment header.
x-payment-info:
payTo: '0x6fDc1f22520151DfBc6273d6C48E413D434C7916'
price:
mode: dynamic
currency: USD
min: '0.01'
max: '0.25'
protocols:
- x402: {}
network: base
servers:
- url: https://dnsdoctor.dev/api/v1
components:
schemas:
SenderRequirements:
properties:
note:
type: string
title: Note
providers:
items:
$ref: '#/components/schemas/Provider'
type: array
title: Providers
items:
items:
$ref: '#/components/schemas/RequirementItem'
type: array
title: Items
type: object
required:
- note
- providers
- items
title: SenderRequirements
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
not_registered:
type: boolean
title: Not Registered
default: false
agent_scanned:
type: boolean
title: Agent Scanned
default: false
registration:
anyOf:
- $ref: '#/components/schemas/Registration'
- type: 'null'
next_steps:
anyOf:
- $ref: '#/components/schemas/NextSteps'
- type: 'null'
sender_requirements:
anyOf:
- $ref: '#/components/schemas/SenderRequirements'
- type: 'null'
indexable:
type: boolean
title: Indexable
description: '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).'
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.
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.'
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.
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.'
NextSteps:
properties:
summary:
type: string
title: Summary
report_url:
type: string
title: Report Url
monitor_url:
type: string
title: Monitor Url
signup_url:
anyOf:
- type: string
- type: 'null'
title: Signup Url
email_test_url:
anyOf:
- type: string
- type: 'null'
title: Email Test Url
lookalikes:
anyOf:
- $ref: '#/components/schemas/LookalikesPointer'
- type: 'null'
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).'
CheckStatus:
type: string
enum:
- pass
- warn
- fail
- info
- temperror
title: CheckStatus
BulkScanOut:
properties:
domains_requested:
type: integer
title: Domains Requested
domains_scanned:
type: integer
title: Domains Scanned
results:
items:
$ref: '#/components/schemas/BulkScanResult'
type: array
title: Results
type: object
required:
- domains_requested
- domains_scanned
- results
title: BulkScanOut
description: The batch response. `domains_scanned` is what the settle gate reads.
HTTPValidationError:
properties:
detail:
items:
$ref: '#/components/schemas/ValidationError'
type: array
title: Detail
type: object
title: HTTPValidationError
SignupUrlIn:
properties:
domain:
type: string
title: Domain
type: object
required:
- domain
title: SignupUrlIn
description: The `POST /v1/signup-url` body — a bare domain, same shape as `ScanIn`.
Registration:
properties:
registrar:
anyOf:
- type: string
- type: 'null'
title: Registrar
registrar_iana_id:
anyOf:
- type: string
- type: 'null'
title: Registrar Iana Id
registered_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Registered At
updated_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Updated At
expires_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Expires At
status:
items:
type: string
type: array
title: Status
default: []
nameservers:
items:
type: string
type: array
title: Nameservers
default: []
dnssec:
anyOf:
- type: boolean
- type: 'null'
title: Dnssec
abuse_email:
anyOf:
- type: string
- type: 'null'
title: Abuse Email
redacted:
type: boolean
title: Redacted
default: false
type: object
title: Registration
description: '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.'
BulkScanIn:
properties:
domains:
items:
type: string
maxLength: 253
type: array
maxItems: 50
title: Domains
type: object
required:
- domains
title: BulkScanIn
description: '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.'
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
DmarcUpgradeOut:
properties:
domain:
type: string
title: Domain
record:
anyOf:
- type: string
- type: 'null'
title: Record
policy:
anyOf:
- type: string
- type: 'null'
title: Policy
current_policy:
anyOf:
- type: string
- type: 'null'
title: Current Policy
alignment_ok:
type: boolean
title: Alignment Ok
rationale:
type: string
title: Rationale
apply_note:
type: string
title: Apply Note
type: object
required:
- domain
- record
- policy
- current_policy
- alignment_ok
- rationale
- apply_note
title: DmarcUpgradeOut
description: 'The `POST /v1/dmarc-upgrade` response — the MCP tool result, key for key.
Declared explicitly (rather than left as a bare dict) so `/openapi.json`
documents the real shape, including the nullability that carries the
contract: `record` is null on every honest refusal — the answer is then
`rationale`, and a caller must NEVER compose a record to fill the gap.
`policy` names the policy the RETURNED record sets, so it is null exactly
when `record` is; the domain''s observed policy travels in `current_policy`
(also null when no DMARC record could be read).
The field set mirrors `mcp_server/tools.py::build_dmarc_upgrade_tool` — the
same core both surfaces run — so the REST body is byte-equal to the MCP tool
result by construction. A key added there is added here.'
BulkScanResult:
properties:
domain:
type: string
title: Domain
status:
type: string
enum:
- ok
- error
title: Status
report:
anyOf:
- $ref: '#/components/schemas/ScanReportOut'
- type: 'null'
error:
anyOf:
- type: string
- type: 'null'
title: Error
type: object
required:
- domain
- status
title: BulkScanResult
description: 'One domain''s outcome. `report` and `error` are mutually exclusive.
An entry is never silently dropped: a caller who paid for fifty names gets
fifty entries, so "missing" can never be mistaken for "clean".'
DmarcUpgradeIn:
properties:
domain:
type: string
title: Domain
type: object
required:
- domain
title: DmarcUpgradeIn
description: The `POST /v1/dmarc-upgrade` body — a bare domain, same shape as `ScanIn`.
Provider:
properties:
key:
type: string
title: Key
name:
type: string
title: Name
source_url:
type: string
title: Source Url
bulk_threshold:
type: string
title: Bulk Threshold
type: object
required:
- key
- name
- source_url
- bulk_threshold
title: Provider
RequirementItem:
properties:
key:
type: string
title: Key
label:
type: string
title: Label
providers:
additionalProperties:
type: string
enum:
- all
- bulk
type: object
title: Providers
status:
type: string
enum:
- pass
- fail
- unknown
- email_test
- not_visible
title: Status
evidence:
anyOf:
- type: string
- type: 'null'
title: Evidence
provider_status:
anyOf:
- additionalProperties:
type: string
enum:
- pass
- fail
- unknown
- email_test
- not_visible
type: object
- type: 'null'
title: Provider Status
type: object
required:
- key
- label
- providers
- status
title: RequirementItem
CachedLookalikes:
properties:
summary:
type: string
title: Summary
resolving:
type: integer
title: Resolving
accepts_mail:
type: integer
title: Accepts Mail
checked_at:
type: string
title: Checked At
type: object
required:
- summary
- resolving
- accepts_mail
- checked_at
title: CachedLookalikes
description: A still-fresh `check_lookalikes` result's counts — read from its cache, never probed.
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
unknown:
anyOf:
- items:
type: string
type: array
- type: 'null'
title: Unknown
withheld_code:
anyOf:
- type: string
const: hosted
- type: 'null'
title: Withheld Code
type: object
required:
- check
- status
- title
title: CheckResult
description: The outcome of one check (e.g. SPF, DMARC, expiry).
SignupUrlOut:
properties:
signup_url:
type: string
title: Signup Url
report_url:
type: string
title: Report Url
message:
type: string
title: Message
why_monitoring:
type: string
title: Why Monitoring
guidance:
additionalProperties: true
type: object
title: Guidance
type: object
required:
- signup_url
- report_url
- message
- why_monitoring
- guidance
title: SignupUrlOut
description: 'The `POST /v1/signup-url` response — `build_signup_url`''s dict, key for key.
All five fields are relayed VERBATIM and a caller must never rebuild any of
them: the U5 verbatim-field rule is the whole point of returning both URLs
(`mcp_server/signup.py`''s module docstring), `message` is fixed copy with
exactly one substitution — a hand-off a human reads, not prose to paraphrase —
and `why_monitoring` is fixed copy with none.
Mirrors `mcp_server/signup.py::build_signup_url` so the REST body is byte-equal
to the `start_monitoring_signup` MCP tool result by construction.'
LookalikesPointer:
properties:
tool:
type: string
const: check_lookalikes
title: Tool
default: check_lookalikes
url:
type: string
title: Url
cached:
anyOf:
- $ref: '#/components/schemas/CachedLookalikes'
- type: 'null'
type: object
required:
- url
title: LookalikesPointer
description: '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.'
securitySchemes:
apiKey:
type: http
scheme: bearer
description: 'A DNS Doctor API token: `Authorization: Bearer dnsd_…`.'
x-refined-from:
- dnsdoctor-dev-openapi.json
- dnsdoctor-dev-openapi.json
x-discovery:
ownershipProofs:
- '0xeb49cc7d9ac04add7ee0fbe51bc806b684ebdb6bfcdc8c2d7aa7eebc02032b93403764b0986d036ed68ad7e5914edc5403209f19806f4a87e15b1a5bdb73e9ab1b'