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.

Base URL
https://hullwise.co/api/v1

Scan

Check a public website or app from the outside.

Check a website

GETPOST/api/v1/scan

The 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.

MCP tool scan_websiteKey requiredRead-onlyIdempotentReaches outside
ParameterDescription
url Required
query (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 optional
query · body (POST) · en | de
Language of label, why, disclaimer and error messages. See Language.
Request · GET
curl "https://hullwise.co/api/v1/scan?url=example.com&lang=en" \
  -H "Authorization: Bearer $HULLWISE_API_KEY"
Request · POST
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"}'
200 OK · example response with Business, texts shortened
{
  "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.

FieldTypeMeaning
urlstringThe hostname that was checked after all redirects. Only the name, without scheme or path.
scannedAtstringTime of the check, ISO 8601 in UTC.
httpsbooleanWhether the site was ultimately served over HTTPS.
scorenumberPoints 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).
gradestringGrade 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.
platformsstring[]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.
scriptsnumberHow many of the page's JavaScript files were loaded and searched, at most 10.
tlsobject | nullCertificate: issuer, validTo, daysLeft, protocol and valid. null if the site runs without HTTPS.
emailobjectEmail protection of the domain: domain, spf (true or false) and dmarc (the policy such as reject, quarantine, none, or null).
findingsobjectFindings, sorted into the fields https, tls, headers, versions, email, exposure, cookies, privacy (data protection) and dns. Each field is a list, often empty.
findings.*[].idstringStable ID of the check. All IDs are listed under Checks. Missing on locked findings.
findings.*[].sevstringwarn or info.
findings.*[].labelstringShort title for people, in the language of the response. Empty on locked findings.
findings.*[].whystringOne or two sentences on what the finding means, in the language of the response.
findings.*[].refsstring[]Versions only: CVE or OSV identifiers.
findings.*[].sourcestringVersions only: source of the match.
findings.*[].criticalbooleanOnly on exposed secret keys in the JavaScript files: true.
findings.*[].lockedbooleanShortened report only: true on locked findings.
lockedobjectShortened report only: hidden (number of locked findings) and total (number of all findings).
disclaimerstringWhat 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 is exposure, versions, https, tls, headers, privacy, email, cookies, dns.
  • All other findings keep their position but contain only sev, an empty label and locked: true. id, why, refs and source are missing.
  • In tech[], every entry has version: null.
  • The locked field states the locked findings in hidden and all findings in total.

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.

200 OK · shortened report, excerpt
{
  "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

GET/api/scan

This 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.

No keyRead-onlyReaches outside
Request
curl "https://hullwise.co/api/scan?url=example.com&lang=en"

Is your app
watertight?

Hullwise finds the leaks before someone else does: exposed keys, database credentials and outdated software. Every finding comes with steps to fix it.

https://

Click a finding to mark it as fixed.

See pricingHow a scan works

Ship fast. Stay hullwise.