API Documentation

Automate Voretix from your own tooling: schedule scans, read full analysis reports, search public scans, hunt across the dataset, and comment on or report any scan — authenticated with your personal API key.

Base URL https://api.voretix.com
Introduction
What the public API can do and how it answers

The Voretix public API gives your scripts and integrations the same capabilities as the website. Every endpoint lives under https://api.voretix.com, speaks JSON, and (except /health) authenticates with your personal API key.

Scan
Schedule a URL scan with the same options as the website form, then poll until it finishes.
Report
Read the full stored analysis: verdicts, WHOIS, TLS, technologies, file hashes, screenshot.
Search
Paginated search over completed scans — the same filters and scoring as the website search.
Hunt
Pivot across all public scans by content, infrastructure, detections, hashes or structure.

Every response carries an ok boolean. Successes add their payload next to it; failures use one shared envelope described in Errors. Authenticated responses carry X-RateLimit-* headers (with X-RateLimit-Scope naming the budget they describe) so you always know where your key stands — see Rate limits & quota.

Privacy by design. Submitter metadata (the account and IP that scheduled a scan) is never returned by any endpoint and can never be searched or hunted. Only the scanned site's own evidence is exposed.
Authentication
Per-account API keys, sent on every request

Generate your key from the profile page (API Access card). Keys look like vx_ followed by 48 hex characters, and can be sent in either header — both are equivalent:

shell
# either header works — pick whichever fits your HTTP client
curl -s "https://api.voretix.com/api/v1/quota" -H "X-API-Key: vx_YOUR_KEY"
curl -s "https://api.voretix.com/api/v1/quota" -H "Authorization: Bearer vx_YOUR_KEY"
  • One key per account. Regenerating mints a new key and the old one stops working immediately; revoking disables API access until you generate a new one.
  • Shown once. The plaintext key appears only in the moment you generate it — Voretix stores a SHA-256 hash, so a lost key can only be replaced, never recovered.
  • Requests without a valid key answer 401 missing_api_key or 403 invalid_api_key.
Keep it server-side. The key identifies your account and spends your quota. Never ship it in client-side code, mobile apps or public repositories — proxy through your own backend instead.
Quick start
Schedule a scan, poll it, read the report
Generate a key

Sign in and create your API key on the profile page. Copy it right away — it is shown only once.

Schedule a scan

POST /api/v1/scans with the target URL. Only url is required; everything else takes the same defaults as the website form.

Poll, then fetch the report once

Poll the lightweight /status endpoint until finished is true, then read the full document a single time. A scan usually takes a minute or two.

# 1) schedule a scan
SCAN_ID=$(curl -s -X POST "https://api.voretix.com/api/v1/scans" \
  -H "X-API-Key: vx_YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://suspicious-site.example"}' | jq -r .scan_id)

# 2) poll until "finished": true (repeat every ~15s)
curl -s "https://api.voretix.com/api/v1/scans/$SCAN_ID/status" \
  -H "X-API-Key: vx_YOUR_KEY"

# 3) fetch the full report once
curl -s "https://api.voretix.com/api/v1/scans/$SCAN_ID" \
  -H "X-API-Key: vx_YOUR_KEY"
import time
import requests

API = "https://api.voretix.com"
H = {"X-API-Key": "vx_YOUR_KEY"}

# 1) schedule a scan
scan = requests.post(f"{API}/api/v1/scans", headers=H,
                     json={"url": "https://suspicious-site.example"}).json()
scan_id = scan["scan_id"]

# 2) poll until finished (a scan usually takes a minute or two)
while True:
    status = requests.get(f"{API}/api/v1/scans/{scan_id}/status", headers=H).json()
    if status["finished"]:
        break
    time.sleep(15)

# 3) fetch the full report once
report = requests.get(f"{API}/api/v1/scans/{scan_id}", headers=H).json()
print(report["ai"]["verdict"], report["result"]["screenshot_url"])
const API = "https://api.voretix.com";
const H = { "X-API-Key": "vx_YOUR_KEY", "Content-Type": "application/json" };

// 1) schedule a scan
const scan = await (await fetch(`${API}/api/v1/scans`, {
  method: "POST", headers: H,
  body: JSON.stringify({ url: "https://suspicious-site.example" }),
})).json();

// 2) poll until finished (a scan usually takes a minute or two)
let status;
do {
  await new Promise((r) => setTimeout(r, 15000));
  status = await (await fetch(`${API}/api/v1/scans/${scan.scan_id}/status`, { headers: H })).json();
} while (!status.finished);

// 3) fetch the full report once
const report = await (await fetch(`${API}/api/v1/scans/${scan.scan_id}`, { headers: H })).json();
console.log(report.ai.verdict, report.result.screenshot_url);
Rate limits & quota
Per-endpoint budgets: one account hunt allowance across the website and API

Every API endpoint draws from exactly one budget, and only scan scheduling spends your plan's monthly quota. Reading reports, searching, hunting and commenting use independent daily budgets, so no amount of reading eats into the scans you paid for. The hunt allowance belongs to the account, not to one API key: signed-in hunts on the website and API spend the same counter. Anonymous website hunts receive the Free allowance per canonical client IP.

The website's Turnstile clearance is an abuse gate, not a quota credential. Its short-lived cookie contains only an opaque random identifier; the server retains its digest and binds the live clearance to the current account (when signed in) and canonical client IP. A copied, expired or unknown cookie is rejected, and even valid clearance never bypasses the daily hunt counter.

