---
title: WatchFor live diagnostics API
description: Run any of 26 live checks against any public target from WatchFor's probe fleet — DNS, propagation, TLS, HTTP, ping, traceroute, ports, blacklists, email policy, mail server grades, PHP and software versions — over REST, MCP or A2A.
canonical: https://watchfor.io/docs/api/diagnostics
---

# WatchFor live diagnostics API

Run any of 26 live checks against any public target from WatchFor's probe fleet — DNS, propagation, TLS, HTTP, ping, traceroute, ports, blacklists, email policy, mail server grades, PHP and software versions — over REST, MCP or A2A.

| Method | Path | Scope | operationId |
| --- | --- | --- | --- |
| GET | `/v1/diagnostics` | read | `listDiagnostics` |
| POST | `/v1/diagnostics/{slug}` | **write** | `runDiagnostic` |
| POST | `/v1/diagnostics/diagnose-target` | **write** | `diagnoseTarget` |

Everything else in this API reports what WatchFor has already recorded.
Diagnostics **measure right now**: each call sends real traffic from a
real machine in one of the [probe locations](/docs/reference/probe-locations)
and hands back the raw answer. The target does not have to be one of your
monitors — it does not have to be yours at all, as long as it is publicly
reachable.

These are the same fleet-backed checks that Dashboard → **Toolbox** and
the public [free tools](/docs/tools) run — same fleet, same limits, same
code path — so a check gives the same answer whoever asks.

> **Why this exists**
>
> An AI agent can already reason about an outage; what it cannot do is
> stand in Singapore and ask a nameserver a question. That is the part
> WatchFor lends it.

## Write scope, not read

`GET /v1/diagnostics` (the catalog) needs `read`. **Running** a check
needs a **`write`** key — even though nothing in your account changes.

A `read` key is what you hand out when the holder must not be able to
cause effects. A diagnostic run sends packets from WatchFor's probe IPs
to a third-party host and spends a metered, per-plan allowance. Both are
effects outside the caller's own data, so a run is not a read. With a
`read` key, `POST /v1/diagnostics/…` returns `403 forbidden`.

## The 26 checks

