API reference
All requests go to https://hullwise.co/api/v1. Responses are JSON, and errors use the format described under Errors. The check is also available as an MCP tool and works the same way there. The machine-readable version is at openapi.json.
https://hullwise.co/api/v1Scan
Check a public website or app from the outside.
Check a website
/api/v1/scanThe check requests the page once via GET, follows up to five redirects and reads at most 800 KB of HTML. It then loads the JavaScript files the page includes itself through <script src> or modulepreload: same domain only, at most 10 files, 2 MB per file, 6 MB and 6 seconds in total. In those files it looks for patterns of secret keys. On top of that come a TLS handshake and DNS lookups for SPF and DMARC. For a subdomain, it looks up the records all the way up to the main domain. With GET, the address goes in the query string; with POST, in the JSON body. Both return the same response.
We reject internal and private addresses, even when a redirect points there. We do not probe paths the page does not include itself. We never use keys we find. We return their value only shortened, for example sk_live_…9f2A, and never return or store the full value. The checked site has 8 seconds to respond. There is no cache; every request runs a fresh check.
| Parameter | Description |
|---|---|
url Requiredquery (GET) · body (POST) · string | Domain or full address, for example example.com or https://example.com/shop. Without http:// or https://, we assume https://. Other schemes return bad_scheme. |
lang optionalquery · body (POST) · en | de | Language of label, why, disclaimer and error messages. See Language. |
curl "https://hullwise.co/api/v1/scan?url=example.com&lang=en" \
-H "Authorization: Bearer $HULLWISE_API_KEY"curl -X POST "https://hullwise.co/api/v1/scan" \
-H "Authorization: Bearer $HULLWISE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"example.com","lang":"en"}'{
"data": {
"url": "kunden.example.com",
"scannedAt": "2026-10-25T09:14:03.512Z",
"https": true,
"score": 5,
"grade": "F",
"tech": [],
"platforms": ["Lovable", "Vite", "Supabase"],
"scripts": 3,
"tls": {
"issuer": "Let's Encrypt",
"validTo": "2026-12-30T08:11:52.000Z",
"daysLeft": 66,
"protocol": "TLSv1.3",
"valid": true
},
"email": { "domain": "example.com", "spf": true, "dmarc": null },
"findings": {
"https": [],
"tls": [],
"exposure": [
{
"id": "code.secret.openai",
"sev": "warn",
"critical": true,
"label": "OpenAI API key in the app's JavaScript files (1×)",
"why": "Any visitor can read this credential from index-B7xQ2.js and use it at your expense or with your permissions. … We never show the full value, only its start and end so you can match it.",
"found": [{ "where": "index-B7xQ2.js", "value": "sk-proj-…x9Qa" }]
}
],
"versions": [],
"headers": [
{
"id": "header.csp.missing",
"sev": "warn",
"label": "No protection against injected scripts (CSP)",
"why": "If foreign code gets into your page, for example through an input field, the browser runs it. …"
},
{ "id": "header.frame.missing", "sev": "info", "label": "Other sites may embed your site (X-Frame-Options)", "why": "…" },
{ "id": "header.permissions.missing", "sev": "info", "label": "Camera, microphone and location not blocked (Permissions-Policy)", "why": "…" }
],
"email": [
{ "id": "email.dmarc.missing", "sev": "warn", "label": "No policy for spoofed email in your name (DMARC)", "why": "…" }
],
"cookies": []
},
"disclaimer": "External scan without proof of ownership. …"
}
}Language
The texts in label, why and disclaimer, and error messages, come in German or English. Set lang=en in the query string or "lang": "en" in the POST body. Without the parameter, an Accept-Language header starting with en selects English; otherwise the response is in German. Responses to a valid key carry a Content-Language header with the language you got. IDs, field names and error codes are the same in both languages, so branch on id, not on the text.
Response fields
The report is always in the data field. The finding lists are often empty. The headers X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset come with every response to a valid key, see Authentication.
| Field | Type | Meaning |
|---|---|---|
url | string | The hostname that was checked after all redirects. Only the name, without scheme or path. |
scannedAt | string | Time of the check, ISO 8601 in UTC. |
https | boolean | Whether the site was ultimately served over HTTPS. |
score | number | Points from 0 to 100. Security headers that are present add points, findings deduct them, and an exposed secret key deducts 40 points per type. The full rule is under Method (/en/docs/method). |
grade | string | Grade A to F. A from 90, B from 75, C from 60, D from 40, E from 20, otherwise F. Always F without HTTPS. |
tech[] | object[] | Detected technology: name, version (or null) and source, that is where it was detected. In the shortened report, version is always null. |
platforms | string[] | Detected platforms and hosts, such as Lovable, Bolt, v0, Replit, Base44, Vercel, Netlify, Next.js, Vite, Supabase or Firebase. Empty if nothing is clearly identifiable. |
scripts | number | How many of the page's JavaScript files were loaded and searched, at most 10. |
tls | object | null | Certificate: issuer, validTo, daysLeft, protocol and valid. null if the site runs without HTTPS. |
email | object | Email protection of the domain: domain, spf (true or false) and dmarc (the policy such as reject, quarantine, none, or null). |
findings | object | Findings, sorted into the fields https, tls, headers, versions, email, exposure, cookies, privacy (data protection) and dns. Each field is a list, often empty. |
findings.*[].id | string | Stable ID of the check. All IDs are listed under Checks. Missing on locked findings. |
findings.*[].sev | string | warn or info. |
findings.*[].label | string | Short title for people, in the language of the response. Empty on locked findings. |
findings.*[].why | string | One or two sentences on what the finding means, in the language of the response. |
findings.*[].refs | string[] | Versions only: CVE or OSV identifiers. |
findings.*[].source | string | Versions only: source of the match. |
findings.*[].critical | boolean | Only on exposed secret keys in the JavaScript files: true. |
findings.*[].locked | boolean | Shortened report only: true on locked findings. |
locked | object | Shortened report only: hidden (number of locked findings) and total (number of all findings). |
disclaimer | string | What the check read and what a finding means, in the language of the response. Include it when you show the result to people. |
Shortened reports without Business
Without a paid plan, the server shortens the report before sending it. This applies to the free key and to the test key sc_test_demo, and on the MCP server also without a key. The grade, score, certificate, email protection, platforms and the number of findings stay complete.
- The first 2 findings come in full. Exposed keys (
critical: true) come first, then warnings, then notices, and DNS hygiene (CAA, DNSSEC, security.txt, MTA-STS) last. Within the same severity, the order isexposure,versions,https,tls,headers,privacy,email,cookies,dns. - All other findings keep their position but contain only
sev, an emptylabelandlocked: true.id,why,refsandsourceare missing. - In
tech[], every entry hasversion: null. - The
lockedfield states the locked findings inhiddenand all findings intotal.
With a key from an account on Business, the locked field is absent and every finding is complete. What is not sent cannot be uncovered in the client either.
{
"data": {
"url": "kunden.example.com",
"score": 5,
"grade": "F",
"platforms": ["Lovable", "Vite", "Supabase"],
"scripts": 3,
"findings": {
"exposure": [
{ "id": "code.secret.openai", "sev": "warn", "critical": true, "label": "OpenAI API key …", "why": "…" }
],
"versions": [],
"https": [],
"tls": [],
"headers": [
{ "id": "header.csp.missing", "sev": "warn", "label": "No protection against injected scripts (CSP)", "why": "…" },
{ "sev": "info", "label": "", "locked": true },
{ "sev": "info", "label": "", "locked": true }
],
"email": [
{ "sev": "warn", "label": "", "locked": true }
],
"cookies": []
},
"locked": { "hidden": 3, "total": 5 }
}
}Possible errors
A missing or invalid key returns 401 unauthorized, exceeding the limit returns 429 rate_limited. On top of that come input and fetch errors such as blocked_host, timeout or tls_invalid. The full list is under Errors.
Homepage
The endpoint used by the form on the homepage.
Check a website without a key
/api/scanThis endpoint is meant for the browser. It needs no key and counts per IP address (20 per hour) or, when you are signed in, per account. The url parameter is the same as above. Only signed-in accounts on Business get the full report; otherwise it is shortened as described above. With lang=en, finding texts and error messages are in English; without it, in German.
The response is the report itself, without the data wrapper. Errors come as two plain fields, for example { "error": "rate_limited", "message": "…" }. This endpoint sends no rate limit headers. For scripts, CI and agents, please use /api/v1/scan or the MCP server.
curl "https://hullwise.co/api/scan?url=example.com&lang=en"