500 scans / mo
Monthly scan quota, per key
Spent only by POST /scans. Free-plan allowance — paid plans raise it. Resets on the 1st, 00:00 UTC.
50–2000 req / day
Hunt budget, per account
Website and API hunts share one allowance: Free 50 · Plus 100 · Pro 500 · Max 2000 per day. Anonymous website hunts use the Free allowance per IP. Resets at 00:00 UTC; API hunts also have a 30/min burst cap.
1000 req / day
Search budget, per key
/search, full reports (/scans/{id}) and /related share this daily pool.
1000 req / day
Comments budget, per key
Reading and writing comments, plus problem reports, share this daily pool.
1000 req / min
Status tier — no quota at all
Status polls, catalogs, stats, /quota and /health never spend any stored budget — poll freely.
120 req / min
Burst guard, per IP
Anti-hammering gate in front of everything except the status tier (which gets the 1000/min allowance instead).

Which endpoints spend which budget

BudgetEndpointsResets
Monthly scans (plan)POST /scans1st of the month, 00:00 UTC
Hunt — one account allowance by plan: 50 / 100 / 500 / 2000 per day; anonymous website use is Free per IPGET /api/v1/hunt · website hunt.phpDaily, 00:00 UTC
Search — 1000/dayGET /search · GET /scans/{id} · GET /scans/{id}/relatedDaily, 00:00 UTC
Comments — 1000/dayGET/POST /scans/{id}/comments · GET /comments · POST /scans/{id}/reportDaily, 00:00 UTC
Status tier — burst only, 1000/minGET /scans/{id}/status · GET /scans/options · GET /hunt/filters · GET /me/stats · GET /quota · PUT /scans/{id}/visibility · GET /healthSliding 60 s window

API budgets are spent per request whatever the response status, but requests that never reach an endpoint — unknown paths, missing/invalid keys, burst rejections — cost nothing. A website hunt spends only when a complete query has valid bot clearance; opening the builder or submitting the Turnstile gate does not spend quota. GET /api/v1/quota is always free and reports every budget at once, so an exhausted key can still find out when it resets. Exhausting a budget answers 429 with a code naming what ran out (rate_limited = monthly scans, daily_rate_limited = a daily budget, burst_rate_limited = a per-minute cap) and a Retry-After counting down to its reset.

Response headers

Every authenticated API response describes the budget it drew from — X-RateLimit-Scope names it, so your client never has to guess what the numbers mean. For hunt-daily, the remaining count already includes signed-in website hunts by the same account:

HeaderMeaning
X-RateLimit-ScopeWhich budget the numbers describe: hunt-daily, search-daily, comments-daily or scans-monthly.
X-RateLimit-LimitThat budget's total allowance.
X-RateLimit-RemainingRequests left in that budget.
X-RateLimit-ResetUnix timestamp of that budget's next reset.
Retry-AfterOn any 429: seconds to wait before retrying.
http
HTTP/1.1 200 OK
Content-Type: application/json
X-RateLimit-Scope: search-daily
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 947
X-RateLimit-Reset: 1783036800

The quota object embedded in scan-flavored responses always describes the monthly scan quota; the per-section budgets live in the headers above and in GET /api/v1/quota.

Errors
One envelope for every failure

All errors — validation, auth, quota, server — use the same JSON envelope with an appropriate HTTP status. error is a stable machine-readable code (branch on it); message is a human-readable explanation that may change over time (log it, don't parse it).

{
  "ok": false,
  "error": "invalid_url",
  "message": "Only http and https URLs can be scanned."
}

The complete list of codes lives in the error code reference.

Scan visibility
Who can see a scan and where it shows up

Every scan has one of three visibility levels, chosen when you schedule it and changeable later by its owner via PUT /scans/{id}/visibility:

public
Viewable by anyone, listed in search, and eligible to appear in other scans' hunt and related-scan results.
hidden
Viewable by anyone with the link (the scan id), but never listed in search, hunts or related-scan matches.
private
Viewable only by the account that scheduled it. To everyone else it does not exist.
No enumeration. A private scan you don't own answers 404 scan_not_found — exactly like a scan that never existed — so scan ids cannot be probed through the API.
GET/health
No authFree
Liveness check

Answers without authentication and without touching the database — use it for uptime monitoring. Every other endpoint requires an API key.

Request

curl -s "https://api.voretix.com/health"
import requests

r = requests.get(
    "https://api.voretix.com/health",
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/health", {
});
console.log(await res.json());

Response

{
  "ok": true,
  "service": "voretix-public-api",
  "time": "2026-07-07T14:52:07.318294+00:00"
}
GET/api/v1/quota
API keyFree — never spends quota
Key & account quota status

Everything about the presented key: its display prefix, the monthly scan quota, today's standing in every daily section budget and lifetime counters. Hunt usage is account-wide, so its used_today includes both API calls and signed-in website hunts; rotating the key does not reset it. This endpoint authenticates but never spends any budget — an exhausted key can still find out when it resets. Poll it as often as you like.

Request

curl -s "https://api.voretix.com/api/v1/quota" \
  -H "X-API-Key: vx_YOUR_KEY"
import requests

r = requests.get(
    "https://api.voretix.com/api/v1/quota",
    headers={"X-API-Key": "vx_YOUR_KEY"},
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/quota", {
  headers: { "X-API-Key": "vx_YOUR_KEY" },
});
console.log(await res.json());

Response