Each slug is the canonical identifier everywhere — the URL segment, the
MCP `tool` value, and the allowance bucket it spends (`dns-health`, which
has no free-tool page of its own, spends `dns-propagation`'s). Short aliases
(`dns`, `port-check`, `api-test`, `rdap`, `web-vitals`, `php`, `exposure`, `smtp`, `mail-check`, `ntp`, `ws`, `redirects`, …) are accepted
and the response always echoes the canonical slug back.

| Slug | The question it answers | `target` | Options |
| --- | --- | --- | --- |
| `smart-audit` | Everything at once for a site: domain expiry, HTTPS/TLS, DNS records and email anti-spoofing, as one scored report. | URL or domain | — |
| `cwv-check` | How fast does this page feel to a real browser — LCP, CLS, TBT and the Lighthouse performance score? | Page URL | `preset` (`desktop`\|`mobile`) |
| `dns-lookup` | What does DNS return for this name right now — A/AAAA/MX/TXT/NS/CNAME/SOA/CAA/SRV and more, optionally from a specific resolver? | Domain, IPv4 or IPv6 | `recordType`, `resolver` |
| `dns-propagation` | Has a DNS change propagated yet — what does every one of our probe locations see for this record? | Domain, IPv4 or IPv6 | `recordType`, `resolver`, `resolverMode` |
| `dns-health` | Is this domain's DNS set up well — at least two nameservers on separate networks, no lame delegation, parent and zone agreeing, SOA serials in sync, DNSSEC valid, CAA published — as an A+…F grade? | Domain name | `propagation`, `recordType` |
| `whois` | Who owns this domain, when does it expire, which registrar and nameservers does the registry list (live RDAP, cache bypassed)? | Domain name | — |
| `ping` | Is this host reachable by ICMP from a given location, and what is the round-trip time and packet loss? | Hostname or IP | `count` (1–20) |
| `traceroute` | Which network path do packets take to this host from a given location, and where do latency or loss appear? | Hostname or IP | `count` (1–10) |
| `port-checker` | Is this TCP port open from a given location, and what banner does the service send? | Hostname or IP | `port` **(required)**, `readBanner` |
| `blacklist` | Is this IP or host listed on the major DNS blocklists (RBLs) that mail servers consult? | IP, or a host we resolve first | `resolver` |
| `ssl-check` | What certificate does this host serve — issuer, subject/SAN names, validity dates and days until expiry? | Hostname or IP | `port`, `serverName` |
| `tls-grade` | How good is this host's TLS configuration — an A+…F grade with the protocol versions, cipher suites and weaknesses behind it? | Hostname or IP | `port`, `serverName` |
| `mcp-check` | Is this Model Context Protocol endpoint alive and spec-compliant — protocol version, capabilities and the tools it advertises? | MCP endpoint URL | — |
| `http-headers` | What does this URL actually return right now — status code, redirect chain, response headers and timing? | `http(s)` URL | `method` (`GET`\|`HEAD`), `followRedirects` |
| `php-version-checker` | Which PHP version does this site announce (X-Powered-By, Server), is it vulnerable to php2shell (CVE-2026-109618/109619) or other known CVEs, and is its branch past end of life — graded A+…F? | `http(s)` URL or bare domain | — |
| `software-exposure-checker` | Which software versions does this page reveal in its headers and HTML — web server, PHP, CMS, JavaScript libraries — and which are outdated, past end of life or have known CVEs, graded A+…F? | `http(s)` URL or bare domain | — |
| `api-tester` | What does this API endpoint answer to a full request (method, headers, body) — status, headers and the complete response body? | `http(s)` URL | `method`, `headers`, `body` |
| `cdn-check` | Which CDN serves this URL, which edge answers in each region, is it actually being cached, and where is delivery going wrong? | URL (a path is allowed and recommended) | — |
| `sitemap-check` | Does this domain publish a valid sitemap, is it reachable from `robots.txt`, and do its URLs resolve? | Domain name | — |
| `brotli-check` | Does this domain serve Brotli and gzip compression — which Content-Encoding comes back when a client offers only br, and when it offers only gzip? | Domain name | — |
| `email-health` | Is this domain protected against email spoofing — are SPF, DKIM and DMARC present and correctly configured? | Domain name | `variant`, `dkimSelector` |
| `redirect-chain` | Where does this URL end up — every redirect hop with its status code and Location, the final URL, and whether the chain is longer, slower or less secure than it should be? | `http(s)` URL | `maxHops` (1–10) |
| `websocket-test` | Can a WebSocket be opened to this URL — handshake time, the server's IP, a ping/pong round-trip, and does it answer a message with the expected reply? | `ws(s)://` URL (`http(s)://` is upgraded) | `sendMessage`, `expectPattern`, `checkPing` |
| `smtp-test` | Does this mail server accept connections — its banner, whether STARTTLS is offered, whether reverse DNS matches, and whether it relays mail for strangers? | Hostname or IP | `port` (25/465/587), `checkOpenRelay`, `checkPtr` |
| `mail-server-check` | How secure are this domain's mail servers — an A+…F grade per service (inbound MX, SMTP submission, IMAP, POP3) for TLS, certificates, STARTTLS, plain-text login, open relay, MTA-STS, TLS-RPT and DANE, checked from three regions? | Domain, mail host, or `host:port` on a mail port (25, 465, 587, 143, 993, 110, 995) | — |
| `ntp-test` | Does this NTP server answer, and what stratum, clock offset, round-trip delay and leap indicator does it report per sample? | Hostname or IP | `port`, `count` (1–5) |

The list, the exact option JSON Schema per check and your organization's
live budget all come from `GET /v1/diagnostics`, so a client never has to
hard-code this table.

### WatchFor Grades in the result

Eleven checks are graded: `tls-grade`, `http-headers` (security headers),
`cwv-check` (performance, lab data), `mcp-check`, `email-health` (with the
default `variant: "all"`), `mail-server-check`, `cdn-check`, `smart-audit`,
`dns-health`, `php-version-checker` and `software-exposure-checker`. Their run response carries a `grade` next to the raw
`result` — the same letter the dashboard and the free tool show, from the
same versioned rules ([how WatchFor grades](/grades)). The catalog marks
them with `graded: true`; other checks have no `grade` key.

```json
"grade": {
  "letter": "C",
  "label": "Weak",
  "rubric": "headers-2026.10",
  "capped_by": [
    {
      "id": "csp.missing",
      "title": "No Content-Security-Policy",
      "cap": "C",
      "fix": "Start from a strict policy, deploy it as Content-Security-Policy-Report-Only, fix what it reports, then enforce it. Add the origins you really load from."
    },
    {
      "id": "nosniff.missing",
      "title": "X-Content-Type-Options: nosniff missing",
      "cap": "B",
      "fix": "Send X-Content-Type-Options: nosniff on every response."
    }
  ]
}
```

- Every grade starts at A+ and each finding can only cap it. `capped_by`
  lists the findings that hold it down, worst first; `cap` is the highest
  grade each one allows, `fix` the exact change.
- `letter: null` means **not graded**: we could not measure (the server
  did not answer from our location, the run timed out, an older probe
  location lacks the check). `not_graded_reason` says why. It is never a
  verdict on the target.
- `smart-audit` and `cdn-check` are composite scores: they add `score`
  (0–100), and their `capped_by` entries with `cap: null` are sections that
  cost points rather than caps.
- `mcp-check` adds `scope: "auth-discovery"` when the server requires
  sign-in: only transport and sign-in discovery were graded.
- `rubric` (the rules version) changes whenever a rule changes, so a stored result always says
  which rules produced it.

### `dns-health`

The zone itself, graded A+…F: the parent's delegation against the zone's
own NS set, every nameserver asked directly (lame delegation, SOA serials
in sync, networks and ASNs), SOA timers, DNSSEC (signed, validates, or
broken) and CAA — from the location nearest to you. With
`propagation: true` it also fans out to every location for `recordType`
(default `A`) and grades whether they agree, which is exactly the grade the
[DNS Health Checker](/dns-health-checker) page shows. Without it the
propagation rules are simply not evaluated. It spends the `dns-propagation`
allowance. `dns-propagation` itself stays the raw cross-location view and is
not graded. Aliases: `dns-grade`, `dnssec-check`, `nameserver-check`.

### `mail-server-check`

A composite check, like `smart-audit`. From a domain it finds the mail
servers (MX records, then RFC 6186 SRV records, Thunderbird autoconfig and
common names such as `imap.` and `smtp.`), grades each service — inbound
MX on port 25, SMTP submission (587/465), IMAP (993/143), POP3 (995/110) —
and checks reachability, TLS version and certificate from two more regions.
Submission, IMAP and POP3 are graded at the primary location. Inbound MX is
always graded from a location that can reach port 25, because most cloud
networks block outbound port 25; the report names that location. No login
is attempted and no message is sent: the open-relay test on port 25 stops
after `RCPT TO` and resets the session. Expect up to about 60 seconds.
`location_id` pins the primary location; the other locations are chosen
for you. If port 25 still cannot be reached, the inbound-mail service says
so instead of getting a grade. A host (no MX records, or a subdomain such as
`mail.example.com` whose MX points back at itself) is checked on its own mail
ports: they are tried first and only the services that answer are graded;
the others come back as `no_answer` and do not affect the overall grade.
Optional `services` limits the check to `smtp` (inbound MX + submission),
`imap` and/or `pop3`, for a domain or a host; omitted means every service.
Services that are not graded never lower the overall grade. Aliases:
`mail-check`, `mail-server-test`, `mail-grade`, `email-server-check`.

The result is one report: an overall `grade` (the worst service grade),
per-service `services[]` with their endpoints, findings and fixes, the DNS
posture (MTA-STS, TLS-RPT, SPF/DMARC summary), and a `regional` matrix of
what each location saw. Unreachable servers get no grade. The report's own
keys are `camelCase` (`gradeLabel`, `regionalFindings`); the endpoint and
DNS objects inside it keep the probe's `snake_case` (`tls_mode`,
`open_relay`, `mta_sts`). The rubric is the one the
[Mail Server Checker](/mail-server-checker) page explains.

