netnl-findings/v1
The document internetnl results <id> --format findings writes. This is
the producer side of the contract: netnl writes this shape, and any
consumer (for example Wanderer, adopting internet.nl results as one of
its own dimensions) reads it without depending on netnl itself or on the
batch API's own quirks directly.
Getting one
internetnl results <request-id> --format findings # to stdout
internetnl results <request-id> --format findings --findings-out out.json
Only a done batch produces output. A batch that is still registering,
running or generating — or one that ended error/cancelled — exits
non-zero and writes nothing: not an empty file, and if --findings-out
names an existing file, that file is left exactly as it was. A CI step
that trusts the exit code can never archive a partial or empty document
as a successful measurement.
Shape
{
"schema": "netnl-findings/v1",
"generated_at": "2026-09-22T10:25:16Z",
"source": {
"api": "internet.nl batch v2",
"endpoint": "api.westerweel.work",
"request_id": "b2dda607433522760b51faecbcb23c87"
},
"domains": [
{
"domain": "example.nl",
"type": "web",
"status": "ok",
"measured_at": "2026-09-22T10:25:16.924927+00:00",
"score_percent": 95,
"report_url": "https://netnl.example/site/example.nl/485/",
"results": [
{
"test": "web_dnssec_exist",
"category": "web_dnssec",
"status": "passed",
"verdict": "good",
"detail": null
}
]
}
]
}
domains is a list, sorted alphabetically by domain; each domain's
results list is sorted alphabetically by test. The same finished
batch, exported at the same generated_at, always exports the same bytes
— same order, same JSON formatting — so two exports of one batch diff
cleanly in an archive and a consumer can recognise a re-import as already
seen.
Requirement: Traceability
A consumer treats a finding as evidence and needs a way back to the batch that produced it — a file with no origin information cannot be checked against the instance that measured it. The document header carries that origin, alongside the results:
| Field | Meaning |
|---|---|
generated_at |
The moment this file was written, RFC 3339 in UTC (YYYY-MM-DDTHH:MM:SSZ) — not the batch's own finished_date, which lives per-domain below. |
source.api |
The batch API generation, from reply.api_version's major component ("2.7.0" → "internet.nl batch v2"). |
source.endpoint |
The instance's hostname only — never a full URL, and never with credentials. |
source.request_id |
reply.request.request_id: the batch this export came from, verbatim. |
Per-domain fields
| Field | Meaning |
|---|---|
domain |
The hostname submitted. |
type |
"web" or "mail" — the batch's own request type, same for every domain in one export. |
status |
The API's domain status ("ok" on success). A non-"ok" domain still appears — see below. |
measured_at |
The batch's request.finished_date. The API gives no per-domain timestamp, so every domain in one export carries the same value. |
score_percent |
The API's own published score. null when the domain has no scoring (a broken domain, e.g. status: "error"). Never recomputed here — a second score that disagrees with the public report destroys trust in both. |
report_url |
domains[d].report.url, passed through unmodified. On a self-hosted instance this points at that instance, never at internet.nl. null when the domain has no report. |
results |
The subtest list below. Empty ([]), not omitted, when the domain has no results (e.g. status: "error"). |
A domain that failed to measure
A domain whose own status is not "ok" (for example "error") still
gets a block, with that status and an empty results list — never
dropped from the document. A consumer that only sees domains with
results cannot tell "measured badly" from "never submitted"; this is
why the block always appears.
Per-test fields
| Field | Meaning |
|---|---|
test |
The API's own subtest name, e.g. web_https_tls_ciphers. |
category |
The category the instance's own GET /metadata/report hierarchy places test under: report.hierarchy.<web\|mail> is a tree, three (or four, for RPKI's nameserver variants) layers deep — category → subtestgroup → test — and test is placed under whichever top-level category it descends from, however deep. This is an exact lookup, not a prefix match: the instance says directly which category each test belongs to (web_dnssec_exist → web_dnssec; web_ns_rpki_exists → web_rpki, several layers down under web_rpki → web_ns_rpki → web_ns_rpki_exists). null when the metadata hierarchy has no entry for the test — the test still appears; nothing is dropped or renamed to force a match. |
status |
One of passed, failed, warning, info, not_tested, error — the API's own value, verbatim. error (a broken measurement) is distinct from failed (a measured, failing result). |
verdict |
The API's own verdict word (good, bad, warning, not-tested, recommendations, other, …), verbatim, alongside status rather than instead of it. |
detail |
Always null in v1. The batch API publishes no per-variant detail (that only exists in the HTML report, which this export does not scrape). The field exists so a later API version — or a later schema version — can fill it without a breaking change to the ones that don't. |
When the metadata hierarchy is unavailable
--format findings fetches GET /metadata/report alongside the batch
results to place each test's category. When that fetch fails, or the
reply is structurally unusable (missing or malformed
report/hierarchy/data), the export still writes — it is a degraded
render, never a hard failure — but category falls back to the older,
weaker rule: the longest key in the batch's own results.categories that
prefixes the test name. That rule can't see a subtestgroup whose name
isn't itself a prefix of the test (it misses web_ns_rpki_exists, for
example, and — since the real hierarchy is several layers deep — most
other tests too), so every fallback is announced with a warning: line
on stderr rather than happening silently — a findings document that
quietly drops a real category reads to a consumer as "measured, nothing
here to flag."
What this export deliberately does not do
- No aggregation, re-weighting, or omission. Every subtest the batch API returned for a domain appears exactly once.
- No second score.
score_percentis the API's own; nothing here recomputes it. - No scraping.
detailstaysnullrather than being filled from the HTML report — that would tie this export to a page layout the API itself makes no promise about. - No breaking change without a version bump.
"schema"is"netnl-findings/v1"today; a future incompatible change ships asnetnl-findings/v2rather than changing this document's meaning out from under an existing consumer.
Existing output is untouched
--format findings is additive. --json, the plain-text table, and
results without --format behave exactly as before — a CI pipeline
that has not asked for findings sees no change.