{
  "ok": true,
  "key_prefix": "vx_1a2b3c4d",
  "monthly_limit": 500,
  "used_this_month": 17,
  "remaining": 483,
  "resets_at": "2026-08-01T00:00:00+00:00",
  "note": "The monthly quota is spent only by POST /api/v1/scans; other endpoints spend their section's daily budget below. Hunt is shared with signed-in website use.",
  "sections": {
    "hunt":     { "daily_limit": 50,   "used_today": 4,  "remaining": 46,  "resets_at": "2026-07-08T00:00:00+00:00" },
    "search":   { "daily_limit": 1000, "used_today": 53, "remaining": 947, "resets_at": "2026-07-08T00:00:00+00:00" },
    "comments": { "daily_limit": 1000, "used_today": 2,  "remaining": 998, "resets_at": "2026-07-08T00:00:00+00:00" }
  },
  "total_requests": 1204,
  "created_at": "2026-07-06T09:14:02",
  "last_used_at": "2026-07-07T14:49:51"
}
missing_api_keyinvalid_api_keyburst_rate_limited
GET/api/v1/me/stats
API keyStatus tier — free
Your scan statistics

Verdict breakdown of your own completed scans — the same numbers the website's history and profile pages show. Always scoped to the key's account server-side; other users' statistics are not reachable. Anything not flagged malicious or suspicious counts as clean.

Request

curl -s "https://api.voretix.com/api/v1/me/stats" \
  -H "X-API-Key: vx_YOUR_KEY"
import requests

r = requests.get(
    "https://api.voretix.com/api/v1/me/stats",
    headers={"X-API-Key": "vx_YOUR_KEY"},
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/me/stats", {
  headers: { "X-API-Key": "vx_YOUR_KEY" },
});
console.log(await res.json());

Response

{
  "ok": true,
  "username": "analyst01",
  "scans": {
    "total": 342,
    "malicious": 51,
    "suspicious": 38,
    "clean": 253
  },
  "quota": {
    "monthly_limit": 500,
    "used_this_month": 18,
    "remaining": 482,
    "resets_at": "2026-08-01T00:00:00+00:00"
  }
}
POST/api/v1/scans
API key1 monthly scan
Schedule a scan

Queues a scan of the given URL and returns its scan_id immediately with status pending. The scan is owned by your account: it appears in your history and you can manage its visibility later. Send a JSON object — url is the only required field; omitted fields take the defaults below (the same ones the website form uses). Unknown fields are rejected with unknown_field.

Body fields

url
stringrequired
The page to scan, up to 2000 characters. If the scheme is missing, https:// is assumed. See the validation rules below.
visibility
stringoptional
Who can see the result — see Scan visibility.publicprivatehidden
location
stringoptional
Scanner region the scan runs from.randomataubecadefrhkitjpplrosesgukus-dallasus-los-angelesus-arlingtonus-cheyenne
os
stringoptional
Operating system the scan emulates.windowslinuxandroidios
browser
stringoptional
Browser profile used for the visit.chromefirefoxedgesafaribraveoperator
language
stringoptional
Browser Accept-Language for the visit.en-USen-GBes-ESfr-FRde-DEit-ITpt-BRja-JPzh-CNru-RU
tor
booleanoptional
Route the scan through the Tor network. Independent of picking the tor browser profile. Default: false

URL validation

  • Only http and https URLs; strict RFC 3986 character set (no whitespace, quotes or angle brackets), valid percent-encoding.
  • No credentials embedded in the URL (user:pass@host is rejected).
  • The host must resolve exclusively to public-unicast A/AAAA addresses. Legacy numeric IP forms are canonicalized; localhost, private, metadata, link-local, reserved, documentation and multicast ranges are rejected.

Request

curl -s -X POST "https://api.voretix.com/api/v1/scans" \
  -H "X-API-Key: vx_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://suspicious-site.example/login",
    "visibility": "public",
    "browser": "chrome",
    "tor": false
}'
import requests

r = requests.post(
    "https://api.voretix.com/api/v1/scans",
    headers={"X-API-Key": "vx_YOUR_KEY"},
    json={
        "url": "https://suspicious-site.example/login",
        "visibility": "public",
        "browser": "chrome",
        "tor": False
    },
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/scans", {
  method: "POST",
  headers: { "X-API-Key": "vx_YOUR_KEY", "Content-Type": "application/json" },
  body: JSON.stringify({"url":"https://suspicious-site.example/login","visibility":"public","browser":"chrome","tor":false}),
});
console.log(await res.json());

Response

{
  "ok": true,
  "scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
  "url": "https://suspicious-site.example/login",
  "status": "pending",
  "config": {
    "visibility": "public",
    "location": "random",
    "os": "windows",
    "browser": "chrome",
    "language": "en-US",
    "tor": false
  },
  "result_endpoint": "/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
  "quota": {
    "monthly_limit": 500,
    "used_this_month": 18,
    "remaining": 482,
    "resets_at": "2026-08-01T00:00:00+00:00"
  }
}
invalid_bodyunknown_fieldinvalid_optioninvalid_urlpayload_too_large
GET/api/v1/scans/options
API keyStatus tier — free
Scan configuration catalog

Machine-readable catalog of every value POST /api/v1/scans accepts, with its default — ideal for building option dropdowns without hardcoding the lists above. Includes your live quota.

Request

curl -s "https://api.voretix.com/api/v1/scans/options" \
  -H "X-API-Key: vx_YOUR_KEY"
import requests

r = requests.get(
    "https://api.voretix.com/api/v1/scans/options",
    headers={"X-API-Key": "vx_YOUR_KEY"},
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/scans/options", {
  headers: { "X-API-Key": "vx_YOUR_KEY" },
});
console.log(await res.json());

