# Dotcom-Tools API

> Markdown version of https://wwwnew.dotcom-tools.com/docs/api, generated from the OpenAPI description at https://wwwnew.dotcom-tools.com/api/v1/openapi.json, so it always matches the API. Base URL: `https://wwwnew.dotcom-tools.com/api/v1`. Every request needs your API key in the `X-Api-Key` header. Site index for agents: https://wwwnew.dotcom-tools.com/llms.txt.

Contents: the guide (Introduction to Errors), a curl quick start, [Endpoints](#endpoints) and [Models](#models).

## Introduction

Run the same tests as the website from your scripts, CI pipelines and dashboards: start a test from the locations
you choose, then fetch its results as JSON. The API is included in every paid plan and uses the same credits as
tests you run on the site.

- **Base URL:** `https://wwwnew.dotcom-tools.com/api/v1`. The paths in this reference already start with `/api/v1`, so send them to
  `https://wwwnew.dotcom-tools.com`.
- **Endpoints:** list locations; see test types, costs and time limits; start a test (one endpoint per test type);
  list your tests; get a test and its results; download result files; check your credits.
- **Credits:** the full cost is taken when a test starts; when it finishes, locations that failed on our side are
  refunded (see Monitoring).
- **Limits per key:** 10 test submissions and 120 requests a minute (HTTP 429 when exceeded).
- **OpenAPI description:** `https://wwwnew.dotcom-tools.com/api/v1/openapi.json`, ready for Postman, Insomnia or Swagger tools.
- **Old API:** the API of the previous Dotcom-Tools site is not supported.

## Authentication

1. Choose a paid plan on the [pricing page](/pricing).
2. On your [account page](/account#billing), under Plan and credits, create a key. Copy it right away: it's shown only once.
3. Send it with every request in the `X-Api-Key` header. Keep it out of client-side code and public repositories,
   and revoke it on the same page if it leaks.

**Try it** on this page sends real requests with the key you enter and uses your credits. The key stays in your browser.

## Quick start

1. Pick locations: `GET /api/v1/locations?testType=webspeed` lists, by region, the locations that can run tests now
   (unavailable ones are left out).
2. Start the test: `POST /api/v1/tests/{type}` (for example `/api/v1/tests/webspeed`) with the location ids and the
   settings. It returns the test id.
3. Poll `GET /api/v1/tests/{id}` every few seconds until its status is no longer `Queued` or `Running`. Most tests
   finish within a minute; website speed tests within about two.

The website's Mobile Website Speed Test and China Firewall Test are `webspeed` tests here: set `"device": "mobile"`,
or choose locations marked `isMainlandChina: true`. Every option of the website's forms works in the API; each test
under **Tests** lists its settings.

```python
import time, uuid, requests

API = "https://wwwnew.dotcom-tools.com/api/v1"
HEADERS = {"X-Api-Key": "YOUR_KEY"}

def get(path, **params):
    response = requests.get(f"{API}{path}", params=params, headers=HEADERS, timeout=30)
    response.raise_for_status()  # 401, 404 or 429: an error body, not data
    return response.json()

regions = get("/locations", testType="ping")
location_ids = [loc["id"] for region in regions for loc in region["locations"]][:3]

# One Idempotency-Key per test: resending the same request with it never starts a second test.
headers = {**HEADERS, "Idempotency-Key": str(uuid.uuid4())}
body = {"locationIds": location_ids, "config": {"target": "example.com"}}
for attempt in range(5):
    try:
        response = requests.post(f"{API}/tests/ping", headers=headers, json=body, timeout=30)
    except requests.RequestException:  # no answer: resend with the same key
        time.sleep(2)
        continue
    code = response.json().get("code") if response.status_code >= 400 else None
    if response.status_code == 409 or code == "rate_limited":  # still starting, or rate limited: wait and resend
        time.sleep(int(response.headers.get("Retry-After", "2")))
        continue
    response.raise_for_status()  # 400, 403, 422, or 429 out of credits: fix the request or budget (see code)
    test = response.json()
    break
else:
    raise SystemExit("Couldn't start the test.")

while True:
    time.sleep(5)
    result = get(f"/tests/{test['testId']}")
    if result["status"] not in ("Queued", "Running"):
        break

for run in result["runs"]:
    r = run["result"]
    if run["status"] in ("CompletedWithErrors", "Blocked"):
        print(run["location"]["name"], "problem:", run["failureCategory"], run["errorMessage"])
    elif run["status"] == "Completed":
        print(run["location"]["name"], "loss", r["packetLossPercent"], "%, avg", r["averageLatencyMs"], "ms")
    else:
        print(run["location"]["name"], "inconclusive (our side):", run["status"])
```

## Results

The overall `status` is `Completed` (every location completed without a problem), `CompletedWithErrors` (at least
one location found a problem with the target, such as an HTTP error or a timeout), `CompletedWithSystemErrors`
(the target was fine wherever it was tested, but some locations failed on our side) or `FailedSystemError` (no
location could test the target). Each entry in `runs` has the location, its own status and a
`result` object with the measurements: load time, TTFB and Core Web Vitals for a speed test, latency and packet
loss for a ping test, and so on. Passwords, header values and cookie values you send are used for the test only
and are masked in stored results.

Each run's `artifacts` lists the files it produced (a website speed test's HAR, Lighthouse report, video,
screenshot and more), each with a `url` you download with your API key. Finished tests can also be exported on
the website as JSON, CSV or a PDF report.

## Monitoring

What an availability and performance monitor needs to know.

**Statuses.** A run is in progress while `Queued`, `Connecting` or `Running`; every other status is final. A test
is in progress while `Queued` or `Running`; every other status is final.

- `Completed` (runs): the location tested the target and found no problem. Check its `result` against your
  thresholds.
- `CompletedWithErrors` and `Blocked` (runs): the target had a problem. `failureCategory` says which.
- `AgentError`, `Skipped`, `QueueTimeout` and `Canceled` (runs): a problem on our side. These aren't charged, and
  they say nothing about your service. Treat them as inconclusive.
- The test's status sums up its runs:

  | Test status | Meaning | For a monitor |
  |---|---|---|
  | `Completed` | Every location completed without a problem | Healthy (check your thresholds) |
  | `CompletedWithErrors` | At least one location found a problem with the target | Apply the decision rule below: down, degraded or, with too few conclusive runs, inconclusive |
  | `CompletedWithSystemErrors` | The target was fine wherever it was tested, but some locations failed on our side | Apply the decision rule below: with fewer than two conclusive runs, inconclusive (rerun); otherwise healthy or degraded by your thresholds |
  | `FailedSystemError` | No location could test the target | Inconclusive; rerun later |

  Locations that failed on our side are never charged, whatever the test's status.

**What counts as a problem.** A run is `CompletedWithErrors` only for these; everything else is a measurement for
you to judge:

| Test | Problem (CompletedWithErrors) | Not a problem: check it yourself |
|---|---|---|
| webspeed | Main document HTTP 4xx/5xx, DNS failure, connection failure or timeout | Slow loads, failed sub-resources, console errors, Lighthouse scores |
| ping | The host name doesn't resolve | Packet loss, even 100% (`packetLossPercent`), latency |
| traceroute | The host name doesn't resolve | Not reaching the target (`reachedTarget`), loss per hop |
| dns | `outcome` is `nxdomain` or `error` | `nodata`, name servers that disagree (`levels[].consistent`) |
| webserver | HTTP 4xx/5xx, `expectedText` not found, a TLS certificate problem, DNS or connection failure, timeout | Response time |
| email, ftp | A step fails: DNS, connection, greeting, TLS, or the login with credentials you sent | Step times |
| dnsbl | An address is listed on a blacklist, or the target doesn't resolve | Lists that refused (`refused`) or didn't answer (`error`): there's one check per address per list, so full coverage is `checkedCount` = 8 × the number of `ips` |
| streaming | The manifest or media can't be fetched or read (DNS, connection, HTTP error, timeout) | `bufferRatio` above 1, throughput |

**Thresholds are yours.** The API doesn't apply performance limits. Compare result fields such as `loadTimeMs`,
`lcpMs`, `averageLatencyMs`, `packetLossPercent`, `bufferRatio` or `tls.daysUntilExpiry` with your own limits.

**A simple decision rule.** Use at least three locations, so one location's network trouble isn't mistaken for an
outage. Count only the conclusive runs (`Completed`, `CompletedWithErrors`, `Blocked`); runs that failed on our
side don't count. With fewer than two conclusive runs, the check is *inconclusive*: rerun it. Otherwise: *down*
when every conclusive run found a problem; *degraded* when some did, or a threshold was exceeded; *healthy* when
none did.

**Polling and time limits.** Poll every 5 seconds; each poll counts toward the request limit. A run waits in the
queue for at most 30 minutes when locations are busy, and then ends as `QueueTimeout`. Once it
starts, it ends within its time limit plus 5 minutes. Every test therefore ends within 37 minutes,
so stop polling after that.

| Test | Credits per location | Time limit |
|---|---|---|
| Website speed test (`webspeed`) | 20 | 120 s |
| Ping test (`ping`) | 1 | 30 s |
| Traceroute test (`traceroute`) | 2 | 60 s |
| DNS trace test (`dns`) | 1 | 45 s |
| Web server test (`webserver`) | 2 | 60 s |
| Email server test (`email`) | 2 | 60 s |
| Email blacklist test (`dnsbl`) | 1 | 60 s |
| FTP test (`ftp`) | 2 | 90 s |
| Streaming media test (`streaming`) | 5 | 90 s |

These are settings we can change; `GET /api/v1/test-types` returns the current values.

**Credits.** The full cost is reserved when a test starts. Every location that ran the test is charged, whatever it
found (a DNS failure or an HTTP error included: runs that end `Completed` or `CompletedWithErrors`); runs that
failed on our side or were blocked are refunded. `GET
/api/v1/credits` shows your balance.

**Rate limits.** Each key has fixed one-minute windows of 120 requests (every endpoint, test starts and polls
included) and 10 test starts (only tests that really start count: an `Idempotency-Key` replay doesn't). Over a
limit, you get HTTP 429 with `code` `rate_limited` and a `Retry-After` header in seconds.

**Retries.** `GET` requests are safe to retry. To make starting a test safe to retry, send an `Idempotency-Key`
header (a new UUID per test) with the `POST`. If it times out, send the same request with the same key: within 24
hours you get the first test back, with the header `Idempotent-Replayed: true`, instead of a new one, and nothing
more is charged. The same key with a different request is refused (`422`, `idempotency_key_reused`); while the
first request is still starting its test you get `409` (`idempotency_in_progress`): retry a moment later. A start
that failed (for example `400` or `429`) frees its key, so you can correct the request and retry with it. Without
the header, every `POST` starts a new test. If our server stops while starting your test, the key is free again
after 2 minutes. If you lose a test id, `GET /api/v1/tests?since=…` finds it.

**Retention.** Results are kept for 13 months on paid plans.

## Errors

| Status | `code` | Meaning |
|---|---|---|
| 400 | `invalid_config`, `locations` | The request is invalid; `detail` says what to fix, such as a missing URL or an unknown location |
| 400 | `invalid_idempotency_key`, `invalid_query` | The Idempotency-Key isn't 1 to 100 visible ASCII characters, or a list filter is wrong |
| 401 | | The API key is missing, wrong or revoked |
| 403 | `paid_required` | The account has no active plan and no paid credits left; API tests need a paid account |
| 403 | `testing_unavailable` | Testing is turned off for this account; contact support |
| 404 | | The test or file doesn't exist or belongs to another account |
| 409 | `idempotency_in_progress` | A request with this Idempotency-Key is still starting its test; retry shortly |
| 422 | `idempotency_key_reused` | This Idempotency-Key was used for a different request in the last 24 hours |
| 429 | `rate_limited` | Too many requests for this key; wait the seconds in `Retry-After` |
| 429 | `insufficient_credits` | You have credits, but fewer than this test needs; choose fewer locations or buy a pack |
| 429 | `limit_reached` | Your plan's credits are used up; buy a pack or wait for the plan's next monthly credits |
| 503 | `maintenance` | Testing is paused for maintenance; try again later |

Error responses are JSON (RFC 9457 problem details) with `title`, `detail` and, for the errors above, a
machine-readable `code`. If a test fails because of a problem on our side,
its run carries a `supportReference` to quote when you contact support.

## Quick start with curl

```bash
# Locations that can run a ping test
curl -s -H "X-Api-Key: $DOTCOM_TOOLS_API_KEY" "https://wwwnew.dotcom-tools.com/api/v1/locations?testType=ping"

# Start the test (one Idempotency-Key per test makes a retry safe); the answer has the testId
curl -s -X POST "https://wwwnew.dotcom-tools.com/api/v1/tests/ping" \
  -H "X-Api-Key: $DOTCOM_TOOLS_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"locationIds": ["LOCATION_ID"], "config": {"target": "example.com"}}'

# Poll every few seconds until the status is no longer Queued or Running
curl -s -H "X-Api-Key: $DOTCOM_TOOLS_API_KEY" "https://wwwnew.dotcom-tools.com/api/v1/tests/TEST_ID"
```

## Endpoints

Errors are `application/problem+json` with a machine-readable `code` (see Errors above). Every endpoint can answer `401` (missing or invalid API key) and `429` with code `rate_limited` (wait the seconds in `Retry-After`); these aren't repeated below.

### GET /api/v1/locations

**List locations**

The locations that can run tests now, grouped by region (unavailable ones are left out, so check before each test). Pass testType (e.g. webspeed, ping) to list only the locations that offer it.

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `testType` | query | no | string: `webspeed`, `ping`, `traceroute`, `dns`, `webserver`, `email`, `dnsbl`, `ftp`, `streaming` | Only locations that offer this test type. Leave it out to list every available location. |

Response `200`: array of [PublicRegion](#publicregion) (`application/json`).

### GET /api/v1/credits

**Plan and credits**

Your plan and the credits you can spend now. A test's full cost is taken from the balance when it starts and the unused part is refunded when it finishes, so the balance already excludes tests in progress. Credits that expire soonest are spent first (usually the monthly plan credits, then OnDemand packs).

Response `200`: [AccountPlan](#accountplan) (`application/json`).

### GET /api/v1/test-types

**Test types, costs and time limits**

Every test type with its current credit cost per location and its time limit. A test costs creditsPerLocation times the number of locations; the full cost is taken when it starts and locations that fail on our side are refunded. These are settings we can change, so read them here rather than copying them.

Response `200`: array of [ApiTestType](#apitesttype) (`application/json`).

### POST /api/v1/tests/webspeed

**Website speed test**

Loads the page in Chrome from each location: load time, TTFB, Core Web Vitals, waterfall, Lighthouse scores and video.

- `device`: desktop or mobile. `network`: none, broadband, fourG or threeG. `dnsMode`: normal or resolveFromRoot.
- Optional `userAgent`, `hostOverrideIp`, `headers` [{name, value, scope: site or all}], `cookies` [{name, value}]
  and `basicAuth` {userName, password}.

The website's Mobile Website Speed Test is this test with `"device": "mobile"`; its China Firewall Test is this
test from locations marked `isMainlandChina: true`.

Where credentials go: a header with `scope: site` is added to every request (the page, its files, redirects)
whose host is the tested URL's registrable domain or a subdomain of it, so testing www.example.com covers
example.com and cdn.example.com; on shared hosting (github.io, azurewebsites.net and the like) only your own
subdomain counts. A header with `scope: all` goes to every request, third parties included, so never put
credentials in one. `cookies` are set for the tested URL's exact host (not its subdomains), every path, and
marked Secure on https. `basicAuth` answers a 401 challenge only from the same site hosts.

Starts the test from one or more locations (`locationIds` from `GET /api/v1/locations?testType=webspeed`).
The full cost is taken when the test starts; locations that end `Completed` or `CompletedWithErrors` are charged and the rest are refunded. Returns the
test id to poll with `GET /api/v1/tests/{id}`. Send an `Idempotency-Key` header to make retries safe (see
Retries in the guide). Passwords, header values and cookie values are used for the test only and are masked in stored results.

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | no | string | Optional. A unique value per test (a UUID is typical). Resending the same request with the same key within 24 hours returns the first test instead of starting and charging another. |

Request body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `locationIds` | array of string (uuid) | yes | Location ids from GET /api/v1/locations?testType=webspeed (your plan sets how many per test). |
| `config` | [WebSpeedConfig](#webspeedconfig) | yes |  |

Example request body:

```json
{
  "locationIds": [
    "LOCATION_ID"
  ],
  "config": {
    "url": "https://www.example.com",
    "device": "desktop",
    "network": "none"
  }
}
```

Response `200`: [SubmitTestResponse](#submittestresponse) (`application/json`).
- Header `Idempotent-Replayed`: true when this answer returns a test started earlier with the same Idempotency-Key.

Errors: `429` Too many requests for this key (code rate_limited; wait the seconds in Retry-After), or not enough credits (code insufficient_credits or limit_reached); `400` Bad Request; `403` Forbidden; `409` Conflict; `422` Unprocessable Entity; `503` Service Unavailable.

#### Result

When the test finishes, each run's `result` in `GET /api/v1/tests/{id}` is a **WebSpeedResult**
(see Models for every field). For example:

```json
{
  "type": "webspeed",
  "finalUrl": "https://www.example.com/",
  "statusCode": 200,
  "httpVersion": "h2",
  "connectedIp": "104.20.23.154",
  "mainHostDnsMs": 130.6,
  "dnsMode": "normal",
  "hostOverrideIp": null,
  "device": "desktop",
  "network": "none",
  "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36",
  "browserVersion": "155.0.8059.84",
  "ttfbMs": 191,
  "fcpMs": 364.218,
  "lcpMs": 520.797,
  "cls": 0,
  "tbtMs": 0,
  "speedIndexMs": 409,
  "ttiMs": 474.17,
  "domContentLoadedMs": 474,
  "loadTimeMs": 474,
  "pageBytes": 486210,
  "requestCount": 24,
  "failedRequestCount": 1,
  "consoleErrorCount": 0,
  "lighthouse": {
    "version": "13.5.0",
    "timedOut": false,
    "performance": 100,
    "accessibility": 96,
    "bestPractices": 100,
    "seo": 80
  },
  "redirects": [
    {
      "url": "https://example.com/",
      "statusCode": 301,
      "location": "https://www.example.com/"
    }
  ],
  "contentTypes": [
    {
      "resourceType": "document",
      "requestCount": 1,
      "transferBytes": 18342
    },
    {
      "resourceType": "stylesheet",
      "requestCount": 3,
      "transferBytes": 42100
    },
    {
      "resourceType": "script",
      "requestCount": 9,
      "transferBytes": 301455
    },
    {
      "resourceType": "image",
      "requestCount": 8,
      "transferBytes": 104230
    },
    {
      "resourceType": "font",
      "requestCount": 2,
      "transferBytes": 19083
    },
    {
      "resourceType": "xhr",
      "requestCount": 1,
      "transferBytes": 1000
    }
  ],
  "thirdParties": [
    {
      "entity": "Google Tag Manager",
      "category": "tag-manager",
      "transferBytes": 98012,
      "mainThreadTimeMs": 41,
      "blockingTimeMs": 0,
      "requestCount": 3
    }
  ],
  "failedRequests": [
    {
      "url": "https://cdn.example.net/widget.js",
      "statusCode": null,
      "error": "net::ERR_NAME_NOT_RESOLVED",
      "resourceType": "script"
    }
  ],
  "blockedDestinations": [],
  "tls": {
    "protocol": "TLSv1.3",
    "cipherSuite": "TLS_AES_256_GCM_SHA384",
    "certificate": {
      "subject": "CN=example.com",
      "issuer": "CN=Cloudflare TLS Issuing ECC CA 3, O=SSL Corporation, C=US",
      "validFrom": "2026-09-26T22:49:11+00:00",
      "validTo": "2026-12-25T22:56:35+00:00",
      "subjectAlternativeNames": [
        "example.com",
        "*.example.com"
      ],
      "thumbprint": "899CB259E7DE8DF10033A2AF48C22C2F6580A21D"
    },
    "chain": [
      {
        "subject": "CN=example.com",
        "issuer": "CN=Cloudflare TLS Issuing ECC CA 3, O=SSL Corporation, C=US",
        "validFrom": "2026-09-26T22:49:11+00:00",
        "validTo": "2026-12-25T22:56:35+00:00",
        "thumbprint": "899CB259E7DE8DF10033A2AF48C22C2F6580A21D"
      },
      {
        "subject": "CN=Cloudflare TLS Issuing ECC CA 3, O=SSL Corporation, C=US",
        "issuer": "CN=SSL.com TLS Transit ECC CA R2, O=SSL Corporation, C=US",
        "validFrom": "2025-05-29T19:49:45+00:00",
        "validTo": "2035-05-27T19:49:44+00:00",
        "thumbprint": "AE801ED1C55BB579D79208B0D772ACFB8CC3A208"
      }
    ],
    "daysUntilExpiry": 60,
    "hostnameValid": true,
    "chainTrusted": true,
    "errors": []
  }
}
```

### POST /api/v1/tests/ping

**Ping test**

Sends 5 ICMP echo requests from each location: average latency and packet loss. `target` is a host name or IPv4 address.

Starts the test from one or more locations (`locationIds` from `GET /api/v1/locations?testType=ping`).
The full cost is taken when the test starts; locations that end `Completed` or `CompletedWithErrors` are charged and the rest are refunded. Returns the
test id to poll with `GET /api/v1/tests/{id}`. Send an `Idempotency-Key` header to make retries safe (see
Retries in the guide).

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | no | string | Optional. A unique value per test (a UUID is typical). Resending the same request with the same key within 24 hours returns the first test instead of starting and charging another. |

Request body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `locationIds` | array of string (uuid) | yes | Location ids from GET /api/v1/locations?testType=ping (your plan sets how many per test). |
| `config` | [PingConfig](#pingconfig) | yes |  |

Example request body:

```json
{
  "locationIds": [
    "LOCATION_ID"
  ],
  "config": {
    "target": "example.com"
  }
}
```

Response `200`: [SubmitTestResponse](#submittestresponse) (`application/json`).
- Header `Idempotent-Replayed`: true when this answer returns a test started earlier with the same Idempotency-Key.

Errors: `429` Too many requests for this key (code rate_limited; wait the seconds in Retry-After), or not enough credits (code insufficient_credits or limit_reached); `400` Bad Request; `403` Forbidden; `409` Conflict; `422` Unprocessable Entity; `503` Service Unavailable.

#### Result

When the test finishes, each run's `result` in `GET /api/v1/tests/{id}` is a **PingResult**
(see Models for every field). For example:

```json
{
  "type": "ping",
  "resolvedIp": "104.20.23.154",
  "averageLatencyMs": 1.2,
  "packetLossPercent": 0,
  "packetsSent": 5,
  "packetsReceived": 5,
  "dnsFailed": false
}
```

### POST /api/v1/tests/traceroute

**Traceroute test**

Traces the network path from each location, with 4 probes per hop: latency and loss per hop.

Starts the test from one or more locations (`locationIds` from `GET /api/v1/locations?testType=traceroute`).
The full cost is taken when the test starts; locations that end `Completed` or `CompletedWithErrors` are charged and the rest are refunded. Returns the
test id to poll with `GET /api/v1/tests/{id}`. Send an `Idempotency-Key` header to make retries safe (see
Retries in the guide).

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | no | string | Optional. A unique value per test (a UUID is typical). Resending the same request with the same key within 24 hours returns the first test instead of starting and charging another. |

Request body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `locationIds` | array of string (uuid) | yes | Location ids from GET /api/v1/locations?testType=traceroute (your plan sets how many per test). |
| `config` | [TracerouteConfig](#tracerouteconfig) | yes |  |

Example request body:

```json
{
  "locationIds": [
    "LOCATION_ID"
  ],
  "config": {
    "target": "example.com",
    "maxHops": 30
  }
}
```

Response `200`: [SubmitTestResponse](#submittestresponse) (`application/json`).
- Header `Idempotent-Replayed`: true when this answer returns a test started earlier with the same Idempotency-Key.

Errors: `429` Too many requests for this key (code rate_limited; wait the seconds in Retry-After), or not enough credits (code insufficient_credits or limit_reached); `400` Bad Request; `403` Forbidden; `409` Conflict; `422` Unprocessable Entity; `503` Service Unavailable.

#### Result

When the test finishes, each run's `result` in `GET /api/v1/tests/{id}` is a **TracerouteResult**
(see Models for every field). For example:

```json
{
  "type": "traceroute",
  "resolvedIp": "104.20.23.154",
  "sourceIp": "203.0.113.50",
  "reachedTarget": true,
  "hopCount": 6,
  "hops": [
    {
      "hop": 1,
      "ip": "10.0.0.1",
      "hostName": null,
      "rttMs": [
        6,
        3.7,
        1.8,
        0.8
      ],
      "minMs": 0.8,
      "averageMs": 3.1,
      "maxMs": 6,
      "lossPercent": 0
    },
    {
      "hop": 2,
      "ip": "203.0.113.1",
      "hostName": "ae1.core1.isp.example.net",
      "rttMs": [
        7.3,
        78.9,
        2.3,
        2.9
      ],
      "minMs": 2.3,
      "averageMs": 22.8,
      "maxMs": 78.9,
      "lossPercent": 0
    },
    {
      "hop": 3,
      "ip": "198.51.100.17",
      "hostName": "be2.edge1.isp.example.net",
      "rttMs": [
        4.7,
        79,
        15.3,
        11.9
      ],
      "minMs": 4.7,
      "averageMs": 27.7,
      "maxMs": 79,
      "lossPercent": 0
    },
    {
      "hop": 4,
      "ip": "198.51.100.42",
      "hostName": null,
      "rttMs": [
        4.5,
        79.3,
        4.1,
        23.3
      ],
      "minMs": 4.1,
      "averageMs": 27.8,
      "maxMs": 79.3,
      "lossPercent": 0
    },
    {
      "hop": 5,
      "ip": "162.158.212.21",
      "hostName": null,
      "rttMs": [
        4.9,
        78.7,
        4.6,
        5.2
      ],
      "minMs": 4.6,
      "averageMs": 23.4,
      "maxMs": 78.7,
      "lossPercent": 0
    },
    {
      "hop": 6,
      "ip": "104.20.23.154",
      "hostName": null,
      "rttMs": [
        2,
        2,
        2,
        2
      ],
      "minMs": 2,
      "averageMs": 2,
      "maxMs": 2,
      "lossPercent": 0
    }
  ]
}
```

### POST /api/v1/tests/dns

**DNS trace test**

Resolves the name step by step from the root servers and flags disagreements between name servers. Optional `dnsServer` and `recursionDesired`.

Starts the test from one or more locations (`locationIds` from `GET /api/v1/locations?testType=dns`).
The full cost is taken when the test starts; locations that end `Completed` or `CompletedWithErrors` are charged and the rest are refunded. Returns the
test id to poll with `GET /api/v1/tests/{id}`. Send an `Idempotency-Key` header to make retries safe (see
Retries in the guide).

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | no | string | Optional. A unique value per test (a UUID is typical). Resending the same request with the same key within 24 hours returns the first test instead of starting and charging another. |

Request body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `locationIds` | array of string (uuid) | yes | Location ids from GET /api/v1/locations?testType=dns (your plan sets how many per test). |
| `config` | [DnsTraceConfig](#dnstraceconfig) | yes |  |

Example request body:

```json
{
  "locationIds": [
    "LOCATION_ID"
  ],
  "config": {
    "domain": "example.com",
    "recordType": "A"
  }
}
```

Response `200`: [SubmitTestResponse](#submittestresponse) (`application/json`).
- Header `Idempotent-Replayed`: true when this answer returns a test started earlier with the same Idempotency-Key.

Errors: `429` Too many requests for this key (code rate_limited; wait the seconds in Retry-After), or not enough credits (code insufficient_credits or limit_reached); `400` Bad Request; `403` Forbidden; `409` Conflict; `422` Unprocessable Entity; `503` Service Unavailable.

#### Result

When the test finishes, each run's `result` in `GET /api/v1/tests/{id}` is a **DnsTraceResult**
(see Models for every field). For example:

```json
{
  "type": "dns",
  "domain": "example.com",
  "recordType": "A",
  "outcome": "answer",
  "answers": [
    {
      "name": "example.com.",
      "type": "A",
      "ttl": 300,
      "value": "104.20.23.154"
    },
    {
      "name": "example.com.",
      "type": "A",
      "ttl": 300,
      "value": "172.66.147.243"
    }
  ],
  "totalMs": 142.6,
  "levels": [
    {
      "queryName": "example.com.",
      "zone": ".",
      "consistent": true,
      "servers": [
        {
          "server": "a.root-servers.net",
          "ip": "198.41.0.4",
          "responseMs": 18.2,
          "kind": "referral",
          "responseCode": "NOERROR",
          "flags": "qr",
          "referral": [
            "a.gtld-servers.net",
            "b.gtld-servers.net"
          ],
          "records": [],
          "error": null,
          "followed": true,
          "agrees": true
        },
        {
          "server": "b.root-servers.net",
          "ip": "170.247.170.2",
          "responseMs": 31.5,
          "kind": "referral",
          "responseCode": "NOERROR",
          "flags": "qr",
          "referral": [
            "a.gtld-servers.net",
            "b.gtld-servers.net"
          ],
          "records": [],
          "error": null,
          "followed": false,
          "agrees": true
        }
      ]
    },
    {
      "queryName": "example.com.",
      "zone": "com",
      "consistent": true,
      "servers": [
        {
          "server": "a.gtld-servers.net",
          "ip": "192.5.6.30",
          "responseMs": 21.7,
          "kind": "referral",
          "responseCode": "NOERROR",
          "flags": "qr",
          "referral": [
            "elliott.ns.cloudflare.com",
            "hera.ns.cloudflare.com"
          ],
          "records": [],
          "error": null,
          "followed": true,
          "agrees": true
        },
        {
          "server": "b.gtld-servers.net",
          "ip": "192.33.14.30",
          "responseMs": 24.1,
          "kind": "referral",
          "responseCode": "NOERROR",
          "flags": "qr",
          "referral": [
            "elliott.ns.cloudflare.com",
            "hera.ns.cloudflare.com"
          ],
          "records": [],
          "error": null,
          "followed": false,
          "agrees": true
        }
      ]
    },
    {
      "queryName": "example.com.",
      "zone": "example.com",
      "consistent": true,
      "servers": [
        {
          "server": "elliott.ns.cloudflare.com",
          "ip": "173.245.59.111",
          "responseMs": 9.8,
          "kind": "answer",
          "responseCode": "NOERROR",
          "flags": "qr aa",
          "referral": [],
          "records": [
            {
              "name": "example.com.",
              "type": "A",
              "ttl": 300,
              "value": "104.20.23.154"
            },
            {
              "name": "example.com.",
              "type": "A",
              "ttl": 300,
              "value": "172.66.147.243"
            }
          ],
          "error": null,
          "followed": true,
          "agrees": true
        },
        {
          "server": "hera.ns.cloudflare.com",
          "ip": "108.162.192.152",
          "responseMs": 11.2,
          "kind": "answer",
          "responseCode": "NOERROR",
          "flags": "qr aa",
          "referral": [],
          "records": [
            {
              "name": "example.com.",
              "type": "A",
              "ttl": 300,
              "value": "104.20.23.154"
            },
            {
              "name": "example.com.",
              "type": "A",
              "ttl": 300,
              "value": "172.66.147.243"
            }
          ],
          "error": null,
          "followed": false,
          "agrees": true
        }
      ]
    }
  ]
}
```

### POST /api/v1/tests/webserver

**Web server test**

One HTTP request with DNS, connect, TLS, TTFB and download timing. Optional `headers`, `body`, `contentType`, `basicAuth`, `followRedirects` and `expectedText`. `headers` (their `scope` is ignored here) and `basicAuth` are sent only to the requested URL's host; a redirect to another host is followed without them.

Starts the test from one or more locations (`locationIds` from `GET /api/v1/locations?testType=webserver`).
The full cost is taken when the test starts; locations that end `Completed` or `CompletedWithErrors` are charged and the rest are refunded. Returns the
test id to poll with `GET /api/v1/tests/{id}`. Send an `Idempotency-Key` header to make retries safe (see
Retries in the guide). Passwords, header values and cookie values are used for the test only and are masked in stored results.

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | no | string | Optional. A unique value per test (a UUID is typical). Resending the same request with the same key within 24 hours returns the first test instead of starting and charging another. |

Request body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `locationIds` | array of string (uuid) | yes | Location ids from GET /api/v1/locations?testType=webserver (your plan sets how many per test). |
| `config` | [WebServerConfig](#webserverconfig) | yes |  |

Example request body:

```json
{
  "locationIds": [
    "LOCATION_ID"
  ],
  "config": {
    "url": "https://example.com",
    "method": "GET"
  }
}
```

Response `200`: [SubmitTestResponse](#submittestresponse) (`application/json`).
- Header `Idempotent-Replayed`: true when this answer returns a test started earlier with the same Idempotency-Key.

Errors: `429` Too many requests for this key (code rate_limited; wait the seconds in Retry-After), or not enough credits (code insufficient_credits or limit_reached); `400` Bad Request; `403` Forbidden; `409` Conflict; `422` Unprocessable Entity; `503` Service Unavailable.

#### Result

When the test finishes, each run's `result` in `GET /api/v1/tests/{id}` is a **WebServerResult**
(see Models for every field). For example:

```json
{
  "type": "webserver",
  "url": "http://github.com/",
  "finalUrl": "https://github.com/",
  "resolvedIp": "140.82.114.3",
  "statusCode": 200,
  "reasonPhrase": "OK",
  "httpVersion": "HTTP/1.1",
  "timings": {
    "dnsMs": 82.2,
    "connectMs": 39,
    "tlsMs": 76.8,
    "sendMs": 2.3,
    "waitMs": 97,
    "receiveMs": 106.2,
    "totalMs": 406.7
  },
  "totalMs": 406.7,
  "responseBytes": 117389,
  "bodyTruncated": false,
  "responseHeaders": [
    {
      "name": "Date",
      "value": "Thu, 01 Oct 2026 04:24:56 GMT"
    },
    {
      "name": "Content-Type",
      "value": "text/html; charset=utf-8"
    },
    {
      "name": "Cache-Control",
      "value": "max-age=0, private, must-revalidate"
    },
    {
      "name": "Strict-Transport-Security",
      "value": "max-age=31536000; includeSubdomains; preload"
    }
  ],
  "redirects": [
    {
      "url": "http://github.com/",
      "ip": "140.82.114.3",
      "statusCode": 301,
      "reasonPhrase": "Moved Permanently",
      "location": "https://github.com/",
      "timings": {
        "dnsMs": 82.2,
        "connectMs": 39,
        "tlsMs": 0,
        "sendMs": 0.4,
        "waitMs": 40.1,
        "receiveMs": 0.2,
        "totalMs": 161.9
      },
      "responseBytes": 0,
      "error": null
    }
  ],
  "tls": {
    "protocol": "TLSv1.3",
    "cipherSuite": "TLS_AES_128_GCM_SHA256",
    "certificate": {
      "subject": "CN=github.com",
      "issuer": "CN=Sectigo Public Server Authentication CA DV E36, O=Sectigo Limited, C=GB",
      "validFrom": "2026-09-01T00:00:00+00:00",
      "validTo": "2026-11-29T23:59:59+00:00",
      "subjectAlternativeNames": [],
      "thumbprint": null
    },
    "chain": [],
    "daysUntilExpiry": 59,
    "hostnameValid": true,
    "chainTrusted": true,
    "errors": []
  },
  "contentMatched": true
}
```

### POST /api/v1/tests/email

**Email server test**

Connects to an SMTP, POP3 or IMAP server; no email is sent. `security`: none, startTls or ssl. Leave out `port` (or send 0) for the standard port: SMTP 25, 587 with startTls, 465 with ssl; POP3 110 or 995 with ssl; IMAP 143 or 993 with ssl. Optional `userName` and `password` to test the login.

Starts the test from one or more locations (`locationIds` from `GET /api/v1/locations?testType=email`).
The full cost is taken when the test starts; locations that end `Completed` or `CompletedWithErrors` are charged and the rest are refunded. Returns the
test id to poll with `GET /api/v1/tests/{id}`. Send an `Idempotency-Key` header to make retries safe (see
Retries in the guide). Passwords, header values and cookie values are used for the test only and are masked in stored results.

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | no | string | Optional. A unique value per test (a UUID is typical). Resending the same request with the same key within 24 hours returns the first test instead of starting and charging another. |

Request body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `locationIds` | array of string (uuid) | yes | Location ids from GET /api/v1/locations?testType=email (your plan sets how many per test). |
| `config` | [EmailServerConfig](#emailserverconfig) | yes |  |

Example request body:

```json
{
  "locationIds": [
    "LOCATION_ID"
  ],
  "config": {
    "protocol": "smtp",
    "host": "mail.example.com",
    "port": 587,
    "security": "startTls"
  }
}
```

Response `200`: [SubmitTestResponse](#submittestresponse) (`application/json`).
- Header `Idempotent-Replayed`: true when this answer returns a test started earlier with the same Idempotency-Key.

Errors: `429` Too many requests for this key (code rate_limited; wait the seconds in Retry-After), or not enough credits (code insufficient_credits or limit_reached); `400` Bad Request; `403` Forbidden; `409` Conflict; `422` Unprocessable Entity; `503` Service Unavailable.

#### Result

When the test finishes, each run's `result` in `GET /api/v1/tests/{id}` is a **EmailServerResult**
(see Models for every field). For example:

```json
{
  "type": "email",
  "protocol": "smtp",
  "host": "smtp.gmail.com",
  "port": 587,
  "security": "startTls",
  "resolvedIp": "142.250.152.108",
  "banner": "smtp.gmail.com ESMTP 46e09a7af769-8212aa1a138sm1532424a34.12 - gsmtp",
  "capabilities": [
    "SIZE 35882577",
    "8BITMIME",
    "AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER XOAUTH",
    "ENHANCEDSTATUSCODES",
    "PIPELINING",
    "CHUNKING",
    "SMTPUTF8"
  ],
  "steps": [
    {
      "name": "DNS lookup",
      "durationMs": 58.1,
      "ok": true,
      "response": "142.250.152.108"
    },
    {
      "name": "Connect",
      "durationMs": 77.3,
      "ok": true,
      "response": "142.250.152.108:587"
    },
    {
      "name": "Greeting",
      "durationMs": 32,
      "ok": true,
      "response": "220 smtp.gmail.com ESMTP 46e09a7af769-8212aa1a138sm1532424a34.12 - gsmtp"
    },
    {
      "name": "EHLO",
      "durationMs": 86.6,
      "ok": true,
      "response": "250 smtp.gmail.com at your service, [203.0.113.50] SIZE 35882577 8BITMIME STARTTLS ENHANCEDSTATUSCODES PIPELINING CHUNKING SMTPUTF8"
    },
    {
      "name": "STARTTLS",
      "durationMs": 29.8,
      "ok": true,
      "response": "220 2.0.0 Ready to start TLS"
    },
    {
      "name": "TLS handshake",
      "durationMs": 27.9,
      "ok": true,
      "response": "Tls13 TLS_AES_256_GCM_SHA384"
    },
    {
      "name": "EHLO (after TLS)",
      "durationMs": 56,
      "ok": true,
      "response": "250 smtp.gmail.com at your service, [203.0.113.50] SIZE 35882577 8BITMIME AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER XOAUTH ENHANCEDSTATUSCODES PIPELINING CHUNKING SMTPUTF8"
    },
    {
      "name": "QUIT",
      "durationMs": 47.4,
      "ok": true,
      "response": "221 2.0.0 closing connection 46e09a7af769-8212aa1a138sm1532424a34.12 - gsmtp"
    }
  ],
  "tls": {
    "protocol": "TLSv1.3",
    "cipherSuite": "TLS_AES_256_GCM_SHA384",
    "certificate": {
      "subject": "CN=smtp.gmail.com",
      "issuer": "CN=WR2, O=Google Trust Services, C=US",
      "validFrom": "2026-09-10T19:23:20+00:00",
      "validTo": "2026-12-03T19:23:19+00:00",
      "subjectAlternativeNames": [],
      "thumbprint": null
    },
    "chain": [],
    "daysUntilExpiry": 63,
    "hostnameValid": true,
    "chainTrusted": true,
    "errors": []
  },
  "loginStatus": "skipped",
  "totalMs": 427.9
}
```

### POST /api/v1/tests/dnsbl

**Email blacklist test**

Checks an IPv4 address (or up to 5 IPv4 addresses of a host name) against 8 DNS blacklists: zen.spamhaus.org, bl.spamcop.net, b.barracudacentral.org, psbl.surriel.com, all.spamrats.com, dnsbl-1.uceprotect.net, bl.mailspike.net and dnsbl.dronebl.org. The list is fixed; a `lists` value in the request is ignored.

Starts the test from one or more locations (`locationIds` from `GET /api/v1/locations?testType=dnsbl`).
The full cost is taken when the test starts; locations that end `Completed` or `CompletedWithErrors` are charged and the rest are refunded. Returns the
test id to poll with `GET /api/v1/tests/{id}`. Send an `Idempotency-Key` header to make retries safe (see
Retries in the guide).

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | no | string | Optional. A unique value per test (a UUID is typical). Resending the same request with the same key within 24 hours returns the first test instead of starting and charging another. |

Request body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `locationIds` | array of string (uuid) | yes | Location ids from GET /api/v1/locations?testType=dnsbl (your plan sets how many per test). |
| `config` | [DnsblConfig](#dnsblconfig) | yes |  |

Example request body:

```json
{
  "locationIds": [
    "LOCATION_ID"
  ],
  "config": {
    "target": "203.0.113.10"
  }
}
```

Response `200`: [SubmitTestResponse](#submittestresponse) (`application/json`).
- Header `Idempotent-Replayed`: true when this answer returns a test started earlier with the same Idempotency-Key.

Errors: `429` Too many requests for this key (code rate_limited; wait the seconds in Retry-After), or not enough credits (code insufficient_credits or limit_reached); `400` Bad Request; `403` Forbidden; `409` Conflict; `422` Unprocessable Entity; `503` Service Unavailable.

#### Result

When the test finishes, each run's `result` in `GET /api/v1/tests/{id}` is a **DnsblResult**
(see Models for every field). For example:

```json
{
  "type": "dnsbl",
  "target": "gmail.com",
  "ips": [
    "142.250.189.133"
  ],
  "checks": [
    {
      "list": "all.spamrats.com",
      "ip": "142.250.189.133",
      "status": "clean",
      "returnCodes": [],
      "reason": null,
      "responseMs": 4487.9
    },
    {
      "list": "b.barracudacentral.org",
      "ip": "142.250.189.133",
      "status": "clean",
      "returnCodes": [],
      "reason": null,
      "responseMs": 155.9
    },
    {
      "list": "bl.mailspike.net",
      "ip": "142.250.189.133",
      "status": "clean",
      "returnCodes": [],
      "reason": null,
      "responseMs": 303.7
    },
    {
      "list": "bl.spamcop.net",
      "ip": "142.250.189.133",
      "status": "clean",
      "returnCodes": [],
      "reason": null,
      "responseMs": 108
    },
    {
      "list": "dnsbl-1.uceprotect.net",
      "ip": "142.250.189.133",
      "status": "clean",
      "returnCodes": [],
      "reason": null,
      "responseMs": 107.5
    },
    {
      "list": "dnsbl.dronebl.org",
      "ip": "142.250.189.133",
      "status": "clean",
      "returnCodes": [],
      "reason": null,
      "responseMs": 310.2
    },
    {
      "list": "psbl.surriel.com",
      "ip": "142.250.189.133",
      "status": "clean",
      "returnCodes": [],
      "reason": null,
      "responseMs": 155.6
    },
    {
      "list": "zen.spamhaus.org",
      "ip": "142.250.189.133",
      "status": "refused",
      "returnCodes": [
        "127.255.255.254"
      ],
      "reason": "The blacklist refused this query (for example from a public DNS resolver or over its free limit).",
      "responseMs": 220.7
    }
  ],
  "listedCount": 0,
  "checkedCount": 7
}
```

### POST /api/v1/tests/ftp

**FTP test**

Connects to an FTP or FTPS server. `security`: none, explicit or implicit. Leave out `port` (or send 0) for 21, or 990 with implicit. Without `userName` the login is anonymous. Optional `password`, `directory` and `listDirectory`.

Starts the test from one or more locations (`locationIds` from `GET /api/v1/locations?testType=ftp`).
The full cost is taken when the test starts; locations that end `Completed` or `CompletedWithErrors` are charged and the rest are refunded. Returns the
test id to poll with `GET /api/v1/tests/{id}`. Send an `Idempotency-Key` header to make retries safe (see
Retries in the guide). Passwords, header values and cookie values are used for the test only and are masked in stored results.

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | no | string | Optional. A unique value per test (a UUID is typical). Resending the same request with the same key within 24 hours returns the first test instead of starting and charging another. |

Request body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `locationIds` | array of string (uuid) | yes | Location ids from GET /api/v1/locations?testType=ftp (your plan sets how many per test). |
| `config` | [FtpConfig](#ftpconfig) | yes |  |

Example request body:

```json
{
  "locationIds": [
    "LOCATION_ID"
  ],
  "config": {
    "host": "ftp.example.com",
    "port": 21,
    "security": "explicit"
  }
}
```

Response `200`: [SubmitTestResponse](#submittestresponse) (`application/json`).
- Header `Idempotent-Replayed`: true when this answer returns a test started earlier with the same Idempotency-Key.

Errors: `429` Too many requests for this key (code rate_limited; wait the seconds in Retry-After), or not enough credits (code insufficient_credits or limit_reached); `400` Bad Request; `403` Forbidden; `409` Conflict; `422` Unprocessable Entity; `503` Service Unavailable.

#### Result

When the test finishes, each run's `result` in `GET /api/v1/tests/{id}` is a **FtpResult**
(see Models for every field). For example:

```json
{
  "type": "ftp",
  "host": "test.rebex.net",
  "port": 21,
  "security": "explicit",
  "resolvedIp": "194.108.117.16",
  "banner": "Welcome to test.rebex.net! See https://test.rebex.net/ for more information and terms of use. If you don\u0027t have an account, log in as \u0027anonymous\u0027 or \u0027ftp\u0027.",
  "steps": [
    {
      "name": "DNS lookup",
      "durationMs": 0.8,
      "ok": true,
      "response": "194.108.117.16"
    },
    {
      "name": "Connect",
      "durationMs": 185.3,
      "ok": true,
      "response": "194.108.117.16:21"
    },
    {
      "name": "Greeting",
      "durationMs": 104.5,
      "ok": true,
      "response": "220 Welcome to test.rebex.net! See https://test.rebex.net/ for more information and terms of use. If you don\u0027t have an account, log in as \u0027anonymous\u0027 or \u0027ftp\u0027."
    },
    {
      "name": "AUTH TLS",
      "durationMs": 121.6,
      "ok": true,
      "response": "234 Switching to TLS."
    },
    {
      "name": "TLS handshake",
      "durationMs": 316.5,
      "ok": true,
      "response": "Tls12 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
    },
    {
      "name": "PBSZ 0",
      "durationMs": 139.6,
      "ok": true,
      "response": "200 PBSZ OK."
    },
    {
      "name": "PROT P",
      "durationMs": 174.3,
      "ok": true,
      "response": "200 PROT OK."
    },
    {
      "name": "Login",
      "durationMs": 273.5,
      "ok": true,
      "response": "230 User \u0027demo\u0027 logged in."
    },
    {
      "name": "PWD",
      "durationMs": 165.9,
      "ok": true,
      "response": "257 \u0022/\u0022 is current directory."
    },
    {
      "name": "Change directory",
      "durationMs": 122.1,
      "ok": true,
      "response": "250 Directory changed to \u0022/pub/example\u0022."
    },
    {
      "name": "PASV",
      "durationMs": 193,
      "ok": true,
      "response": "227 Entering Passive Mode (194,108,117,16,4,16)"
    },
    {
      "name": "Data connection",
      "durationMs": 129.4,
      "ok": true,
      "response": "194.108.117.16:1040"
    },
    {
      "name": "LIST",
      "durationMs": 178.8,
      "ok": true,
      "response": "150 Opening \u0027BINARY\u0027 data connection."
    },
    {
      "name": "Transfer listing",
      "durationMs": 191,
      "ok": true,
      "response": "16 entries, 1089 bytes"
    },
    {
      "name": "Transfer complete",
      "durationMs": 0.2,
      "ok": true,
      "response": "226 Transfer complete."
    },
    {
      "name": "QUIT",
      "durationMs": 122.5,
      "ok": true,
      "response": "221 Closing session."
    }
  ],
  "tls": {
    "protocol": "TLSv1.2",
    "cipherSuite": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
    "certificate": {
      "subject": "CN=test.rebex.net",
      "issuer": "CN=YR1, O=Let\u0027s Encrypt, C=US",
      "validFrom": "2026-09-10T09:08:16+00:00",
      "validTo": "2026-12-09T09:08:15+00:00",
      "subjectAlternativeNames": [],
      "thumbprint": null
    },
    "chain": [],
    "daysUntilExpiry": 69,
    "hostnameValid": true,
    "chainTrusted": true,
    "errors": []
  },
  "loginStatus": "succeeded",
  "anonymousLogin": false,
  "directory": "/pub/example",
  "listingCount": 3,
  "listing": [
    "-r-------- 1 demo users      19156 Feb 16  2007 imap-console-client.png",
    "-rw------- 1 demo users      36672 Mar 19  2007 KeyGenerator.png",
    "-rw------- 1 demo users      24029 Mar 19  2007 KeyGeneratorSmall.png"
  ],
  "totalMs": 2560.8
}
```

### POST /api/v1/tests/streaming

**Streaming media test**

Checks an HLS or DASH stream or a media file: startup time, download speed against the bitrate, codec and resolution.

Starts the test from one or more locations (`locationIds` from `GET /api/v1/locations?testType=streaming`).
The full cost is taken when the test starts; locations that end `Completed` or `CompletedWithErrors` are charged and the rest are refunded. Returns the
test id to poll with `GET /api/v1/tests/{id}`. Send an `Idempotency-Key` header to make retries safe (see
Retries in the guide).

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `Idempotency-Key` | header | no | string | Optional. A unique value per test (a UUID is typical). Resending the same request with the same key within 24 hours returns the first test instead of starting and charging another. |

Request body (`application/json`):

| Field | Type | Required | Description |
|---|---|---|---|
| `locationIds` | array of string (uuid) | yes | Location ids from GET /api/v1/locations?testType=streaming (your plan sets how many per test). |
| `config` | [StreamingConfig](#streamingconfig) | yes |  |

Example request body:

```json
{
  "locationIds": [
    "LOCATION_ID"
  ],
  "config": {
    "url": "https://example.com/live/master.m3u8"
  }
}
```

Response `200`: [SubmitTestResponse](#submittestresponse) (`application/json`).
- Header `Idempotent-Replayed`: true when this answer returns a test started earlier with the same Idempotency-Key.

Errors: `429` Too many requests for this key (code rate_limited; wait the seconds in Retry-After), or not enough credits (code insufficient_credits or limit_reached); `400` Bad Request; `403` Forbidden; `409` Conflict; `422` Unprocessable Entity; `503` Service Unavailable.

#### Result

When the test finishes, each run's `result` in `GET /api/v1/tests/{id}` is a **StreamingResult**
(see Models for every field). For example:

```json
{
  "type": "streaming",
  "url": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
  "format": "hls",
  "resolvedIp": "151.101.194.132",
  "initialStatusCode": 200,
  "initialTimings": {
    "dnsMs": 1.2,
    "connectMs": 2.2,
    "tlsMs": 12.3,
    "sendMs": 0.2,
    "waitMs": 78.4,
    "receiveMs": 0.5,
    "totalMs": 95.1
  },
  "variants": [
    {
      "bandwidthKbps": 2149.3,
      "resolution": "1280x720",
      "codecs": "mp4a.40.2,avc1.64001f",
      "frameRate": null
    },
    {
      "bandwidthKbps": 246.4,
      "resolution": "320x184",
      "codecs": "mp4a.40.5,avc1.42000d",
      "frameRate": null
    },
    {
      "bandwidthKbps": 460.6,
      "resolution": "512x288",
      "codecs": "mp4a.40.5,avc1.420016",
      "frameRate": null
    }
  ],
  "selectedVariant": {
    "bandwidthKbps": 2149.3,
    "resolution": "1280x720",
    "codecs": "mp4a.40.2,avc1.64001f",
    "frameRate": null
  },
  "segments": [
    {
      "index": 1,
      "durationSeconds": 10,
      "downloadedBytes": 1915156,
      "downloadMs": 245.7,
      "throughputKbps": 62358
    },
    {
      "index": 2,
      "durationSeconds": 10,
      "downloadedBytes": 4182812,
      "downloadMs": 153.7,
      "throughputKbps": 217713
    }
  ],
  "startupMs": 352.8,
  "mediaSeconds": 20,
  "downloadedBytes": 6097968,
  "throughputKbps": 122143,
  "bitrateKbps": 2149,
  "bufferRatio": 0.02,
  "media": {
    "container": "mpegts",
    "videoCodec": "h264",
    "resolution": "1280x720",
    "frameRate": 60,
    "audioCodec": "aac",
    "audioSampleRateHz": 44100,
    "audioChannels": "stereo",
    "bitrateKbps": 1528
  },
  "tls": {
    "protocol": "TLSv1.3",
    "cipherSuite": "TLS_AES_128_GCM_SHA256",
    "certificate": {
      "subject": "CN=test-streams.mux.dev",
      "issuer": "CN=Certainly Intermediate R1, O=Certainly, C=US",
      "validFrom": "2026-09-29T03:42:29+00:00",
      "validTo": "2026-10-29T03:42:28+00:00",
      "subjectAlternativeNames": [],
      "thumbprint": null
    },
    "chain": [],
    "daysUntilExpiry": 27,
    "hostnameValid": true,
    "chainTrusted": true,
    "errors": []
  },
  "totalMs": 1739.3,
  "note": null
}
```

### GET /api/v1/tests

**List tests**

Your tests, newest first, without their results. Filter by since and until (submission time, ISO 8601), testType, status and origin (api, web or schedule); page with limit (1-100, default 50) and cursor (nextCursor from the previous page). Use it to find a test whose id you lost, or to review recent checks. Tests are kept for 13 months on paid plans.

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `since` | query | no | string (date-time) | Only tests submitted at or after this time (ISO 8601). |
| `until` | query | no | string (date-time) | Only tests submitted before this time (ISO 8601). |
| `testType` | query | no | string: `webspeed`, `ping`, `traceroute`, `dns`, `webserver`, `email`, `dnsbl`, `ftp`, `streaming` | Only tests of this type. |
| `status` | query | no | string: `Queued`, `Running`, `Completed`, `CompletedWithErrors`, `FailedSystemError`, `CompletedWithSystemErrors` | Only tests with this status (case-insensitive). |
| `origin` | query | no | string: `api`, `web`, `schedule` | Only tests started this way. |
| `limit` | query | no | integer (int32) | Tests per page. |
| `cursor` | query | no | string | nextCursor from the previous page. |

Response `200`: [ApiTestPage](#apitestpage) (`application/json`).

Errors: `400` Bad Request.

### GET /api/v1/tests/{id}

**Get a test and its results**

The test's overall status and one run per location, with its status and results. Poll every few seconds until the
status is no longer `Queued` or `Running` (most tests finish within a minute; website speed tests within about two).

**Results.** Each run's `result` holds that location's measurements. Its schema depends on the test type, and its
`type` property names it: `webspeed` WebSpeedResult, `ping` PingResult, `traceroute` TracerouteResult, `dns`
DnsTraceResult, `webserver` WebServerResult, `email` EmailServerResult, `dnsbl` DnsblResult, `ftp` FtpResult,
`streaming` StreamingResult. Each test's page under **Tests** shows an example, and Models lists every field.

- Every field of a result is always present; `null` means the value isn't available, and lists are empty rather
  than null. Durations end in `Ms`, sizes in `Bytes`, rates in `Kbps`; percentages are 0-100.
- `result` is `null` while the run is in progress, or when it failed before measuring anything.
- Why a location failed is in the run's `errorMessage` and `failureCategory`. `error` fields inside a result
  describe one step (a redirect, a name server), not the whole run.
- Fields may be added within v1; existing fields aren't renamed or removed.

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | path | yes | string (uuid) |  |

Response `200`: [ApiTest](#apitest) (`application/json`).

Example response:

```json
{
  "id": "01a175de-dfe8-7aa8-8cf8-e47c8a826f84",
  "publicId": 201635648893,
  "testType": "ping",
  "target": "example.com",
  "status": "CompletedWithErrors",
  "submittedAt": "2026-10-27T14:00:00+00:00",
  "completedAt": "2026-10-27T14:00:11+00:00",
  "config": {
    "target": "example.com"
  },
  "runs": [
    {
      "id": "6f1c2a4e-0b7d-4c1e-9a52-3d8e5f7a1001",
      "location": {
        "id": "6f1c2a4e-0b7d-4c1e-9a52-000000001001",
        "name": "Chicago, US",
        "region": "North America",
        "latitude": 41.88,
        "longitude": -87.63
      },
      "status": "Completed",
      "queuePosition": null,
      "queuedAt": "2026-10-27T14:00:00+00:00",
      "startedAt": "2026-10-27T14:00:02+00:00",
      "finishedAt": "2026-10-27T14:00:08+00:00",
      "failureCategory": null,
      "errorMessage": null,
      "supportReference": null,
      "result": {
        "type": "ping",
        "resolvedIp": "104.20.23.154",
        "averageLatencyMs": 1.2,
        "packetLossPercent": 0,
        "packetsSent": 5,
        "packetsReceived": 5,
        "dnsFailed": false
      },
      "artifacts": []
    },
    {
      "id": "6f1c2a4e-0b7d-4c1e-9a52-3d8e5f7a2002",
      "location": {
        "id": "6f1c2a4e-0b7d-4c1e-9a52-000000002002",
        "name": "Frankfurt, DE",
        "region": "Europe",
        "latitude": 50.11,
        "longitude": 8.68
      },
      "status": "CompletedWithErrors",
      "queuePosition": null,
      "queuedAt": "2026-10-27T14:00:00+00:00",
      "startedAt": "2026-10-27T14:00:02+00:00",
      "finishedAt": "2026-10-27T14:00:06+00:00",
      "failureCategory": "dns_failure",
      "errorMessage": "DNS lookup failed.",
      "supportReference": null,
      "result": {
        "type": "ping",
        "resolvedIp": null,
        "averageLatencyMs": null,
        "packetLossPercent": 100,
        "packetsSent": 5,
        "packetsReceived": 0,
        "dnsFailed": true
      },
      "artifacts": []
    },
    {
      "id": "6f1c2a4e-0b7d-4c1e-9a52-3d8e5f7a3003",
      "location": {
        "id": "6f1c2a4e-0b7d-4c1e-9a52-000000003003",
        "name": "Singapore, SG",
        "region": "Asia Pacific",
        "latitude": 1.35,
        "longitude": 103.82
      },
      "status": "AgentError",
      "queuePosition": null,
      "queuedAt": "2026-10-27T14:00:00+00:00",
      "startedAt": "2026-10-27T14:00:02+00:00",
      "finishedAt": "2026-10-27T14:00:11+00:00",
      "failureCategory": "agent_failure",
      "errorMessage": "The test couldn't run at this location.",
      "supportReference": "K7Q2M9XD",
      "result": null,
      "artifacts": []
    }
  ]
}
```

Errors: `404` Not Found.

### GET /api/v1/tests/{id}/runs/{runId}/artifacts/{kind}

**Download a result file**

A file a run produced, such as a website speed test's har, video, screenshot, lighthouse or lighthouse-html report. Use the url in the run's artifacts list. Large text files are sent gzip-compressed (Content-Encoding: gzip), which most HTTP clients unpack automatically.

Parameters:

| Name | In | Required | Type | Description |
|---|---|---|---|---|
| `id` | path | yes | string (uuid) |  |
| `runId` | path | yes | string (uuid) |  |
| `kind` | path | yes | string |  |

Response `200`: string (binary) (`*/*`). The file. Its Content-Type is the artifact's contentType; large text files come gzip-compressed (Content-Encoding: gzip).

Errors: `404` Not Found.

## Models

Types used above. "or null" means the field is always present and may be null.

### AccountPlan

| Field | Type | Required | Description |
|---|---|---|---|
| `isPaid` | boolean | yes | An active plan or paid credits left. API tests need a paid account. |
| `tier` | [PlanTier](#plantier) or null | yes |  |
| `interval` | [BillingInterval](#billinginterval) or null | yes |  |
| `status` | [SubscriptionStatus](#subscriptionstatus) or null | yes |  |
| `renewsAt` | string (date-time) or null | yes |  |
| `cancelAtPeriodEnd` | boolean | yes |  |
| `pendingTier` | [PlanTier](#plantier) or null | yes |  |
| `balance` | [PaidBalance](#paidbalance) | yes |  |
| `maxLocations` | integer (int32) or null | yes | Most locations per test; null means no limit (paid accounts can use every location). |

### ApiRun

The test at one location.

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `location` | [LocationView](#locationview) | yes |  |
| `status` | [RunStatus](#runstatus) | yes | Queued, Connecting or Running while in progress; Completed; CompletedWithErrors (the target had a problem, such as an HTTP error, a DNS failure or a timeout); Blocked (the target resolved to a private address); or AgentError, Skipped, QueueTimeout or Canceled (a problem on our side, not charged). |
| `queuePosition` | integer (int32) or null | yes | Position in the queue while queued. |
| `queuedAt` | string (date-time) | yes |  |
| `startedAt` | string (date-time) or null | yes |  |
| `finishedAt` | string (date-time) or null | yes |  |
| `failureCategory` | string or null | yes | Why the location had errors or failed. Target problems: dns_failure, connection_failure, tls_failure, http_error, content_mismatch, blacklisted, protocol_error, authentication_failed, target_timeout, blocked_internal_address. Our side: queue_timeout, location_unavailable, maintenance_canceled, agent_offline, agent_failure, dispatch_failure, execution_timeout. |
| `errorMessage` | string or null | yes | What went wrong, in words. The one place to read why a location failed. |
| `supportReference` | string or null | yes | Quote this to support when a run failed because of a problem on our side. |
| `result` | [TestResult](#testresult) or null | yes | The measurements; the schema depends on the test type (the type field names it). Null until the run finishes, or when it failed before measuring anything. |
| `artifacts` | array of [ArtifactView](#artifactview) | yes | Files the run produced (website speed tests: har, lighthouse, lighthouse-html, video, screenshot, filmstrip with frame-0, frame-1…, dom, console). Download each url with your API key. |

### ApiTest

A test and its runs, one per location.

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `publicId` | integer (int64) or null | yes | The number in the test's result page URL. |
| `testType` | string | yes | webspeed, ping, traceroute, dns, webserver, email, dnsbl, ftp or streaming. |
| `target` | string | yes | The URL, host or address that was tested. |
| `status` | [TestStatus](#teststatus) | yes | Queued or Running; Completed (every location completed without a problem); CompletedWithErrors (a location found a problem with the target); CompletedWithSystemErrors (the target was fine wherever it was tested, but some locations failed on our side); or FailedSystemError (no location could test the target). |
| `submittedAt` | string (date-time) | yes |  |
| `completedAt` | string (date-time) or null | yes |  |
| `config` | [JsonElement](#jsonelement) | yes | The settings the test ran with; passwords, header values and cookie values are masked. |
| `runs` | array of [ApiRun](#apirun) | yes |  |

### ApiTestPage

A page of tests, newest first.

| Field | Type | Required | Description |
|---|---|---|---|
| `items` | array of [ApiTestSummary](#apitestsummary) | yes |  |
| `nextCursor` | string or null | yes | Pass as cursor to get the next (older) page; null on the last page. |

### ApiTestSummary

A test in the list, without its results (GET /api/v1/tests/{id} returns those).

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `publicId` | integer (int64) or null | yes |  |
| `testType` | string | yes | webspeed, ping, traceroute, dns, webserver, email, dnsbl, ftp or streaming. |
| `target` | string | yes |  |
| `status` | [TestStatus](#teststatus) | yes | Same values as a test's status. |
| `origin` | string: `api`, `web`, `schedule` | yes | How the test was started: api, web (the website) or schedule (a scheduled test). |
| `submittedAt` | string (date-time) | yes |  |
| `completedAt` | string (date-time) or null | yes |  |
| `locationCount` | integer (int32) | yes |  |

### ApiTestType

A test type with its current cost and time limit.

| Field | Type | Required | Description |
|---|---|---|---|
| `testType` | string | yes | The type used in POST /api/v1/tests/{type} and in results. |
| `name` | string | yes |  |
| `creditsPerLocation` | integer (int32) | yes | Credits charged per location that runs the test. |
| `timeLimitSeconds` | integer (int32) | yes | How long one location's run may take, in seconds. |

### ArtifactView

| Field | Type | Required | Description |
|---|---|---|---|
| `kind` | string | yes | har, lighthouse, lighthouse-html, video, screenshot, filmstrip, frame-0, frame-1…, dom or console. |
| `url` | string (uri) | yes | Absolute URL of the file (https://…/api/v1/tests/{id}/runs/{runId}/artifacts/{kind}); request it with your X-Api-Key. |
| `contentType` | string | yes |  |
| `sizeBytes` | integer (int64) | yes | Stored size; large text files are stored and sent gzip-compressed. |

### BillingInterval

One of: `Month`, `Year`.

### BlockedDestination

A destination the test refused to connect to because it resolved to a private or internal address.

| Field | Type | Required | Description |
|---|---|---|---|
| `host` | string | yes |  |
| `port` | integer (int32) | yes |  |
| `ip` | string or null | yes |  |

### Certificate

The server certificate.

| Field | Type | Required | Description |
|---|---|---|---|
| `subject` | string or null | yes |  |
| `issuer` | string or null | yes |  |
| `validFrom` | string (date-time) or null | yes |  |
| `validTo` | string (date-time) or null | yes |  |
| `subjectAlternativeNames` | array of string | yes | Names the certificate covers. Empty for network tools, which don't read them. |
| `thumbprint` | string or null | yes | SHA-1 thumbprint; null for network tools. |

### ChainCertificate

One certificate of the chain, from the server certificate up to the root.

| Field | Type | Required | Description |
|---|---|---|---|
| `subject` | string or null | yes |  |
| `issuer` | string or null | yes |  |
| `validFrom` | string (date-time) or null | yes |  |
| `validTo` | string (date-time) or null | yes |  |
| `thumbprint` | string or null | yes |  |

### ContentTypeTotal

Requests and bytes per resource type.

| Field | Type | Required | Description |
|---|---|---|---|
| `resourceType` | string | yes | document, stylesheet, script, image, font, media, xhr (including fetch) or other. |
| `requestCount` | integer (int32) | yes |  |
| `transferBytes` | integer (int64) | yes | Bytes transferred (compressed size). |

### DnsblCheck

One blacklist lookup.

| Field | Type | Required | Description |
|---|---|---|---|
| `list` | string | yes | The blacklist zone, e.g. zen.spamhaus.org. |
| `ip` | string | yes |  |
| `status` | string | yes | listed, clean, refused (the list declined to answer) or error. |
| `returnCodes` | array of string | yes | The list's 127.0.0.x answers; they say why an address is listed. |
| `reason` | string or null | yes | The list's explanation, when it gives one. |
| `responseMs` | number (double) | yes |  |

### DnsblConfig

| Field | Type | Required | Description |
|---|---|---|---|
| `target` | string | yes |  |
| `lists` | array of string or null | no | Ignored: every test checks the same 8 blacklists (see the test's description). |

### DnsblResult

Email blacklist test result: the target's IPv4 addresses checked against DNS blacklists, one check per address per list.

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string: `dnsbl` | yes |  |
| `target` | string | yes |  |
| `ips` | array of string | yes | The IPv4 addresses that were checked (up to 5). |
| `checks` | array of [DnsblCheck](#dnsblcheck) | yes | One entry per address per blacklist: 8 × the number of ips. |
| `listedCount` | integer (int32) | yes | Checks with status listed (an address listed on two lists counts twice). |
| `checkedCount` | integer (int32) | yes | Checks with status listed or clean; refused and error checks aren't counted. Full coverage is 8 × the number of ips. |

### DnsRecord

A DNS record.

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | Owner name, fully qualified with a trailing dot. |
| `type` | string | yes | Record type, e.g. A, AAAA, CNAME, MX, TXT. |
| `ttl` | integer (int32) | yes | Time to live, in seconds. |
| `value` | string | yes |  |

### DnsServerReply

One name server's reply at one level of the trace.

| Field | Type | Required | Description |
|---|---|---|---|
| `server` | string | yes | Name server host name. |
| `ip` | string | yes |  |
| `responseMs` | number (double) or null | yes | Null when the server didn't answer. |
| `kind` | string | yes | DNS terms: referral, answer, cname, nodata, nxdomain, error or noresponse. |
| `responseCode` | string or null | yes | DNS response code, e.g. NOERROR, NXDOMAIN, SERVFAIL. |
| `flags` | string or null | yes | Header flags of the reply, e.g. "qr aa". |
| `referral` | array of string | yes | Name servers the reply delegated to. |
| `records` | array of [DnsRecord](#dnsrecord) | yes |  |
| `error` | string or null | yes | Why the query failed, for this server only. |
| `followed` | boolean | yes | The trace continued from this reply. |
| `agrees` | boolean | yes | Same answer as the majority of servers at this level. |

### DnsTraceConfig

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes |  |
| `recordType` | string: `A`, `AAAA`, `NS`, `CNAME`, `SOA`, `TXT`, `MX`, `PTR`, `SPF`, `SRV`, `NAPTR`, `CAA` | no |  |
| `dnsServer` | string or null | no |  |
| `recursionDesired` | boolean | no |  |

### DnsTraceLevel

One delegation level: every name server of the zone was asked.

| Field | Type | Required | Description |
|---|---|---|---|
| `queryName` | string | yes |  |
| `zone` | string | yes | The zone, e.g. ".", "com", "example.com". |
| `consistent` | boolean | yes | Every server answered, and all gave the same answer. |
| `servers` | array of [DnsServerReply](#dnsserverreply) | yes |  |

### DnsTraceResult

DNS trace result: resolution from the root servers down to the answer, asking every name server at each level.

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string: `dns` | yes |  |
| `domain` | string | yes |  |
| `recordType` | string | yes |  |
| `outcome` | string | yes | DNS terms: answer, nodata, nxdomain or error. |
| `answers` | array of [DnsRecord](#dnsrecord) | yes |  |
| `totalMs` | number (double) | yes |  |
| `levels` | array of [DnsTraceLevel](#dnstracelevel) | yes |  |

### EmailServerConfig

| Field | Type | Required | Description |
|---|---|---|---|
| `protocol` | string: `smtp`, `pop3`, `imap` | yes |  |
| `host` | string | yes |  |
| `port` | integer (int32) | no |  |
| `security` | string: `none`, `startTls`, `ssl` | no |  |
| `userName` | string or null | no |  |
| `password` | string or null | no |  |

### EmailServerResult

Email server test result: connection, greeting, capabilities, TLS and optional login; no email is sent.

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string: `email` | yes |  |
| `protocol` | string | yes | smtp, pop3 or imap. |
| `host` | string | yes |  |
| `port` | integer (int32) | yes |  |
| `security` | string | yes | none, startTls or ssl. |
| `resolvedIp` | string or null | yes |  |
| `banner` | string or null | yes | The server's greeting. |
| `capabilities` | array of string | yes | Extensions the server announced (e.g. EHLO lines). |
| `steps` | array of [ProtocolStep](#protocolstep) | yes |  |
| `tls` | [TlsInfo](#tlsinfo) or null | yes |  |
| `loginStatus` | string | yes | succeeded, failed, skipped (no credentials given) or unsupported (the server offers no login). |
| `totalMs` | number (double) | yes |  |

### FailedRequest

A request that failed: an HTTP error status, or no response at all.

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes |  |
| `statusCode` | integer (int32) or null | yes | The HTTP status; null when no response was received (see error). |
| `error` | string or null | yes | The network error when there was no response, e.g. net::ERR_NAME_NOT_RESOLVED. |
| `resourceType` | string | yes | document, stylesheet, script, image, font, media, xhr or other. |

### FtpConfig

| Field | Type | Required | Description |
|---|---|---|---|
| `host` | string | yes |  |
| `port` | integer (int32) | no |  |
| `security` | string: `none`, `explicit`, `implicit` | no |  |
| `userName` | string or null | no |  |
| `password` | string or null | no |  |
| `directory` | string or null | no |  |
| `listDirectory` | boolean | no |  |

### FtpResult

FTP test result: connection, TLS, login and optional directory listing.

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string: `ftp` | yes |  |
| `host` | string | yes |  |
| `port` | integer (int32) | yes |  |
| `security` | string | yes | none, explicit or implicit. |
| `resolvedIp` | string or null | yes |  |
| `banner` | string or null | yes |  |
| `steps` | array of [ProtocolStep](#protocolstep) | yes |  |
| `tls` | [TlsInfo](#tlsinfo) or null | yes |  |
| `loginStatus` | string | yes | succeeded, failed or skipped. |
| `anonymousLogin` | boolean | yes | The login was anonymous (no user name was given). |
| `directory` | string or null | yes | The directory that was opened, if any. |
| `listingCount` | integer (int32) or null | yes | Entries in the listing; null when no listing was requested. |
| `listing` | array of string | yes | The listing's lines as the server sent them. |
| `totalMs` | number (double) | yes |  |

### HttpHeader

An HTTP response header.

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes |  |
| `value` | string | yes |  |

### HttpRedirect

One request of the redirect chain.

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes |  |
| `ip` | string or null | yes |  |
| `statusCode` | integer (int32) or null | yes |  |
| `reasonPhrase` | string or null | yes | Null over HTTP/2, which has no reason phrases. |
| `location` | string or null | yes | The Location header. |
| `timings` | [HttpTimings](#httptimings) | yes |  |
| `responseBytes` | integer (int64) | yes |  |
| `error` | string or null | yes | Why this request failed, if it did. |

### HttpTimings

Time spent in each phase of one HTTP request, in milliseconds (0 when a phase wasn't needed, such as TLS on http:// or DNS on a reused connection).

| Field | Type | Required | Description |
|---|---|---|---|
| `dnsMs` | number (double) | yes |  |
| `connectMs` | number (double) | yes |  |
| `tlsMs` | number (double) | yes |  |
| `sendMs` | number (double) | yes |  |
| `waitMs` | number (double) | yes |  |
| `receiveMs` | number (double) | yes |  |
| `totalMs` | number (double) | yes |  |

### JsonElement

Any JSON value.

### LighthouseScores

Lighthouse category scores, 0-100 (null when Lighthouse didn't finish).

| Field | Type | Required | Description |
|---|---|---|---|
| `version` | string or null | yes | Lighthouse version. |
| `timedOut` | boolean | yes | Lighthouse stopped before finishing; the page-load measurements are still valid. |
| `performance` | integer (int32) or null | yes |  |
| `accessibility` | integer (int32) or null | yes |  |
| `bestPractices` | integer (int32) or null | yes |  |
| `seo` | integer (int32) or null | yes |  |

### LocationView

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `name` | string | yes |  |
| `region` | string or null | yes |  |
| `latitude` | number (double) | yes |  |
| `longitude` | number (double) | yes |  |

### MediaInfo

Codec details read from the downloaded media.

| Field | Type | Required | Description |
|---|---|---|---|
| `container` | string or null | yes |  |
| `videoCodec` | string or null | yes |  |
| `resolution` | string or null | yes |  |
| `frameRate` | number (double) or null | yes |  |
| `audioCodec` | string or null | yes |  |
| `audioSampleRateHz` | integer (int32) or null | yes |  |
| `audioChannels` | string or null | yes | e.g. mono, stereo, 5.1. |
| `bitrateKbps` | number (double) or null | yes |  |

### PageRedirect

A redirect of the main document.

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes |  |
| `statusCode` | integer (int32) | yes |  |
| `location` | string or null | yes | The Location header. |

### PaidBalance

| Field | Type | Required | Description |
|---|---|---|---|
| `monthly` | integer (int32) | yes | This month's plan credits left; they expire at monthlyExpiresAt. |
| `packs` | integer (int32) | yes | OnDemand pack credits left (they lapse after 24 months without account activity). |
| `adjustments` | integer (int32) | yes | Credits added by our support team, if any. |
| `monthlyExpiresAt` | string (date-time) or null | yes | When this month's plan credits expire; null without a plan. |
| `total` | integer (int32) | yes | Credits you can spend now: monthly + packs + adjustments. Tests in progress are already taken out. |

### PingConfig

| Field | Type | Required | Description |
|---|---|---|---|
| `target` | string | yes |  |

### PingResult

Ping test result: 5 ICMP echo requests from one location.

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string: `ping` | yes |  |
| `resolvedIp` | string or null | yes | The IP address that was pinged. |
| `averageLatencyMs` | number (double) or null | yes | Average round trip over all 5 packets, with timeouts counted as 2000 ms; null when every packet timed out or DNS failed. |
| `packetLossPercent` | number (double) | yes |  |
| `packetsSent` | integer (int32) | yes |  |
| `packetsReceived` | integer (int32) | yes |  |
| `dnsFailed` | boolean | yes | The host name couldn't be resolved. |

### PlanTier

One of: `Small`, `Medium`, `Large`.

### ProblemDetails

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string or null | no |  |
| `title` | string or null | no |  |
| `status` | integer (int32) or null | no |  |
| `detail` | string or null | no |  |
| `instance` | string or null | no |  |
| `code` | string or null | no | Machine-readable reason: invalid_config, locations, invalid_idempotency_key, invalid_query, paid_required, testing_unavailable, idempotency_in_progress, idempotency_key_reused, rate_limited, insufficient_credits, limit_reached or maintenance (see Errors in the guide). |

### ProtocolStep

One protocol step and the server's reply.

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes | The step, e.g. Connect, Greeting, EHLO, STARTTLS, TLS handshake, Login. |
| `durationMs` | number (double) | yes |  |
| `ok` | boolean | yes | The step succeeded. |
| `response` | string or null | yes | The server's reply (passwords are never included). |

### PublicLocation

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | yes |  |
| `name` | string | yes |  |
| `latitude` | number (double) | yes |  |
| `longitude` | number (double) | yes |  |
| `isMainlandChina` | boolean | no |  |

### PublicRegion

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) or null | yes |  |
| `name` | string | yes |  |
| `locations` | array of [PublicLocation](#publiclocation) | yes |  |

### RunStatus

One of: `Queued`, `Connecting`, `Running`, `Completed`, `CompletedWithErrors`, `Blocked`, `AgentError`, `Skipped`, `QueueTimeout`, `Canceled`.

### StreamingConfig

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes |  |

### StreamingResult

Streaming media test result: the manifest and the first segments of an HLS or DASH stream, or a progressive media file.

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string: `streaming` | yes |  |
| `url` | string | yes |  |
| `format` | string | yes | hls, dash or progressive. |
| `resolvedIp` | string or null | yes |  |
| `initialStatusCode` | integer (int32) or null | yes | HTTP status of the first request: the manifest, or the media file for progressive. |
| `initialTimings` | [HttpTimings](#httptimings) or null | yes | Timings of that first request. |
| `variants` | array of [StreamVariant](#streamvariant) | yes | Quality levels in the manifest; empty for progressive. |
| `selectedVariant` | [StreamVariant](#streamvariant) or null | yes | The level that was downloaded; null for progressive. |
| `segments` | array of [StreamSegment](#streamsegment) | yes |  |
| `startupMs` | number (double) or null | yes | Time until the first segment was downloaded. |
| `mediaSeconds` | number (double) or null | yes | Media duration of what was downloaded. |
| `downloadedBytes` | integer (int64) | yes |  |
| `throughputKbps` | number (double) or null | yes | Average download speed. |
| `bitrateKbps` | number (double) or null | yes | Bitrate of the stream. |
| `bufferRatio` | number (double) or null | yes | Download time divided by media duration: below 1 plays smoothly, above 1 means rebuffering. |
| `media` | [MediaInfo](#mediainfo) or null | yes |  |
| `tls` | [TlsInfo](#tlsinfo) or null | yes |  |
| `totalMs` | number (double) | yes |  |
| `note` | string or null | yes | A remark about the test, e.g. why fewer segments were downloaded. |

### StreamSegment

One downloaded media segment (or chunk of a progressive file).

| Field | Type | Required | Description |
|---|---|---|---|
| `index` | integer (int32) | yes |  |
| `durationSeconds` | number (double) or null | yes |  |
| `downloadedBytes` | integer (int64) | yes |  |
| `downloadMs` | number (double) | yes |  |
| `throughputKbps` | number (double) | yes |  |

### StreamVariant

A quality level offered by an adaptive stream.

| Field | Type | Required | Description |
|---|---|---|---|
| `bandwidthKbps` | number (double) or null | yes | Bandwidth the manifest declares for this level. |
| `resolution` | string or null | yes | e.g. 1280x720. |
| `codecs` | string or null | yes |  |
| `frameRate` | number (double) or null | yes |  |

### SubmitTestResponse

| Field | Type | Required | Description |
|---|---|---|---|
| `testId` | string (uuid) | yes |  |
| `publicId` | integer (int64) or null | yes |  |

### SubscriptionStatus

One of: `Active`, `PastDue`, `Canceled`.

### TestResult

One of these, chosen by `type`:

- `webspeed`: [WebSpeedResult](#webspeedresult)
- `ping`: [PingResult](#pingresult)
- `traceroute`: [TracerouteResult](#tracerouteresult)
- `dns`: [DnsTraceResult](#dnstraceresult)
- `webserver`: [WebServerResult](#webserverresult)
- `email`: [EmailServerResult](#emailserverresult)
- `dnsbl`: [DnsblResult](#dnsblresult)
- `ftp`: [FtpResult](#ftpresult)
- `streaming`: [StreamingResult](#streamingresult)

### TestStatus

One of: `Queued`, `Running`, `Completed`, `CompletedWithErrors`, `FailedSystemError`, `CompletedWithSystemErrors`.

### ThirdParty

A third-party service the page loaded.

| Field | Type | Required | Description |
|---|---|---|---|
| `entity` | string | yes | The service, e.g. Google Tag Manager. |
| `category` | string or null | yes | Its category, e.g. analytics, ad, tag-manager; null when unknown. |
| `transferBytes` | integer (int64) | yes |  |
| `mainThreadTimeMs` | number (double) | yes | Main-thread time its scripts used. |
| `blockingTimeMs` | number (double) or null | yes | Time it blocked the main thread; null when not measured. |
| `requestCount` | integer (int32) or null | yes |  |

### TlsInfo

The TLS connection and certificate checks. Null when TLS wasn't used.

| Field | Type | Required | Description |
|---|---|---|---|
| `protocol` | string or null | yes | Protocol version, e.g. TLSv1.3 or TLSv1.2. |
| `cipherSuite` | string or null | yes | Negotiated cipher suite, e.g. TLS_AES_256_GCM_SHA384. |
| `certificate` | [Certificate](#certificate) or null | yes |  |
| `chain` | array of [ChainCertificate](#chaincertificate) | yes | The certificate chain (website speed tests only; empty for network tools). |
| `daysUntilExpiry` | integer (int32) or null | yes | Days until the server certificate expires; negative when it has expired. |
| `hostnameValid` | boolean | yes | The certificate is valid for the host name. |
| `chainTrusted` | boolean | yes | The chain leads to a trusted root. |
| `errors` | array of string | yes | Validation problems, empty when the certificate is fine. |

### TracerouteConfig

| Field | Type | Required | Description |
|---|---|---|---|
| `target` | string | yes |  |
| `maxHops` | integer (int32) | no | 1 to 64; 0 or left out means 30. |

### TracerouteHop

One hop of the route.

| Field | Type | Required | Description |
|---|---|---|---|
| `hop` | integer (int32) | yes | Hop number, starting at 1. |
| `ip` | string or null | yes | The router's IP address; null when no probe got a reply. |
| `hostName` | string or null | yes | The router's reverse DNS name, when it has one. |
| `rttMs` | array of number (double) or null | yes | Round trip of each of the 4 probes; null for a probe that got no reply. |
| `minMs` | number (double) or null | yes |  |
| `averageMs` | number (double) or null | yes |  |
| `maxMs` | number (double) or null | yes |  |
| `lossPercent` | number (double) | yes |  |

### TracerouteResult

Traceroute test result: the network path from one location to the target.

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string: `traceroute` | yes |  |
| `resolvedIp` | string or null | yes | The target's IP address. |
| `sourceIp` | string or null | yes | The testing location's public IP address. |
| `reachedTarget` | boolean | yes | The last hop reached the target. |
| `hopCount` | integer (int32) | yes |  |
| `hops` | array of [TracerouteHop](#traceroutehop) | yes |  |

### WebServerConfig

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes |  |
| `method` | string: `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` | no |  |
| `headers` | array of object or null | no |  |
| `body` | string or null | no |  |
| `contentType` | string or null | no |  |
| `basicAuth` | object | no |  |
| `followRedirects` | boolean | no |  |
| `expectedText` | string or null | no |  |

### WebServerResult

Web server test result: one HTTP request (and its redirects) from one location.

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string: `webserver` | yes |  |
| `url` | string | yes | The requested URL. |
| `finalUrl` | string or null | yes | The URL of the final response, after redirects. |
| `resolvedIp` | string or null | yes |  |
| `statusCode` | integer (int32) or null | yes |  |
| `reasonPhrase` | string or null | yes | Null over HTTP/2, which has no reason phrases. |
| `httpVersion` | string or null | yes | e.g. HTTP/1.1 or HTTP/2. |
| `timings` | [HttpTimings](#httptimings) or null | yes | Phase timings of the final request. |
| `totalMs` | number (double) | yes | Total time including redirects. |
| `responseBytes` | integer (int64) | yes |  |
| `bodyTruncated` | boolean | yes | The body was larger than the download limit and was cut off. |
| `responseHeaders` | array of [HttpHeader](#httpheader) | yes |  |
| `redirects` | array of [HttpRedirect](#httpredirect) | yes | Requests before the final one, in order. |
| `tls` | [TlsInfo](#tlsinfo) or null | yes |  |
| `contentMatched` | boolean or null | yes | The final body contains expectedText; null when no expectedText was set. |

### WebSpeedConfig

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes |  |
| `device` | string: `desktop`, `mobile` | no |  |
| `network` | string: `none`, `broadband`, `fourG`, `threeG` | no |  |
| `dnsMode` | string: `normal`, `resolveFromRoot` | no |  |
| `userAgent` | string or null | no |  |
| `hostOverrideIp` | string or null | no |  |
| `headers` | array of object or null | no |  |
| `cookies` | array of object or null | no |  |
| `basicAuth` | object | no |  |

### WebSpeedResult

Website speed test result: one page load in Chrome from one location.

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string: `webspeed` | yes |  |
| `finalUrl` | string or null | yes | The page URL after redirects. |
| `statusCode` | integer (int32) or null | yes | HTTP status of the main document's final response. |
| `httpVersion` | string or null | yes | HTTP version of the main document, e.g. h2 or http/1.1. |
| `connectedIp` | string or null | yes | IP address the browser connected to for the page's host. |
| `mainHostDnsMs` | number (double) or null | yes | DNS lookup time of the page's host. |
| `dnsMode` | string or null | yes | normal or resolveFromRoot, as requested. |
| `hostOverrideIp` | string or null | yes | The IP address the host was loaded from instead of its DNS answer, when requested. |
| `device` | string or null | yes | desktop or mobile, as requested. |
| `network` | string or null | yes | Network throttling: none, broadband, fourG or threeG, as requested. |
| `userAgent` | string or null | yes | The user agent the browser sent. |
| `browserVersion` | string or null | yes | Chrome version. |
| `ttfbMs` | number (double) or null | yes | Time to first byte of the main document. |
| `fcpMs` | number (double) or null | yes | First Contentful Paint. |
| `lcpMs` | number (double) or null | yes | Largest Contentful Paint. |
| `cls` | number (double) or null | yes | Cumulative Layout Shift (unitless score). |
| `tbtMs` | number (double) or null | yes | Total Blocking Time. |
| `speedIndexMs` | number (double) or null | yes | Speed Index. |
| `ttiMs` | number (double) or null | yes | Time to Interactive. |
| `domContentLoadedMs` | number (double) or null | yes | Time until DOMContentLoaded. |
| `loadTimeMs` | number (double) or null | yes | Time until the load event. |
| `pageBytes` | integer (int64) or null | yes | Total bytes transferred (compressed). |
| `requestCount` | integer (int32) or null | yes |  |
| `failedRequestCount` | integer (int32) or null | yes |  |
| `consoleErrorCount` | integer (int32) or null | yes | Errors the page logged to the browser console. |
| `lighthouse` | [LighthouseScores](#lighthousescores) | yes |  |
| `redirects` | array of [PageRedirect](#pageredirect) | yes | Redirects of the main document, in order. |
| `contentTypes` | array of [ContentTypeTotal](#contenttypetotal) | yes |  |
| `thirdParties` | array of [ThirdParty](#thirdparty) | yes |  |
| `failedRequests` | array of [FailedRequest](#failedrequest) | yes | Up to 500 failed requests. |
| `blockedDestinations` | array of [BlockedDestination](#blockeddestination) | yes |  |
| `tls` | [TlsInfo](#tlsinfo) or null | yes |  |