### `php-version-checker`

Passive, like the [PHP Version Checker](/php-version-checker) page it
mirrors: one ordinary `GET` of the page from one probe location (redirects
followed), and one more of `/index.php` on the site it landed on only when
the page showed no PHP version. We read the headers every visitor already
gets (`X-Powered-By`, `Server`, the PHP session cookie); nothing
exploit-like is ever sent. A bare domain becomes `https://`. The grade is
computed on our side against the PHP release, end-of-life and CVE data as
of the run: php2shell, a CVE on CISA's exploited list or one scored 9.0+,
or a branch more than two years past end of life is an **F**; a
distribution build (Debian, Ubuntu, Red Hat, SUSE) that would otherwise be
D or F is capped at C, because the vendor may have backported the fix. A
site that hides its version, or shows no sign of PHP, is **not graded** —
`not_graded_reason` says which. Aliases: `php`,
`php-version`, `php-check`, `php2shell`.

`result` keeps the page's shape: `requests[]` (each URL we fetched, its
status and where it landed), `detection` (`status` `version` / `presence` /
`none`, `version`, `rawVersion`, the header that revealed it, `distro`),
`analysis` for a visible version (branch support dates, patch releases
behind, `php2shell` status with the fixed release, CVE counts by severity
with the worst listed, and the exact `recommendation`), and `data.asOf` —
when the release and CVE data was last refreshed.