Response

GET/api/v1/scans/{scan_id}
API keySearch budget — 1000/day
Full scan report

Everything stored for one scan, as a single nested document. While the scan is still pending or underway the analysis sections are null/empty and stored is false — use the status endpoint to poll cheaply instead of re-fetching this document.

Path parameters

scan_id
string
The id returned when the scan was scheduled (1–50 chars of A–Z a–z 0–9 . _ -).

Document sections

KeyContents
scanThe scheduled request: target URL, timestamp, configuration, numeric completed/public plus their readable status and visibility labels.
resultAnalysis frame: final URL after redirects, resolved IP, start/end timestamps, screenshot & HTML SHA-256 hashes, and screenshot_url — the captured screenshot on the image CDN (null until one is stored).
domain_infoWHOIS & DNS: registrar (+ abuse contacts), owner organization/country, hashed owner email, domain age, DNSSEC, and the nameservers array.
technologiessummary (counts and scanned sources) and detected — each technology with name, version, confidence, categories and evidence.
content_summaryAI-generated summary of the page text, with input/output size counters.
heuristicsYARA-style engine header (rules loaded, matched count, max severity) and matches — each rule with severity, category, description, tags, files and matched strings.
aiAI verdict: verdict, confidence (0–100), targeting_brand and the reasoning.
fingerprint128-bit structural SimHash, split into simhash_high/simhash_low — the value the similarity hunts use.
page_stateThe rendered page: title, body text sample, viewport, scroll metrics, image counts and the anti-bot challenge flag.
main_documentThe main HTTP response: status, MIME, server, timing, response headers, and the full TLS certificate details (issuer, subject, validity, SANs).
redirectsEvery redirect hop with from/to URL, status, peer IP and headers, in order.
assetsEvery fetched subresource with URL, type, MIME, kind, status and SHA-256/SHA-1/MD5 hashes.
linksNavigable links found on the page, classified internal/external, with anchor text.
imagesLoaded <img> URLs with alt text and natural dimensions.

Request

curl -s "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70" \
  -H "X-API-Key: vx_YOUR_KEY"
import requests

r = requests.get(
    "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
    headers={"X-API-Key": "vx_YOUR_KEY"},
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70", {
  headers: { "X-API-Key": "vx_YOUR_KEY" },
});
console.log(await res.json());

Response (abbreviated: arrays trimmed to one entry)

Reading hashes: binary hashes are returned as lowercase hex; timestamps are ISO-8601 (UTC). is_owner tells you whether the presented key's account scheduled this scan.
invalid_scan_idscan_not_found
GET/api/v1/scans/{scan_id}/status
API keyStatus tier — free
Poll a scan's progress

Lightweight one-row poll: the scan's current state and a finished flag. Poll this until finished is true, then fetch the full report once. status walks pending → underway → completed (or failed); finished covers both terminal states.

Request

curl -s "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/status" \
  -H "X-API-Key: vx_YOUR_KEY"
import requests

r = requests.get(
    "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/status",
    headers={"X-API-Key": "vx_YOUR_KEY"},
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/status", {
  headers: { "X-API-Key": "vx_YOUR_KEY" },
});
console.log(await res.json());

Response

{
  "ok": true,
  "scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
  "url": "https://suspicious-site.example/login",
  "status": "underway",
  "finished": false,
  "visibility": "public",
  "timestamp": "2026-07-07T14:52:09",
  "result_endpoint": "/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
  "quota": {
    "monthly_limit": 500,
    "used_this_month": 18,
    "remaining": 482,
    "resets_at": "2026-08-01T00:00:00+00:00"
  }
}
invalid_scan_idscan_not_found
PUT/api/v1/scans/{scan_id}/visibility
API keyStatus tier — freeOwner only
Change a scan's visibility

Switch a scan between public, hidden and private — see Scan visibility for what each means. Only the account that scheduled the scan can change it; scans submitted anonymously on the website have no owner and can never be reclaimed through the API.

Body fields

visibility
stringrequired
The new visibility level.publicprivatehidden

Request

curl -s -X PUT "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/visibility" \
  -H "X-API-Key: vx_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "visibility": "hidden"
}'
import requests

r = requests.put(
    "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/visibility",
    headers={"X-API-Key": "vx_YOUR_KEY"},
    json={
        "visibility": "hidden"
    },
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/visibility", {
  method: "PUT",
  headers: { "X-API-Key": "vx_YOUR_KEY", "Content-Type": "application/json" },
  body: JSON.stringify({"visibility":"hidden"}),
});
console.log(await res.json());

Response

{
  "ok": true,
  "scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
  "visibility": "hidden",
  "quota": {
    "monthly_limit": 500,
    "used_this_month": 18,
    "remaining": 482,
    "resets_at": "2026-08-01T00:00:00+00:00"
  }
}
invalid_scan_idinvalid_bodyinvalid_optionscan_not_foundnot_scan_owner
GET/api/v1/scans/{scan_id}/comments
API keyComments budget — 1000/day
List a scan's comments

The comments left directly on this scan, oldest first, up to 200 rows. Readable by whoever may view the scan itself: private scans you don't own answer 404 scan_not_found exactly like missing ones. (The website's report page shows a wider thread — this scan's comments plus comments on other public scans of the same URL; comments by URL is the equivalent cross-scan read here.)

