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

# WatchFor live diagnostics API

Run any of 22 live checks against any public target from WatchFor's probe fleet — DNS, propagation, TLS, HTTP, ping, traceroute, ports, blacklists, email policy — 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 22 checks

Each slug is the canonical identifier everywhere — the URL segment, the
MCP `tool` value, and the allowance bucket it spends. Short aliases
(`dns`, `port-check`, `api-test`, `rdap`, `web-vitals`, `smtp`, `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` |
| `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` |
| `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 or gzip compression, and how much bandwidth is it saving? | 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` |
| `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.

## 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`, a failed TLS handshake or a host that
> never replies is a **`200` with `success: false`** and a populated
> `error` — the check ran, and silence is exactly the finding you asked
> for. 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 private target. 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": "1.0"
  },
  "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

Eighteen of the 22 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.

Four checks ignore `location_id` because their answer does not depend on
where you stand: `whois` (a registry lookup), `blacklist` (a reputation
lookup), `email-health` (published DNS policy) and `dns-propagation`
(which uses every location by definition).

## `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": "ping", "label": "Ping", "status": "skipped", "reason": "quota_exhausted",
      "detail": "…" }
  ],
  "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.
- `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 18 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 22 checks with a UI, and
  the same per-plan allowance
- [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
