Using the API
CA Chain Check has a small JSON API. The web page uses the same API, so anything the page shows is also in the API response.
For every field and a way to try requests in the browser, see the interactive API reference (Swagger), or download the OpenAPI spec as openapi.json.
Check a server
Send a POST to https://this-server/api/check with a JSON body:
curl -s https://this-server/api/check \
-H 'Content-Type: application/json' \
-d '{"host": "www.example.edu", "port": 443}'
| Field | Required | Meaning |
|---|---|---|
host | Yes | Hostname or IP address. The hostname is sent as SNI. A URL also works; only its hostname is used. Put the port in port, not after the hostname. |
port | No | TCP port. Defaults to 443, or to the protocol's usual port when starttls names one. |
starttls | No | smtp, imap, pop3, ftp, ldap, postgres, or none. See STARTTLS. |
connectIPs | No | List of IP addresses to connect to instead of looking up the hostname, like curl --resolve. The hostname is still sent as SNI. This server may turn this off; GET /api/config says whether you can use it. Sending more addresses than this server allows is an error. |
Every IP address the hostname resolves to is tested on its own. The body must be JSON, under 4 KB, with no other fields.
STARTTLS
Mail, directory and database servers often start in plain text and switch to TLS when asked. That switch is called STARTTLS.
- Automatic: on ports 21 (FTP), 25 and 587 (SMTP), 110 (POP3), 143 (IMAP), 389 (LDAP) and 5432 (PostgreSQL), the check tries TLS first. If the TLS handshake fails, it reconnects and tries STARTTLS for that protocol.
- Named:
"starttls": "smtp"uses STARTTLS straight away, on any port. Use this for servers on other ports, such as SMTP on 2525. - Off:
"starttls": "none"uses TLS only, even on the ports above.
When STARTTLS was used, the result for that IP has tls.starttls set to the protocol name.
RSA and ECDSA certificates
Many servers have both an RSA and an ECDSA certificate. After the normal handshake, the check asks each IP for the other key type with a TLS 1.2 handshake. If the server has it, that chain is in results[].alternate, with its own tls, analysis and overall, and results[].overall is the worse of the two. Otherwise results[].keyTypeNote says why, for example that the server has only one certificate or only allows TLS 1.3. results[].keyType is the main leaf's key type.
curl -s https://this-server/api/check \
-H 'Content-Type: application/json' \
-d '{"host": "smtp.example.edu", "port": 587}'
Response
A successful check returns HTTP 200 with JSON like this (shortened):
{
"host": "www.example.edu",
"port": 443,
"checkedAt": "2026-11-08T20:16:00Z",
"resolution": { "source": "dns", "ips": ["192.0.2.10", "192.0.2.11"] },
"results": [
{
"ip": "192.0.2.10",
"tested": true,
"overall": "pass",
"tls": { "version": "TLS 1.3", "cipherSuite": "TLS_AES_128_GCM_SHA256", "sni": "www.example.edu", "handshakeMs": 42 },
"analysis": {
"overall": "pass",
"checks": [ { "id": "order", "title": "Certificates in correct order", "status": "pass", "detail": "..." } ],
"chain": [ { "index": 0, "subjectCN": "www.example.edu", "sha256": "...", "pem": "..." } ],
"storeRoots": [ ... ],
"paths": [ { "nodes": ["c0", "c1", "r0"], "terminal": "mozilla_root", "trusted": true, "best": true } ],
"mermaid": "flowchart TD ..."
}
}
],
"consistency": { "status": "pass", "detail": "..." },
"trustStore": { "count": 118, "loadedAt": "..." }
}
Fields you will use most
| Field | Meaning |
|---|---|
results[].overall | Worst status for that IP: pass, warn, fail or info. |
results[].error | Set when the connection, STARTTLS or TLS handshake failed. There is no analysis then. |
results[].denyReason | Set when this server's policy does not allow testing that IP, for example a private address. |
results[].analysis.checks[] | One entry per test, with id, status, detail and sometimes items (the certificates involved). |
results[].analysis.chain[] | The certificates in the order the server sent them, with subject, issuer, dates, fingerprints and PEM. |
results[].analysis.storeRoots[] | Roots from the trust store that complete a path but were not sent. |
results[].analysis.paths[] | Every path from the leaf to a root, listed by certificate id. terminal is mozilla_root (a root at least one store trusts), untrusted_root or incomplete. |
consistency | Whether all tested IPs sent the same chain. |
Check IDs
| ID | Test |
|---|---|
mozilla_root | Each required trust store (Apple, Chrome, Microsoft, Mozilla by default) trusts at least one path. Warns when only some do. The result per store is in analysis.trustStores, and each path lists the stores that trust it in stores. |
order | Each certificate is followed by its issuer. |
validity | Certificates on the path are within their dates. |
extra_certs | No certificates that aren't needed. |
pending_distrust | No CA on the path is scheduled to lose trust. |
revocation | No certificate on the best path is revoked, according to its issuer's OCSP responder or CRL. Each certificate's answers are in chain[].revocation. This server may turn the check off. |
ocsp_stapling | If the server staples an OCSP response, it is valid, current and says the leaf is good. Fails when the leaf requires stapling (Must-Staple) and there is no usable staple. Not stapling is fine otherwise. Details are in analysis.staple. |
hostname | The leaf certificate covers the hostname. |
root_sent | Note only: the server sends its root. |
For example, to list the result for each IP with jq:
curl -s https://this-server/api/check \
-H 'Content-Type: application/json' \
-d '{"host": "www.example.edu"}' \
| jq '.results[] | {ip, overall, error}'
Example scripts
Each script checks one host and prints a table with one row per IP address and certificate. A server with both an RSA and an ECDSA certificate gets a row for each. The table lists the checks that warned or failed, then the consistency check and an overall result: the worst IP result, or the consistency result if it warns or fails.
They use this server unless you set CACHECK_URL. The same scripts are in the examples folder of the source code. The exit code makes them usable in CI and monitoring: 0 pass, 1 warn, 2 fail, 3 the request failed. Run them as ./cacheck.sh HOST [PORT], for example:
$ ./cacheck.sh expired.badssl.com
IP KEY TLS RESULT PROBLEMS
104.154.89.105 rsa TLS 1.2 fail validity (fail), extra_certs (warn)
Consistency: info. Only one IP was tested.
Overall: fail
Bash and jq
Needs curl and jq. Save as cacheck.sh.
#!/usr/bin/env bash
# Usage: ./cacheck.sh HOST [PORT]
# Prints one row per IP and certificate. Exit code: 0 pass, 1 warn, 2 fail, 3 request failed.
set -euo pipefail
host=${1:?usage: cacheck.sh HOST [PORT]}
port=${2:-0}
api="${CACHECK_URL:-https://this-server}/api/check"
body=$(jq -n --arg host "$host" --argjson port "$port" '{host: $host, port: $port}')
out=$(curl -sS -w '\n%{http_code}' -H 'Content-Type: application/json' -d "$body" "$api") || exit 3
code=${out##*$'\n'}
resp=${out%$'\n'*}
if [[ $code != 200 ]]; then
echo "HTTP $code: $(jq -r '.error' <<<"$resp" 2>/dev/null || echo "$resp")" >&2
exit 3
fi
# The overall result is the worst IP result, or the consistency check if it warns or fails.
overall=$(jq -r '[.results[].overall, (.consistency.status // empty | select(. == "warn" or . == "fail"))]
| map({"pass": 0, "info": 1, "warn": 2, "fail": 3}[.] // 0) | max
| ["pass", "info", "warn", "fail"][.]' <<<"$resp")
jq -r --arg overall "$overall" '
def problems: [.checks[]? | select(.status == "warn" or .status == "fail") | "\(.id) (\(.status))"] | join(", ");
( [["IP", "KEY", "TLS", "RESULT", "PROBLEMS"]]
+ [ .results[] as $r
| ( [$r.ip, ($r.keyType // "-"), ($r.tls.version // "-"),
($r.analysis.overall // $r.overall), ($r.error // $r.denyReason // ($r.analysis | problems))] ),
( $r.alternate // empty
| [$r.ip, .keyType, .tls.version, .overall, (.analysis | problems)] ) ]
) as $rows
| [range(0; 4) as $i | $rows | map(.[$i] | length) | max] as $width
| ( $rows[] | [range(0; 4) as $i | .[$i] + " " * ($width[$i] - (.[$i] | length) + 2)] + [.[4]] | add ),
"",
( .consistency // empty | "Consistency: \(.status). \(.detail)" ),
"Overall: \($overall)"
' <<<"$resp"
case $overall in
fail) exit 2 ;;
warn) exit 1 ;;
esac
PowerShell
Tested with PowerShell 7 (pwsh) on Linux. Save as cacheck.ps1.
#!/usr/bin/env pwsh
# Usage: ./cacheck.ps1 HOST [PORT]
# Prints one row per IP and certificate. Exit code: 0 pass, 1 warn, 2 fail, 3 request failed.
param(
[Parameter(Mandatory = $true)][string]$HostName,
[int]$Port = 0,
[string]$Server = $(if ($env:CACHECK_URL) { $env:CACHECK_URL } else { 'https://this-server' })
)
$ErrorActionPreference = 'Stop'
$rank = @{ pass = 0; info = 1; warn = 2; fail = 3 }
function Get-Problems($analysis) {
@($analysis.checks | Where-Object { $_.status -in 'warn', 'fail' } |
ForEach-Object { "$($_.id) ($($_.status))" }) -join ', '
}
$body = @{ host = $HostName; port = $Port } | ConvertTo-Json
try {
$r = Invoke-RestMethod -Method Post -Uri "$Server/api/check" -ContentType 'application/json' -Body $body -TimeoutSec 180
} catch {
$msg = $_.Exception.Message
if ($_.ErrorDetails.Message) {
$msg = $_.ErrorDetails.Message
try { $msg = ($msg | ConvertFrom-Json).error } catch { }
}
[Console]::Error.WriteLine("Request failed: $msg")
exit 3
}
$rows = foreach ($ip in $r.results) {
if ($ip.analysis) {
$result = $ip.analysis.overall
$problems = Get-Problems $ip.analysis
} else {
$result = $ip.overall
$problems = "$($ip.error)$($ip.denyReason)"
}
[pscustomobject]@{ IP = $ip.ip; Key = $(if ($ip.keyType) { $ip.keyType } else { '-' }); TLS = $(if ($ip.tls) { $ip.tls.version } else { '-' }); Result = $result; Problems = $problems }
if ($ip.alternate) {
$alt = $ip.alternate
[pscustomobject]@{ IP = $ip.ip; Key = $alt.keyType; TLS = $alt.tls.version; Result = $alt.overall; Problems = Get-Problems $alt.analysis }
}
}
($rows | Format-Table -AutoSize -Wrap | Out-String -Width 200).Trim()
''
$overall = 'pass'
$statuses = @($r.results | ForEach-Object { $_.overall })
if ($r.consistency) {
if ($r.consistency.status -in 'warn', 'fail') { $statuses += $r.consistency.status }
"Consistency: $($r.consistency.status). $($r.consistency.detail)"
}
foreach ($s in $statuses) { if ($rank[$s] -gt $rank[$overall]) { $overall = $s } }
"Overall: $overall"
if ($overall -eq 'fail') { exit 2 }
if ($overall -eq 'warn') { exit 1 }
exit 0
Python
Needs Python 3.8 or later and nothing outside the standard library. Save as cacheck.py.
#!/usr/bin/env python3
"""Usage: ./cacheck.py HOST [PORT]
Prints one row per IP and certificate. Exit code: 0 pass, 1 warn, 2 fail, 3 request failed.
Needs Python 3.8 or later and nothing outside the standard library.
"""
import json
import os
import sys
import urllib.error
import urllib.request
RANK = {"pass": 0, "info": 1, "warn": 2, "fail": 3}
def problems(analysis):
return ", ".join(
f"{c['id']} ({c['status']})"
for c in (analysis or {}).get("checks", [])
if c["status"] in ("warn", "fail")
)
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
server = os.environ.get("CACHECK_URL", "https://this-server")
body = {"host": sys.argv[1], "port": int(sys.argv[2]) if len(sys.argv) > 2 else 0}
req = urllib.request.Request(
f"{server}/api/check",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json", "User-Agent": "cacheck-example"},
)
try:
with urllib.request.urlopen(req, timeout=180) as resp:
data = json.load(resp)
except urllib.error.HTTPError as e:
try:
msg = json.load(e).get("error", "")
except ValueError:
msg = e.reason
print(f"HTTP {e.code}: {msg}", file=sys.stderr)
sys.exit(3)
except urllib.error.URLError as e:
print(f"Request failed: {e.reason}", file=sys.stderr)
sys.exit(3)
rows = [("IP", "KEY", "TLS", "RESULT", "PROBLEMS")]
for r in data["results"]:
analysis = r.get("analysis")
rows.append((
r["ip"],
r.get("keyType") or "-",
(r.get("tls") or {}).get("version", "-"),
analysis["overall"] if analysis else r["overall"],
problems(analysis) if analysis else r.get("error") or r.get("denyReason") or "",
))
if alt := r.get("alternate"):
rows.append((r["ip"], alt["keyType"], alt["tls"]["version"], alt["overall"], problems(alt["analysis"])))
widths = [max(len(row[i]) for row in rows) for i in range(4)]
for row in rows:
print(" ".join(cell.ljust(w) for cell, w in zip(row, widths)) + " " + row[4])
statuses = [r["overall"] for r in data["results"]]
if consistency := data.get("consistency"):
if consistency["status"] in ("warn", "fail"):
statuses.append(consistency["status"])
print(f"\nConsistency: {consistency['status']}. {consistency['detail']}")
overall = max(statuses, key=lambda s: RANK.get(s, 0))
print(f"Overall: {overall}")
sys.exit({"warn": 1, "fail": 2}.get(overall, 0))
if __name__ == "__main__":
main()
Errors and limits
- 400: the request was not valid, for example a bad hostname or port, a hostname that does not resolve, or
connectIPswhen you may not use it. The body is{"error": "..."}. - 429: too many checks from your address. Wait for the number of seconds in the
Retry-Afterheader. A host with many IP addresses uses more of the limit than a host with one. - 502: the DNS lookup failed, for example because the resolver timed out.
- 503: this server has no trust store data yet, so it cannot tell whether a chain is trusted. It retries the download automatically. Try again after the number of seconds in
Retry-After.
A failed connection to one IP is not an HTTP error. It shows up as error on that IP's result. An address this server may not test, such as a private address, shows up with tested: false and denyReason.
Other endpoints
GET /api/configreturnsipOverrideAllowed(whether you may sendconnectIPs) and the trust store's size and age.GET /healthzreturnsokwhile the service is running.
Links to the web page
A link can fill in the form for someone else. Opening it does not run the check; they still press Check chain.
https://this-server/?host=www.example.edu&port=443
https://this-server/?host=smtp.example.edu&port=2525&starttls=smtp
starttls takes the same values as in the API and has no control on the page.