Comments are plain text only (see the character rules on the posting endpoint), and every field is re-cleaned on the way out. source tells you which door a comment came through: web (the website's comment box) or api (this API).

Path parameters

scan_id
string
The scan whose thread to read (1–50 chars of A–Z a–z 0–9 . _ -).

Request

curl -s "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/comments" \
  -H "X-API-Key: vx_YOUR_KEY"
import requests

r = requests.get(
    "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/comments",
    headers={"X-API-Key": "vx_YOUR_KEY"},
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/comments", {
  headers: { "X-API-Key": "vx_YOUR_KEY" },
});
console.log(await res.json());

Response

{
  "ok": true,
  "scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
  "url": "https://suspicious-site.example/login",
  "count": 2,
  "comments": [
    {
      "username": "analyst01",
      "source": "web",
      "comment": "Same kit as the campaign from last week.",
      "created_at": "2026-07-07T09:14:22"
    },
    {
      "username": "hunter7",
      "source": "api",
      "comment": "Reported to the registrar, hosting abuse notified.",
      "created_at": "2026-07-07T11:02:41"
    }
  ],
  "quota": {
    "monthly_limit": 500,
    "used_this_month": 18,
    "remaining": 482,
    "resets_at": "2026-08-01T00:00:00+00:00"
  }
}
invalid_scan_idscan_not_found
POST/api/v1/scans/{scan_id}/comments
API keyComments budget — 1000/dayAnti-spam limits
Comment on a scan

Adds a comment to a scan's thread, authored by your account and stored with source: "api". The body must carry both the scan's URL and the comment text: the URL is checked against the scan's stored URL (after the same normalization scan submission applies), so a client that mixed up scan ids gets 409 url_mismatch instead of posting onto the wrong report.

Body fields

url
stringrequired
The scanned URL of this scan — the scan.target_url of the report (or the url the scan was scheduled with). Must match, or the request answers 409 url_mismatch.
comment
stringrequired
The comment, up to 1000 characters after cleaning. Plain text only: ASCII letters, digits, whitespace and basic punctuation. Everything else — emojis, accented letters, <>, {}, [], backticks — is stripped server-side, and a comment that is empty after cleaning is rejected.

Anti-spam limits

  • At most 10 comments per scan per account (429 comment_limit_reached).
  • At least 15 seconds between your comments, across all scans (429 comment_cooldown).

Request

curl -s -X POST "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/comments" \
  -H "X-API-Key: vx_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://suspicious-site.example/login",
    "comment": "Same phishing kit as the campaign from last week - reported to the registrar."
}'
import requests

r = requests.post(
    "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/comments",
    headers={"X-API-Key": "vx_YOUR_KEY"},
    json={
        "url": "https://suspicious-site.example/login",
        "comment": "Same phishing kit as the campaign from last week - reported to the registrar."
    },
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/comments", {
  method: "POST",
  headers: { "X-API-Key": "vx_YOUR_KEY", "Content-Type": "application/json" },
  body: JSON.stringify({"url":"https://suspicious-site.example/login","comment":"Same phishing kit as the campaign from last week - reported to the registrar."}),
});
console.log(await res.json());

Response

{
  "ok": true,
  "scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
  "comment": {
    "username": "analyst01",
    "source": "api",
    "comment": "Same phishing kit as the campaign from last week - reported to the registrar.",
    "created_at": "2026-07-07T15:04:12"
  },
  "quota": {
    "monthly_limit": 500,
    "used_this_month": 18,
    "remaining": 482,
    "resets_at": "2026-08-01T00:00:00+00:00"
  }
}
Comments are public. Your username is displayed next to the comment wherever the scan is visible — that is the point of a comment section. The response echoes the stored comment, so you can render it without a second request.
invalid_scan_idinvalid_bodyunknown_fieldinvalid_urlinvalid_commentscan_not_foundurl_mismatchcomment_limit_reachedcomment_cooldown
GET/api/v1/comments
API keyComments budget — 1000/day
Comments across a URL

Everything the community said about one URL, across every scan of it — newest first, up to 200 rows, each row carrying the scan_id it was left on. The URL is normalized exactly like a scan submission (missing scheme becomes https://), then matched exactly.

A URL lookup is a listing, so it follows the same rules as search: only comments on public scans appear, plus comments on your own scans whatever their visibility. Hidden scans stay link-only and private scans someone else owns stay invisible.

Query parameters

url
stringrequired
The scanned URL to look up, matched exactly after normalization. URL-encode the value if the target URL itself contains &, ? or #.

Request

curl -s "https://api.voretix.com/api/v1/comments?url=https://suspicious-site.example/login" \
  -H "X-API-Key: vx_YOUR_KEY"
import requests

r = requests.get(
    "https://api.voretix.com/api/v1/comments?url=https://suspicious-site.example/login",
    headers={"X-API-Key": "vx_YOUR_KEY"},
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/comments?url=https://suspicious-site.example/login", {
  headers: { "X-API-Key": "vx_YOUR_KEY" },
});
console.log(await res.json());

Response

{
  "ok": true,
  "url": "https://suspicious-site.example/login",
  "count": 2,
  "comments": [
    {
      "scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
      "username": "hunter7",
      "source": "api",
      "comment": "Reported to the registrar, hosting abuse notified.",
      "created_at": "2026-07-07T11:02:41"
    },
    {
      "scan_id": "0197e884-2c0d-7b12-8e4a-1f6b9c3d2a55",
      "username": "analyst01",
      "source": "web",
      "comment": "Same kit as the campaign from last week.",
      "created_at": "2026-07-05T09:14:22"
    }
  ],
  "quota": {
    "monthly_limit": 500,
    "used_this_month": 18,
    "remaining": 482,
    "resets_at": "2026-08-01T00:00:00+00:00"
  }
}
unknown_parameterinvalid_url
POST/api/v1/scans/{scan_id}/report
API keyComments budget — 1000/dayOne per scan
Report a problem with a scan