### `software-exposure-checker`

One passive `GET` of the page (headers and up to 256 KB of HTML), as on
the [Software Exposure Checker](/software-exposure-checker) page. Every
version the page reveals — web server, PHP, OpenSSL, the CMS, JavaScript
libraries — is matched against release, end-of-life and CVE data and
graded: a server, runtime or CMS version with an exploited or critical CVE,
or more than two years past end of life, is an **F**; a version named in a
header or the HTML caps it at A even when current (front-end library URLs
don't count). PHP is graded by the same rules as `php-version-checker`, so
both agree. When a bot challenge or WAF page
answers instead of the site, the run is still `success: true` (the page
answered) but `grade.letter` is `null` and `not_graded_reason` names the
challenge — retrying from us will not change it. Aliases: `exposure`,
`software-exposure`, `exposure-check`, `software-versions`.

`result` carries `request` (status, final URL), `detection.components[]`
(product, version, where it was seen, confidence), `blocked`, and
`analysis.components[]` worst first, each with its support status, CVEs
and the upgrade to make, plus `analysis.counts` (outdated, end of life,
CVEs, exploited). `detection.bodyTruncated` says when the page was longer
than we read.

```bash
curl -X POST https://watchfor.io/api/v1/diagnostics/php-version-checker \
  -H "Authorization: Bearer wf_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target":"example.com"}'
```

## The catalog

```bash
curl https://watchfor.io/api/v1/diagnostics \
  -H "Authorization: Bearer wf_live_YOUR_KEY"
```

```json
{
  "object": "list",
  "data": [
    {
      "slug": "dns-lookup",
      "label": "DNS Lookup",
      "question": "What does DNS return for this name right now — …?",
      "path": "/v1/diagnostics/dns-lookup",
      "aliases": ["dns", "dns-check"],
      "probe_type": "dns",
      "composite": false,
      "location_aware": true,
      "target": { "field": "domain", "description": "Domain, IPv4 or IPv6 to resolve" },
      "options": { "type": "object", "properties": { "recordType": { "…": "…" } } },
      "allowance": {
        "tool_key": "dns-lookup",
        "limit": 25,
        "remaining": 24,
        "window_seconds": 3600,
        "available": true
      }
    }
  ],
  "has_more": false,
  "bundles": [{ "slug": "diagnose-target", "available": true, "…": "…" }],
  "location_select": true,
  "locations_available": 20,
  "upgrade_url": "https://watchfor.io/pricing",
  "distinct_targets": { "max": 50, "window_seconds": 3600, "used": 3 }
}
```

One call is enough to plan an investigation: what can be asked, what each
answer means, and how much budget is left. There is no second discovery
request.

## Run one check

`POST /v1/diagnostics/{slug}` takes a `target`, optional per-check
`options`, and an optional `location_id`.

```bash
# Is the A record consistent, and what does Google's resolver see?
curl -X POST https://watchfor.io/api/v1/diagnostics/dns-lookup \
  -H "Authorization: Bearer wf_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target":"example.com","options":{"recordType":"A","resolver":"8.8.8.8"}}'
```

```bash
# Is :443 open from a real machine, and what banner comes back?
curl -X POST https://watchfor.io/api/v1/diagnostics/port-checker \
  -H "Authorization: Bearer wf_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target":"example.com","options":{"port":443}}'
```

Options may also be sent flat next to `target` (`{"target":"…","port":443}`);
both shapes are accepted because agents produce both.

### The response envelope

Every single-check run returns the same object, whatever the check:

```json
{
  "object": "diagnostic_run",
  "tool": "dns-lookup",
  "target": "example.com",
  "success": true,
  "error": null,
  "location": {
    "id": "9c2f…",
    "name": "Frankfurt",
    "system_name": "frankfurt-1",
    "region": "Europe",
    "country": "Germany",
    "country_code": "DE",
    "city": "Frankfurt",
    "flag": "🇩🇪",
    "auto": false
  },
  "duration_ms": 142,
  "timed_out": false,
  "result": {
    "success": true,
    "duration_ms": 142,
    "response": { "query_type": "A", "rcode": "NOERROR", "resolver": "8.8.8.8", "records": [] }
  },
  "remaining": 23,
  "allowance": {
    "tool_key": "dns-lookup",
    "limit": 25,
    "remaining": 23,
    "window_seconds": 3600,
    "available": true
  },
  "location_select": true,
  "locations_available": 20,
  "upgrade_url": "https://watchfor.io/pricing",
  "distinct_targets": { "used": 4, "max": 50, "window_seconds": 3600 }
}
```

| Field | Meaning |
| --- | --- |
| `tool` | The canonical slug, even when you called an alias |
| `success` | Whether the **check** succeeded — see below |
| `error` | Why it did not, in one sentence; `null` on success |
| `location` | Which probe answered. `auto: true` means WatchFor picked it |
| `duration_ms` | How long the probe took, end to end |
| `timed_out` | `true` when nothing answered inside the check's budget |
| `result` | The probe's raw result: `{ success, duration_ms, response?, metrics?, error? }`. The `response` shape depends on the check; composites return their aggregate here |
| `remaining` | Runs left for **this check** in the current window; `null` = unlimited |
| `allowance` | The full budget row that `remaining` came from |
| `location_select`, `locations_available`, `upgrade_url` | Whether this plan may pin a location, how many exist, and where the plans are described |
| `distinct_targets` | The hourly anti-scanner ceiling and how much of it is used |

> **success: false is an answer, not an error**
>
> A closed port, an `NXDOMAIN`, an expired or untrusted certificate, a
> failed TLS handshake, a host that doesn't resolve or never replies, or
> a name that resolves to an internal address (refused by the probe) is a
> **`200` with `success: false`** and a populated `error` — the check
> ran, and that is exactly the finding you asked for. The top-level
> `success`/`error` always describe the check's finding; `result` keeps
> the probe's raw answer, including its own `success` and `error`.
> Do not retry it; read it. HTTP error codes are reserved for calls
> that *could not run*: bad input, wrong scope, blocked plan, exhausted
> budget, a target that is a private address as written. A run that timed out also sets
> `timed_out: true`, and it still costs a slot of the allowance, because
> the probe really went out.

### `cdn-check` returns an analysis, not a probe result

Most checks put a single probe's answer in `result`. `cdn-check` is a
composite: it requests your URL three times from **every** probe location
and returns the whole analysis.

> **result keys are camelCase**
>
> The envelope around it is `snake_case` (`duration_ms`, `timed_out`), but
> `result` is the check's own payload passed through untouched — so its
> keys are `camelCase`. The same is true of `smart-audit`. Read
> `result.summary.cacheHitRatio`, not `cache_hit_ratio`.

```json
{
  "url": "https://example.com/static/app.js",
  "hostname": "example.com",
  "scannedAt": "2026-09-10T12:00:00.000Z",
  "provider": {
    "id": "cloudflare",
    "label": "Cloudflare",
    "confidence": "high",
    "evidence": [{ "label": "`cf-ray` response header present", "weight": 45 }]
  },
  "health": {
    "score": 92,
    "letter": "A",
    "categories": [
      { "id": "cache", "label": "Cache", "score": 96, "weight": 25, "reasons": [] }
    ],
    "rubric": "cdn-2026.10"
  },
  "summary": {
    "locationsTotal": 20,
    "locationsResponded": 20,
    "cacheHitRatio": 0.96,
    "ttfb": { "p50": 71, "p75": 96, "p95": 184, "min": 24, "max": 184, "count": 20 }
  },
  "findings": [
    {
      "severity": "warning",
      "category": "cache",
      "code": "cache.regional_miss",
      "title": "Persistent cache misses in Singapore",
      "locations": ["Singapore"],
      "evidence": { "sequences": [{ "location": "Singapore", "requests": ["MISS", "MISS", "MISS"] }] },
      "explanation": "…",
      "action": "…"
    }
  ],
  "locations": [
    {
      "location": { "name": "Warsaw", "region": "Europe", "countryCode": "LT" },
      "status": "responded",
      "cacheSequence": ["MISS", "HIT", "HIT"],
      "cacheOutcome": "warming",
      "pop": "WAW",
      "ttfbMs": 42,
      "bodySha256": "9f2c…"
    }
  ],
  "protocols": { "ipv4": true, "ipv6": true, "http2": true, "http3": null, "brotli": true },
  "consistency": { "identical": true, "comparable": 20, "likelyDynamic": false },
  "cacheControl": { "raw": "public, max-age=3600", "verdict": "cacheable", "notes": ["…"] }
}
```

Three things are worth knowing before you build on it.

**`findings` is the part to read first.** It is produced by fixed rules from
the measurements, never by a language model, so the same inputs always give
the same findings. Each carries a stable `code`, the numbers behind the
claim in `evidence`, and an `action` when there is one to take.

**`null` means "not tested", and never "failed".** `protocols.http3: null`
means our QUIC handshake did not complete and we cannot tell whether that is
your endpoint or our vantage point. `false` is reserved for things we
established — `protocols.ipv6: false` means the hostname publishes no AAAA
record at all, which we read directly from DNS. Treat the two differently.
The same rule governs `summary.cacheHitRatio`: `null` means no edge
published a verdict, which is not the same as `0`.

**A `MISS` on the first request is not a fault.** We cannot know whether an
edge held your object before we asked, so `cacheSequence` is reported as-is
and `cacheOutcome` is the interpretation: `warming` for `MISS → HIT → HIT`,
`never-cached` for three misses, `bypassed` when the edge deliberately did
not cache, `unknown` when the provider published no cache header. Alert on
`never-cached`, not on the presence of a `MISS`.

## Two budgets

A probe costs far more than a REST read, so diagnostics are metered
separately from the [per-plan API request budget](/docs/api/errors#rate-limits).
Two independent budgets apply, and every response tells you where you
stand in both.

### The per-check plan allowance

The same allowance the dashboard spends. Running `ping` through the API
consumes exactly the slot that running `ping` in Dashboard → **Toolbox**
would have — one bucket per check, per organization, per hour, sized by
your plan. There is deliberately no parallel limit system for machines.

Read it from `allowance` / `remaining` in any response, or for every
check at once from the catalog. `remaining: null` means unlimited on this
plan; `available: false` means the plan does not include that check at
all. Exceeding it is `429 rate_limited`; a check your plan excludes is
`403 forbidden`.

### The hourly distinct-target ceiling

**50 distinct targets per organization per hour**, across all
diagnostics. This is a fleet guard, not a plan feature: a key with ad-hoc
probing is a programmable scanner running from WatchFor's IPs, and the
abuse reports would land on the probe fleet every customer depends on. A
real investigation touches a handful of hosts; a sweep touches hundreds.

It limits **breadth, not depth** — the target is collapsed to its
hostname first, so paths, ports, schemes and a sixth check on a host you
already probed this hour are all free. Going over is `429 rate_limited`.
The ceiling is a constant on every plan, up to and including Enterprise:
a bigger plan should not buy a bigger scanner.

Both are reported on every run, so an agent pacing a long investigation
never has to guess:

```json
"remaining": 23,
"distinct_targets": { "used": 4, "max": 50, "window_seconds": 3600 }
```

## Choosing a location

Twenty of the 26 checks are **location-aware** — where the probe stands
changes the answer, so you can pin it with `location_id` (ids come from
[`GET /v1/locations`](/docs/api/locations)):

```bash
curl -X POST https://watchfor.io/api/v1/diagnostics/ping \
  -H "Authorization: Bearer wf_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target":"example.com","location_id":"<location-id>","options":{"count":5}}'
```

Pinning is honoured on plans whose `location_select` is `true`. On other
plans the request still runs — from the location nearest the caller — and
the response says so: `location.auto` is `true` and `location_select` is
`false`. Nothing is refused for want of a plan, and no marketing prose
appears in an error message; the facts arrive as fields
(`location_select`, `locations_available`, `upgrade_url`) for the calling
agent to relay however it wants.

Six checks ignore `location_id`. Three because their answer does not
depend on where you stand: `whois` (a registry lookup), `blacklist` (a
reputation lookup) and `email-health` (published DNS policy). Three
because they choose locations themselves: `dns-propagation` and
`cdn-check` use every location by definition, and `dns-health` runs its
zone checks from the nearest one (and every location for `propagation`).

## `diagnose-target` — the whole picture in one call

`POST /v1/diagnostics/diagnose-target` is the call an agent cannot
assemble on its own: it runs `dns-lookup`, `dns-propagation`, `tls-grade`,
`http-headers` and `ping` **from up to three different regions**, all in
parallel, and returns one verdict.

```bash
curl -X POST https://watchfor.io/api/v1/diagnostics/diagnose-target \
  -H "Authorization: Bearer wf_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target":"example.com","max_locations":3}'
```

```json
{
  "object": "diagnostic_report",
  "target": "example.com",
  "hostname": "example.com",
  "url": "https://example.com",
  "started_at": "2026-09-07T10:00:00.000Z",
  "duration_ms": 1642,
  "verdict": {
    "status": "warnings",
    "summary": "No outage, but 1 thing to look at: tls_weak_configuration.",
    "findings": [
      {
        "code": "tls_weak_configuration",
        "severity": "warning",
        "check": "tls-grade",
        "detail": "TLS configuration graded B."
      }
    ]
  },
  "checks": [
    { "check": "dns-lookup", "label": "DNS Lookup", "status": "ok", "duration_ms": 121, "…": "…" },
    { "check": "tls-grade", "label": "SSL/TLS Grade", "status": "ok",
      "grade": { "letter": "B", "label": "Adequate, with weaknesses", "rubric": "tlsgrade-2026.10b",
        "capped_by": [{ "id": "protocol.legacy", "title": "Obsolete TLS versions enabled", "cap": "B", "fix": "Disable TLS 1.0 and TLS 1.1." }] },
      "…": "…" },
    { "check": "ping", "label": "Ping", "status": "skipped", "reason": "quota_exhausted",
      "detail": "…" }
  ],
  "grades": {
    "tls-grade": { "letter": "B", "label": "Adequate, with weaknesses", "rubric": "tlsgrade-2026.10b", "capped_by": ["…"] },
    "http-headers": { "letter": "A", "label": "Good", "rubric": "headers-2026.10", "capped_by": ["…"] }
  },
  "locations_used": [],
  "location_select": true,
  "locations_available": 20,
  "upgrade_url": "https://watchfor.io/pricing",
  "distinct_targets": { "used": 5, "max": 50, "window_seconds": 3600 }
}
```

- `verdict.status` is `ok`, `warnings`, `problems` or `inconclusive`
  (nothing could run). `findings[].code` is stable and safe to branch on
  — `dns_no_records`, `dns_inconsistent`, `tls_handshake_failed`,
  `http_server_error`, `icmp_packet_loss` and friends — while
  `findings[].detail` is a sentence safe to relay verbatim.
- `grades` holds the [WatchFor Grades](#watchfor-grades-in-the-result) of
  the graded checks in the bundle — `tls-grade` (SSL/TLS) and
  `http-headers` (security headers) — keyed by check; each graded check also
  carries its own `grade`. A skipped check has none.
- `checks[].status` is `ok`, `failed` or `skipped`. A check your plan
  blocks or that is out of budget comes back **`skipped`** with a
  `reason` (`plan_blocked`, `quota_exhausted`, `unavailable`) instead of
  failing the whole call — four answers beat none.
- **A bundle is not a discount.** Each underlying check spends its own
  allowance, so one call costs four slots plus one `ping` slot per region.
- The bundle itself needs a plan whose `location_select` is `true`,
  because running from several regions at once is the entire point.
  Without it the call is `403 forbidden` — and the five checks it bundles
  stay available individually on every plan.

## Errors

Everything the caller can fix keeps a proper 4xx; see
[Errors & rate limits](/docs/api/errors) for the envelope.

| Status | Code | When |
| --- | --- | --- |
| `400` | `invalid_request` | Missing/invalid `target` or options — the message names the field and the allowed values. Also a target in private, loopback or cloud-metadata address space, which is never probed |
| `403` | `forbidden` | A `read` key, or a check (or `diagnose-target`) the plan does not include |
| `404` | `not_found` | Unknown slug — the message lists every valid one |
| `409` | `conflict` | An `Idempotency-Key` reused with a different body |
| `429` | `rate_limited` | The key's requests/minute, the hourly distinct-target ceiling, or the check's plan allowance |
| `500` | `internal_error` | The probe fleet could not be reached — retry shortly |

Retries are safe: `POST` accepts `Idempotency-Key` (24-hour window), and
a replayed run returns the original answer without probing — or paying —
twice.

## From an AI agent

The same three operations are the MCP tools `list_diagnostics`,
`run_diagnostic` and `diagnose_target`, and the A2A skill `diagnose`.
They call this API, so the limits, the envelope and the write-scope rule
are identical.

**MCP**

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "run_diagnostic",
    "arguments": {
      "tool": "dns-propagation",
      "target": "app.example.com",
      "options": { "recordType": "A" }
    }
  }
}
```

```bash
curl -X POST https://watchfor.io/api/mcp \
  -H "Authorization: Bearer wf_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"diagnose_target","arguments":{"target":"example.com"}}}'
```

`run_diagnostic`'s description enumerates all 26 slugs with the question
each answers, so a model picks the right one without a discovery
round-trip. See the [MCP server](/docs/api/mcp).

**A2A**

```bash
curl -X POST https://watchfor.io/api/a2a \
  -H "Authorization: Bearer wf_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"Why can'"'"'t I reach example.com?"}]}}}'
```

The `diagnose` skill runs the whole bundle by default; pass `check` to
run a single one. See the [A2A agent](/docs/api/a2a).

## Related

- [Free tools & Toolbox](/docs/tools) — the same checks with a UI, and
  the same per-plan allowance (`dns-health` is shown on the DNS Propagation
  page)
- [Probe locations](/docs/reference/probe-locations) — the fleet these
  run on
- [Monitor types](/docs/api/monitor-types) — turn a one-off check into
  continuous monitoring
- [Checks & uptime](/docs/api/checks-uptime) — what WatchFor already
  recorded, as opposed to what is true right now

---

Canonical page: https://watchfor.io/docs/api/diagnostics · All docs: https://watchfor.io/docs · Site guide: https://watchfor.io/llms.txt