Flags a scan for review — the API equivalent of the report page's Report button. Pick a reason and, optionally, tell us more in your own words. Each account can report a given scan once; a repeat answers 409 already_reported. The scan's URL and domain are recorded from the scan row server-side — you never send them.

Body fields

reason
stringrequired
Why you are reporting the scan.wrong-classificationreport-bugnsfw-illegalother
message
stringoptional
Free text, up to 2000 characters. Optional — except with reason other, where it is required.

Request

curl -s -X POST "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/report" \
  -H "X-API-Key: vx_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "wrong-classification",
    "message": "This is our company staging site, not a phishing page."
}'
import requests

r = requests.post(
    "https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/report",
    headers={"X-API-Key": "vx_YOUR_KEY"},
    json={
        "reason": "wrong-classification",
        "message": "This is our company staging site, not a phishing page."
    },
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/scans/0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70/report", {
  method: "POST",
  headers: { "X-API-Key": "vx_YOUR_KEY", "Content-Type": "application/json" },
  body: JSON.stringify({"reason":"wrong-classification","message":"This is our company staging site, not a phishing page."}),
});
console.log(await res.json());

Response

{
  "ok": true,
  "scan_id": "0197f9a2-4b1c-7e3a-9d5f-8c2b6a1e4d70",
  "reason": "wrong-classification",
  "quota": {
    "monthly_limit": 500,
    "used_this_month": 18,
    "remaining": 482,
    "resets_at": "2026-08-01T00:00:00+00:00"
  }
}
invalid_scan_idinvalid_bodyunknown_fieldinvalid_reasoninvalid_messagescan_not_foundalready_reported
GET/api/v1/hunt
API keyHunt budget — 50–2000/day by plan30/min burst cap
Advanced hunting

Pivot across all public scans by page content, infrastructure, detections, file hashes or structural similarity — the same engine behind the website's Hunt page. Every query parameter is a named filter from the filter catalog; anything unrecognized is rejected rather than ignored, so a typo can never silently widen a hunt.

  • All filters AND together. A repeated filter must hold for every value.
  • At most 15 filter conditions per request.
  • *_contains filters are case-insensitive substring matches of 3–200 characters; % and _ match literally.
  • Only public scans are huntable — private and hidden scans never appear, and visibility itself is not a filter.
  • Results come newest-first with keyset pagination: when has_more is true, pass cursor=<next_cursor> to get the next page.
  • Each request spends the API key owner's account-wide daily hunt allowance. Signed-in website hunts spend the same counter, and API-key rotation does not reset it.

Modifiers

limit
integeroptional
Page size, 1–200. Default: 50
cursor
stringoptional
Only return scans older than this scan id — pass the previous page's next_cursor.
max_distance
integeroptional
Hamming-distance ceiling for the simhash / similar_to filters, 0–32. Default: 8

Request — young domains impersonating a brand

curl -s "https://api.voretix.com/api/v1/hunt?brand=PayPal&domain_age_max_days=30&limit=50" \
  -H "X-API-Key: vx_YOUR_KEY"
import requests

r = requests.get(
    "https://api.voretix.com/api/v1/hunt?brand=PayPal&domain_age_max_days=30&limit=50",
    headers={"X-API-Key": "vx_YOUR_KEY"},
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/hunt?brand=PayPal&domain_age_max_days=30&limit=50", {
  headers: { "X-API-Key": "vx_YOUR_KEY" },
});
console.log(await res.json());

Response

When a hunt uses simhash or similar_to, each result row gains a simhash_distance column — the Hamming distance to your probe, 0 meaning structurally identical.
unknown_filterno_filterstoo_many_filtersinvalid_filterinvalid_modifierinvalid_cursordaily_rate_limitedburst_rate_limited
GET/api/v1/hunt/filters
API keyStatus tier — free
Hunt filter catalog (machine-readable)

Self-documenting catalog of every hunt filter and modifier — the same information as the reference below, as JSON. Use it to build filter pickers that stay current automatically.

Request

curl -s "https://api.voretix.com/api/v1/hunt/filters" \
  -H "X-API-Key: vx_YOUR_KEY"
import requests

r = requests.get(
    "https://api.voretix.com/api/v1/hunt/filters",
    headers={"X-API-Key": "vx_YOUR_KEY"},
)
print(r.status_code, r.json())
const res = await fetch("https://api.voretix.com/api/v1/hunt/filters", {
  headers: { "X-API-Key": "vx_YOUR_KEY" },
});
console.log(await res.json());

Response (abbreviated)

Hunt filter catalog
Every filter GET /api/v1/hunt accepts — searchable

All 72 filters, grouped like the website's hunt builder. Combine up to 15 of them; every filter ANDs with the rest. Type to search, or click a group to narrow the list.

72 filters
url_contains
Scan & URL
Substring of the submitted URL or the final URL after redirects.
?url_contains=wp-login
url_path
Scan & URL
URL path fragment (must start with '/') matched against the submitted and final URL.
?url_path=/account/verify
redirect_url_contains
Scan & URL
Substring of any redirect hop URL (from or to).
?redirect_url_contains=bit.ly
scanned_after
Scan & URL
Scans scheduled at or after this ISO date/time.
?scanned_after=2026-06-01
scanned_before
Scan & URL
Scans scheduled at or before this ISO date/time.
?scanned_before=2026-07-01T12:00:00
completed
Scan & URL
Scan state: pending / completed / underway (or 0/1/2).
?completed=completed
location
Scan & URL
Scanner location the scan ran from (exact location code, e.g. de, us-dallas).
?location=de
platform
Scan & URL
Platform the scan emulated (exact).
?platform=Windows
browser
Scan & URL
Browser the scan used (exact).
?browser=Chrome
language
Scan & URL
Browser language of the scan (exact).
?language=en-US
tor
Scan & URL
Whether the scan was routed through Tor (boolean).
?tor=1
ip
Network & HTTP
IP the scanned site answered from: resolved address, main-document peer or any redirect-hop peer.
?ip=203.0.113.7
http_status
Network & HTTP
HTTP status of the main document.
?http_status=403
server_contains
Network & HTTP
Substring of the main document's Server header value.
?server_contains=nginx
header_contains
Network & HTTP
Substring anywhere in the main document's response headers (JSON).
?header_contains=x-powered-by
mime_type
Network & HTTP
Exact MIME type of the main document.
?mime_type=text/html
host
Domain · WHOIS · DNS
Exact scanned hostname.
?host=login.example.com
host_contains
Domain · WHOIS · DNS
Substring of the scanned hostname.
?host_contains=paypal
registrar
Domain · WHOIS · DNS
Exact domain registrar name.
?registrar=NameCheap, Inc.
registrar_contains
Domain · WHOIS · DNS
Substring of the domain registrar name.
?registrar_contains=namecheap
registrar_abuse_email
Domain · WHOIS · DNS
Exact registrar abuse-contact email.
owner_organization_contains
Domain · WHOIS · DNS
Substring of the WHOIS owner organization.
?owner_organization_contains=privacy
owner_country
Domain · WHOIS · DNS
Exact WHOIS owner country code.
?owner_country=US
owner_email_domain
Domain · WHOIS · DNS
Exact domain of the WHOIS owner email.
?owner_email_domain=protonmail.com
owner_email_hash
Domain · WHOIS · DNS
Exact WHOIS owner email hash (64 hex chars) — pivots on the same registrant across domains.
?owner_email_hash=<sha256>
whois_hidden
Domain · WHOIS · DNS
Whether WHOIS data is privacy-protected (boolean).
?whois_hidden=1
dnssec
Domain · WHOIS · DNS
Exact DNSSEC status string from WHOIS.
?dnssec=unsigned
domain_created_after
Domain · WHOIS · DNS
Domain creation date on or after this ISO date.
?domain_created_after=2026-05-01
domain_created_before
Domain · WHOIS · DNS
Domain creation date on or before this ISO date.
?domain_created_before=2026-06-30
domain_age_max_days
Domain · WHOIS · DNS
Domain age at scan time was at most this many days (young-domain hunting).
?domain_age_max_days=30
domain_age_min_days
Domain · WHOIS · DNS
Domain age at scan time was at least this many days.
?domain_age_min_days=3650
nameserver_contains
Domain · WHOIS · DNS
Substring of any authoritative nameserver hostname.
?nameserver_contains=cloudflare
nameserver_ip
Domain · WHOIS · DNS
Exact IPv4/IPv6 of any authoritative nameserver.
?nameserver_ip=198.51.100.53
tls_issuer_contains
TLS Certificate
Substring of the TLS certificate issuer.
?tls_issuer_contains=Let's Encrypt
tls_subject_contains
TLS Certificate
Substring of the TLS certificate subject name.
?tls_subject_contains=example
tls_san_contains
TLS Certificate
Substring of any TLS certificate SAN entry.
?tls_san_contains=.example.com
tls_protocol
TLS Certificate
Exact TLS protocol version.
?tls_protocol=TLS 1.3
title
Page Content
Exact rendered page title.
?title=Sign in to your account
title_contains
Page Content
Substring of the rendered page title.
?title_contains=verify
dom_text_contains
Page Content
Substring of the rendered DOM body text sample.
?dom_text_contains=your account has been limited
summary_contains
Page Content
Substring of the AI content summary.
?summary_contains=credential
has_challenge_text
Page Content
Whether the page showed anti-bot challenge text (boolean).
?has_challenge_text=1
link_url_contains
Page Content
Substring of any navigable link URL on the page.
?link_url_contains=t.me/
link_text_contains
Page Content
Substring of any link's anchor text.
?link_text_contains=unlock account
image_url_contains
Page Content
Substring of any <img> URL loaded on the page.
?image_url_contains=logo
image_alt_contains
Page Content
Substring of any image's alt text.
?image_alt_contains=bank
technology
Technologies
Exact detected technology name.
?technology=WordPress
technology_contains
Technologies
Substring of any detected technology name.
?technology_contains=cpanel
technology_version
Technologies
Exact detected technology version (combine with technology).
?technology_version=5.8.1
technology_category_contains
Technologies
Substring of any detected technology's categories.
?technology_category_contains=CDN
verdict
Detections
Exact AI verdict (clean / suspicious / malicious / ...).
?verdict=malicious
brand
Detections
Exact brand the page was judged to impersonate.
?brand=PayPal
brand_contains
Detections
Substring of the targeted brand.
?brand_contains=micro
ai_confidence_min
Detections
Minimum AI verdict confidence (0-100).
?ai_confidence_min=80
ai_reason_contains
Detections
Substring of the AI verdict reasoning.
?ai_reason_contains=fake login
rule
Detections
Exact heuristic/YARA rule name that matched.
?rule=phishkit_login_form
rule_contains
Detections
Substring of any matched heuristic/YARA rule name.
?rule_contains=phishkit
heuristic_category
Detections
Exact category of any heuristic match.
?heuristic_category=credentials
heuristic_severity_min
Detections
At least one heuristic match with severity >= this value.
?heuristic_severity_min=70
heuristic_tag_contains
Detections
Substring of any heuristic match's tags.
?heuristic_tag_contains=obfuscation
yara_similar_to
Detections
Scans sharing at least one matched heuristic/YARA rule with this ScanID (seed excluded).
?yara_similar_to=<scan_id>
asset_sha256
Files & Screenshots
Scans that served a file with this SHA-256.
?asset_sha256=<64 hex>
asset_sha1
Files & Screenshots
Scans that served a file with this SHA-1.
?asset_sha1=<40 hex>
asset_md5
Files & Screenshots
Scans that served a file with this MD5.
?asset_md5=<32 hex>
asset_url_contains
Files & Screenshots
Substring of any loaded asset URL.
?asset_url_contains=/kit/config.js
asset_kind
Files & Screenshots
Exact asset kind (css, js, img, ...).
?asset_kind=js
asset_mime_type
Files & Screenshots
Exact MIME type of any loaded asset.
?asset_mime_type=application/zip
screenshot_sha256
Files & Screenshots
Scans whose full-page or above-the-fold screenshot has this SHA-256 (pixel-identical render).
?screenshot_sha256=<64 hex>
html_sha256
Files & Screenshots
Scans whose captured page HTML has this SHA-256 (byte-identical document).
?html_sha256=<64 hex>
shares_assets_with
Files & Screenshots
Scans serving at least one file (same sha256) also seen in this ScanID (seed excluded).
?shares_assets_with=<scan_id>
simhash
Structural Similarity
128-bit structural simhash (32 hex chars) within max_distance Hamming bits; adds a simhash_distance result column.
?simhash=<32 hex>
similar_to
Structural Similarity
Scans structurally similar to this ScanID's fingerprint within max_distance Hamming bits (seed excluded).
?similar_to=<scan_id>
No filter matches — try a different term or clear the group.
Error codes
Every machine-readable error the API can answer

Branch on error, not on message. Statuses group naturally: 4xx means fix the request (or wait, for 429); 5xx means retry later with backoff.

CodeHTTPWhen
missing_api_key 401 No API key in the request. Send it as X-API-Key or Authorization: Bearer.
invalid_api_key 403 The key is malformed, unknown, or has been revoked / regenerated.
rate_limited 429 The key's monthly scan quota is exhausted (POST /scans only). Retry-After gives the seconds until the reset at 00:00 UTC on the 1st.
daily_rate_limited 429 A daily section budget is exhausted — the account-wide hunt allowance shared by website and API (plan-based: 50–2000/day), search or comments (1000/day each). Retry-After counts down to the reset at 00:00 UTC.
burst_rate_limited 429 Too many requests in the last minute — the per-IP guard (120/min), the hunt/related cap (30/min per key) or the status-tier cap (1000/min). Retry-After: 60.
invalid_body 400 The request body is not a JSON object.
unknown_field 400 The request body contains a field that endpoint does not accept (the message lists what is expected).
invalid_option 400 A configuration value is not in its allowed list (scan options, tor, or visibility).
invalid_url 400 The URL failed validation; the message explains exactly why.
invalid_scan_id 400 The scan id is not a valid id (1–50 chars of A–Z a–z 0–9 . _ -).
scan_not_found 404 No such scan — or it is private and this key does not own it (both answer identically).
not_scan_owner 403 Visibility can only be changed by the account that scheduled the scan.
url_mismatch 409 The url in a comment body does not match the scan's stored URL — check you are posting to the right scan id.
invalid_comment 400 The comment is empty (or empty once sanitized to plain text), or longer than 1000 characters.
comment_limit_reached 429 You already left 10 comments on this scan — the per-user cap per scan.
comment_cooldown 429 Less than 15 seconds since your previous comment (on any scan). Wait a few seconds and retry.
invalid_reason 400 The report reason is not one of: wrong-classification, report-bug, nsfw-illegal, other.
invalid_message 400 The report message is not a string, exceeds 2000 characters, or is missing when reason is "other".
already_reported 409 This account already reported this scan — one report per user per scan.
invalid_account 403 The key's account name cannot author comments or reports (it is empty after sanitization).
unknown_parameter 400 The query string contains a parameter that endpoint does not know (search / comments-by-URL).
invalid_query 400 A search parameter is out of range or not in its allowed set.
unknown_filter 400 Hunt received a parameter that is not a known filter or modifier.
no_filters 400 A hunt needs at least one filter.
too_many_filters 400 More than 15 filter conditions in one hunt.
invalid_filter 400 A hunt filter value failed validation; the message names the filter.
invalid_modifier 400 limit or max_distance is not an integer in its allowed range.
invalid_cursor 400 The hunt cursor is not a valid scan id.
payload_too_large 413 The request body exceeds 64 KB.
not_found 404 No such endpoint. Unknown paths never spend quota.
method_not_allowed 405 The endpoint exists, but not for this HTTP method.
database_error 503 Transient storage problem — retry with backoff.
internal_server_error 500 Unexpected failure on our side.

The API also documents itself at runtime: GET /api/v1/scans/options and GET /api/v1/hunt/filters return machine-readable catalogs that always match the deployed service — including your live quota. Ready to start? Generate your API key.